Repository navigation
Report a request the API took and never answered before RESTest stopped waiting - #405
Merged
Merged
Conversation
…st stopped waiting (F312) The engine records what it was still waiting for when one of its time limits ran out; a new rule reports a request it had on an open connection and never answered, with a switch, checks.noReplyInTime. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
… shared test helpers Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…and headers, both causes in the closing line Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
12 tasks done
ssegura
added a commit
that referenced
this pull request
Oct 17, 2026
…t declare, and a 501 to an operation it declares (#406) **Related:** None. The fourth of the rules of fault the plan's version 2.2.0 adds, after #403 and #405. ## What you can do now that you could not before A run now tells you when the API answers **what its document never promised**: a status code the document does not declare for the operation — `201` where it promises `200`, a `400` it never mentions — a content type it does not declare for that status — `application/problem+json` where it promises `application/json` — and `501 Not Implemented` to an operation the document declares. Until now all three passed as `no faults found`. ## Try it yourself With only Java and git. The "API" is a stand-in of a few lines on the small HTTP server that comes with Java, and every one of its four operations but one answers something its document does not say: `/pets` answers as documented, `/vets` answers `201` where the document says `200`, `/owners` sends its `404` as `application/problem+json` where the document says `application/json`, and `/clinics` answers `501`. ```bash git fetch origin && git switch feat/m3-1d-undeclared-replies ./mvnw -q install -DskipTests mkdir -p try cat > try/Stand.java <<'EOF' import com.sun.net.httpserver.HttpServer; import java.net.InetSocketAddress; /** A stand-in clinic: pets as documented, vets 201, owners as problem+json, clinics 501. */ public class Stand { public static void main(String[] args) throws Exception { HttpServer server = HttpServer.create(new InetSocketAddress("127.0.0.1", 8765), 0); server.createContext("/pets", exchange -> answer(exchange, 200, "application/json", "[]")); server.createContext("/vets", exchange -> answer(exchange, 201, "application/json", "[]")); server.createContext("/owners", exchange -> answer(exchange, 404, "application/problem+json", "{\"title\": \"no owners yet\"}")); server.createContext("/clinics", exchange -> answer(exchange, 501, "text/plain", "later")); server.start(); } private static void answer(com.sun.net.httpserver.HttpExchange exchange, int status, String type, String text) throws java.io.IOException { byte[] body = text.getBytes(); exchange.getResponseHeaders().add("Content-Type", type); exchange.sendResponseHeaders(status, body.length); exchange.getResponseBody().write(body); exchange.close(); } } EOF cat > try/clinic.yaml <<'EOF' openapi: 3.0.3 info: {title: Clinic, version: "1"} paths: /pets: get: operationId: listPets responses: "200": {description: the pets, content: {application/json: {schema: {type: array, items: {type: object}}}}} /vets: get: operationId: listVets responses: "200": {description: the vets, content: {application/json: {schema: {type: array, items: {type: object}}}}} /owners: get: operationId: listOwners responses: "200": {description: the owners, content: {application/json: {schema: {type: array, items: {type: object}}}}} "404": {description: no owners, content: {application/json: {schema: {type: object}}}} /clinics: get: operationId: listClinics responses: "200": {description: the clinics} EOF java try/Stand.java & ./restest run try/clinic.yaml --url http://127.0.0.1:8765 --budget 10s --seed 1 ``` Real output, from a fresh clone of this branch (the first three faults printed, then the end); the run ends with `1`: ``` RESTest testing Clinic at http://127.0.0.1:8765 4 of 4 operations can be tested, seed 1, budget 10s what it sends depends on the API's own replies, so the seed alone does not repeat this run; --store keeps what it sent F205 Schema Violation: no-501-if-implemented listClinics - GET http://127.0.0.1:8765/clinics -> 501 the API answered 501, not implemented, to an operation its document declares curl -i -X GET 'http://127.0.0.1:8765/clinics' -H 'Accept: */*' -H 'User-Agent: RESTest/2.0' F920 A Status Code The Document Does Not Declare listVets - GET http://127.0.0.1:8765/vets -> 201 the API answered 201, which the document does not declare for this operation; it declares 200 curl -i -X GET 'http://127.0.0.1:8765/vets' -H 'Accept: application/json' -H 'User-Agent: RESTest/2.0' F921 A Content Type The Document Does Not Declare listOwners - GET http://127.0.0.1:8765/owners -> 404 the API answered 404 as application/problem+json, which the document does not declare for that status; it declares application/json curl -i -X GET 'http://127.0.0.1:8765/owners' -H 'Accept: application/json' -H 'User-Agent: RESTest/2.0' ... 604082 requests to 4 operations in 10.0s, 3% of it idle opening lap: 4 requests in 133ms, 2 of 4 operations answered 2xx 302042 2xx, 151020 4xx, 151020 5xx 0 operation(s) answered 500, 1 answered some 5xx 453061 faults: 151020 x F205 Schema Violation: no-501-if-implemented 151021 x F920 A Status Code The Document Does Not Declare 151020 x F921 A Content Type The Document Does Not Declare report written to restest-out/report.json (30.0 KiB) the run itself was not kept; pass --store to keep every request and reply ``` `/pets` is never mentioned. The same run on `master` (`08d1eda8`) ends with `0` and says `no faults found`. Each check switches off in one line, and the others carry on; with the one for status codes off: ```bash ./restest run try/clinic.yaml --url http://127.0.0.1:8765 --budget 10s --seed 1 --set checks.undeclaredStatusCode=false ``` ``` 1 setting(s) are not what RESTest does by default; --print-settings lists them with where each came from ... 299641 faults: 149820 x F205 Schema Violation: no-501-if-implemented 149821 x F921 A Content Type The Document Does Not Declare ``` The other two are `checks.undeclaredContentType` and `checks.notImplemented`. Stop the stand-in with `kill %1` and remove `try/` and `restest-out/`. ## How it works Three rules more judge every reply. **A status code the document does not declare** (`F920`): for an operation that declares at least one reply, a code below 500 that the document neither names, nor covers with a range such as `4XX`, nor with a `default` reply. **A content type the document does not declare** (`F921`): a reply with a body, whose status code the document declares with at least one media type, sent under a type the document does not list for it — case and parameters such as `charset` aside, a declared range such as `application/*` covering what it names, and `application/problem+json` *not* the same as `application/json` — or sent with no type at all. **Not implemented** (`F205`): a `501` to an operation the document declares, whether or not it lists `501` among its replies. Server errors are left to the rules that already report them (`F100`, `F121`, now `F205`), so no reply counts twice over its status code. Nothing uncertain is claimed: an operation RESTest could read only in part — a reply pointing at a shape the document never defines, say — is left alone, since a reply it could not read would otherwise look undeclared; so is a document declaring `*/*`, which promised no type, and a reply to a request whose body RESTest sent, on purpose, as a type the operation does not take. `F205` is a number of the shared catalogue of faults (Web Fuzzing Commons). The catalogue has none for the other two, and leaves 900 to 999 to each tool, so they are RESTest's own numbers, with keys starting `RESTEST_`; EvoMaster and Schemathesis, which use the catalogue most, count both disagreements under `F200` instead. ADR-0036 says why RESTest does not. ## What changed in the code - **`restest-oracles`**: `UndeclaredStatusOracle`, `UndeclaredContentTypeOracle` and `NotImplementedOracle`, with `HeldToTheDocument`, which says when a reply can fairly be held to the document (status 100–499, an operation the model knows and read in full, a request body not sent as a type the operation refuses). Both undeclared rules reuse the model's matching: `Operation.responseFor` (exact, then `4XX`, then `default`), `ResponseModel.declaredContentTypeFor` and `RequestBodyModel.contentFor` (exact, then `type/*`, then `*/*`). The content type quoted in a fault is cut at 100 characters, with control and invisible formatting characters replaced, since the API writes it. `ResponseSchemaOracle.hasBody` is now shared within the package. - **`restest-core`**: `RestestFault`, RESTest's own kinds beside `WfcFault` behind `FaultCategory` (920, 921); `ApiModel.readInPart(OperationId)`, beside `unreadableOperations()`; `StatusCodes.isHttpStatus`, also used by `InteractionQuery`, which repeated the range; three switches in `CheckSettings`, `SettingKey` and `Settings`, in the order of the numbers of what they report. - **Tests**: a class for each rule and for `RestestFault`; `OraclesTest`, `SettingsTest`, `StatusCodesTest` and `ApiModelTest` extended; `CheckSwitchesCommandTest` runs a stand-in wrong in every way a check looks for, now eight kinds, and turns each new switch off on its own, checking the screen and `report.json`; a new `AReplyUnreadIsNotUndeclaredTest` reads a document with a broken `$ref` to a `404` through the real reader; `DocumentedReportTest` now reads RESTest's own kinds from the rules too. - **Documentation**: `docs/faults.md` (three sections, real output from the measurement below), `docs/switches.md`, `docs/settings.md`, `docs/report.md`, `docs/known-limitations.md`, the README, `docs/DESIGN.md`, the manual's chapters 1, 4, 10 and 11; ADR-0036 new; ADR-0014, 0016, 0025 and 0027 amended. - **Left for later**: grouping faults by operation and status code (the reports count every reply); severities, so that these do not fail a build; silencing a check on one operation; checking a body the document describes only in words; response headers; undeclared methods. ## Evidence - [x] Tests: 61 new test methods, several of them parameterised, in five new classes and nine changed ones; `./mvnw verify` green on all nine modules, 4,560 tests (4,422 before) — CI: https://github.com/isa-group/RESTest/pull/406/checks - [x] Architecture tests: unchanged rules, all green; the documentation rule that holds the faults page to the rules' code now reads RESTest's own kinds too, which makes it stricter — none weakened - [x] Mutation score, `restest-oracles`: 98% (248 of 254; 97%, 195 of 201, before), every mutant in the new classes killed; three that survived the first pass found three missing tests, now added - [x] Per-request overhead: below what a minute's run can measure. On bibliothek, where 87.5% of requests met a new kind, three pairs of one-minute runs with the three checks on and off, alternated, sent 0.3% more requests on average with them on (+1.7%, −0.7%, −0.3%); on swagger-petstore, where none was met, the pairs moved by −1.5%, −15.4% and −0.5%, which is the noise of one-minute runs - [x] Smoke run, the pet shop and the scouts' catalogue, 10 s, seed 20260914, three runs each: operations tested 19 and 49 (19 and 49 before), answered 2xx in the opening lap 14–15 and 18 (14–15 and 18), operations answering 500 16 and 12 (15–16 and 12), kinds of fault 3 and 3–4 (3 and 2); operations with faults 17 and 29 (17 and 23): the scouts' catalogue now reports `F920` on six more operations. The pet shop's document declares `default` replies, so it gets none of the new kinds; its requests, 88,476–95,416 (94,569–103,050 before), vary as much as the cost runs found it varies with nothing to report - [x] Idle time: 4.1–4.5% on both smoke APIs (4.0–4.5% before); about 1% in the one-minute runs below - [x] Artefact: fifteen APIs of the Web Fuzzing Dataset run locally, a minute each, seed 42, 3,553,050 requests; each run's counts checked against a replay of the three rules written apart from RESTest's code, which agreed on every operation of fourteen APIs, and on the fifteenth but for the eight operations described under *Decisions taken*: | Kind | Faults | APIs | What there is to fix | Read by hand | |---|---|---|---|---| | `F920` | 1,443,634 | 13 | 205 pairs of operation and status code: 400 in 93, 404 in 59, 204 in 14, 422 in 9, 201 in 7, 302 in 6, 403 in 6, 406 in 4, 414 in 3, 415 in 3, 409 in 1 | 20 of 20 real: refusals the document leaves out, a 204 where a generated document says 200 | | `F921` | 16,933 | 2 | 44 pairs of operation, status code and type: `application/problem+json` for `application/json` in 25, `text/html` error pages in 19 | 20 of 20 real | | `F205` | 21,419 | 1 | 1 operation, which answers 501 when it has nothing to return | 5 of 5 real | One run's exit code changed: bibliothek's only faults are `F920`, 580,699 of them on 14 pairs, so it ends with `1` where it ended with `0`. On spring-rest-example, the fifty faults the screen writes out whole were all `F920` and `F205`, and none of its 10,902 replies of 500 — a new line in the known limitations - [x] Size: no dependency added; the archive and the image grow by the new classes only ## Decisions taken - **What the rules judge, decided by the maintainer after a study of nine tools' code and a replay over stored runs** (ADR-0016's amendment and ADR-0036 have the detail): - `default` and ranges count as declared, as in Schemathesis, EvoMaster, Prism and Tcases; - no 5xx is judged by the new rules, unlike Schemathesis and EvoMaster, which count an undeclared 500 twice; - a reply to a body sent under a type the operation does not accept is not judged, as Schemathesis exempts its probes, decided by the request's `Content-Type` rather than by the name of the change that sent it, so that any such request is covered; - content types are compared strictly on `+json`, as Schemathesis, EvoMaster and Tcases do and CATS and Prism do not; - a 501 is `F205` even where the document lists it, as EvoMaster and Schemathesis do; - RESTest's own numbers, not `F200`; on by default; every reply counted, as for every kind. - **What the review of the plan settled with the maintainer**: - an operation RESTest read only in part is not judged, rather than changing how documents are read; - the content-type rule skips 5xx too; - a document declaring only `*/*` is never contradicted; - tests whose stand-in answers codes their document left out declare them in the document, never by switching the rule off: `pet-shelter.yaml` now declares `404` on its four operations and `400` on `addPet`, and the inline document of `RestestTest.an_unreadable_operation_never_shadows_a_readable_one_of_the_same_name` declares `404` on `getVet`. - **"Read in part" applies to the content-type rule as well as to the status-code rule.** The plan named the status-code rule; the reason is the same for both — a `404` the document declares through a broken reference, beside a `default` reply, would otherwise be judged against `default`'s type — and the maintainer confirmed it after the code review. - **"Read in part" is coarser than it needs to be, kept as approved.** An operation renamed because the document repeats its identifier counts as read in part, and on scout-api that hides eight operations whose replies were read whole (20 pairs, 31,283 faults). It is a known limitation, and a separate change will tell a rename apart from a part that could not be read. - **A reply the parsing library drops on its own** — `"404": "not found"`, a word where an object belongs — leaves a warning about the document that names no operation, so that operation's 404s are `F920`. Kept and documented, by the maintainer's choice, rather than reading the library's messages. - **Where code went, against the plan's sketch**: whether an operation was read in part is `ApiModel.readInPart`, beside `unreadableOperations()`, checked per reply, rather than a cache kept by the oracles; `StatusCodes.isHttpStatus` is new, and replaces the range `InteractionQuery` wrote out. - **The three undeclared `415`s** in the measurement are `PUT` requests sent with no body, which one API refuses as `Content type '' not supported`: true disagreements, not requests the exemption should have caught. - **Not taken from the code-quality pass**: an index for `ApiModel.operation`, which every rule would gain from and which belongs in a change of its own; accessors for a record's `Content-Type` in `restest-core`, wider than this change; and merging the two rules' tests of what they leave alone, which each rule's tests keep as part of describing it. ## Open questions None. --- - [x] Targets `master` - [x] English throughout, including comments and test names - [x] The hard rules in `AGENTS.md` hold - [x] Nothing from "Out of scope" in `docs/DESIGN.md` without explicit approval 🤖 Generated with [Claude Code](https://claude.com/claude-code) --------- Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Related: None. The last of the three rules of fault the plan's version 2.2.0 takes from requests RESTest already sends, after #403.
What you can do now that you could not before
A run now tells you when the API took a request and never answered it: the connection was open, the request went out, and nothing came back before RESTest stopped waiting — half a minute by default. Until now such a request was only one more
noReplyin a count, and a run whose only problem was an operation that hangs ended withno faults found.Try it yourself
With only Java and git. The "API" is a stand-in of a dozen lines on the small HTTP server that comes with Java:
/petsanswers at once,/kennelstakes a minute. The run waits two seconds for a reply instead of thirty, so that it is quick.Real output, from a fresh clone of this branch (the first two of the faults printed, then the end); the run ends with
1:Every request to
/kennelsis a fault; every one to/petswas answered and is not. The check switches off in one line, and the run then reports nothing, asmasterdoes — the requests are still counted, underno reply:That run ends with
0. Stop the stand-in withkill %1and removetry/andrestest-out/.The run lasts longer than its budget because it waits, at the end, for the answers to requests it has already sent; that is not new. A run against an address where nothing answers anything is tested end to end by
RestestTest:How it works
RESTest gives each request a set time: half a minute for anything to arrive (
engine.readTimeout), a minute for the whole request (engine.callTimeout). Until now, when the time ran out, the part that sends requests wrote down only a sentence such asSocketTimeoutException: timeout, which said the same as a connection that was refused or cut. It now also writes down what it was still waiting for: a connection, when none could be made in time, so the request never reached the API; or a reply, when a connection to the API was open and the status line and headers that begin a reply had not come back. It knows which because it notes, at the last step before the request goes on the wire, that a reply is awaited, and crosses the note out when one starts to arrive.A new rule reads that note: a request that timed out waiting for a reply is a fault,
F312, which the shared catalogue of faults (Web Fuzzing Commons, Faults 0.9.0) calls Call Timeout. A connection that could not be made in time is not one — a wrong or firewalled address looks exactly the same — and nor is a reply that began and then stopped half-way, which is what an endpoint that streams does. The rule has a switch,checks.noReplyInTime, on by default.What was waited for is kept with the request in
report.jsonand in the stored run, as"timedOutWaitingFor": "reply", so a stored run can be judged again later. A run in which nothing answered still ends with3("nothing was tested"), since something that accepts connections and never speaks HTTP does exactly the same; its last line now says how many requests were sent on an open connection and never answered, and that an API that is only slow can be given longer.What changed in the code
restest-core:InteractionOutcome.TransportFailuregainsOptional<Wait> timedOutWaitingFor, withWaiteitherCONNECTIONorREPLY;Interactiongains atransportFailure(...)that takes it, andtimedOutWaitingForAReply(), the one check the rule and the run loop share.InteractionDocumentwritestimedOutWaitingForonly when there is one and reads it back, so a document written before reads as no timeout, and refuses a word it does not know.Secrets, which hides a key handed over with--auth, keeps the wait in both places it rebuilds a failure.CheckSettings,SettingKeyandSettings:checks.noReplyInTime. Ninety-four settings in nine groups.restest-exec:WireCapture.Slot.awaitingAReply, set just before the network interceptor passes a request on and cleared when the status line and headers come back. OkHttp runs that interceptor only once a connection is open, and again for each address a redirection sends it to.OkHttpEngine.timedOutWaitingFor(failure, awaitingAReply): aSocketTimeoutException, or anInterruptedIOExceptionon a thread that is not interrupted (OkHttp's limit on the whole request), is a timeout; anything else is not.restest-oracles:NoReplyInTimeOracle, registered inmodule-info.java, the services file and last inOracles.standard: 100, 121, 200, 206, 312.restest-cli:RunLoopcounts the requests that timed out waiting for a reply (Outcome.noReplyInTime);RunCommandsays so in the line a run ends with when nothing was answered.TimedOutWaitingForTest(9 cases) names each kind of failure;EngineFaultsTestmakes the real ones happen against real sockets — a read timeout, a status line that trickles in until the whole request runs out, a TLS handshake that never finishes (CONNECTION), a redirection to such an address (CONNECTION), a body that stalls after its headers (still a malformed reply).NoReplyInTimeOracleTest(14);InteractionTest,InteractionDocumentTest,SecretsTest,JsonReportTest;OraclesTestandSettingsTestover five switches;CheckSwitchesCommandTestwith a stand-in operation that answers after four seconds, five kinds of fault, and the switch off;RestestTest, an address that takes connections and never answers, with the check on and off.docs/faults.md(the kind, with a real fault from a run, what is and is not reported, and why),docs/switches.md,docs/settings.md,docs/report.md(timedOutWaitingFor),docs/known-limitations.md(a hang the run's pace provoked, a pooled connection something in between dropped, a redirection followed to an address that hangs, a slow reply),docs/DESIGN.md, the README, the manual's chapters 1, 10 and 11. Amendments to ADR-0005 (what a missing reply records), ADR-0016 (F312, how other tools treat it, the measurements) and ADR-0025 (the switch).312); splittingnoReplyinreport.jsonby cause;report.json's JSON Schema, which is the next change toreport.json's.Evidence
./mvnw verifygreen on all nine modules, 4,422 tests — CI: CI_LINKrestest-oracles: 97% (195 of 201; 194 of 200 before), and every mutant in the new rule and inOracleskilledfetch, which gathers data from GitHub and waits on it (29), and kafka-rest's five operations on a broker's configuration, which hang for a cluster that does not exist instead of answering 404 (13). Run again with the rule, a minute each, seed 42: catwatch 30F312, all onfetch; kafka-rest 15 on six operations, the same five and one that sends a batch of records, which hung before any reply in this run. kafka-rest's 13 connections cut with nothing sent back, and its streaming endpoint that answered 200 and then sent nothing, were not reported. Both APIs also answered 500, so neither changes its exit codeDecisions taken
312for a reply slower than a limit, which is off unless a limit is given. ADR-0016's amendment has the details, checked in each tool's code.F312, the catalogue's Call Timeout. The catalogue files it among its security kinds, but it is not one of the security oraclesdocs/DESIGN.mdkeeps out of scope: it sends nothing and judges an outcome every run already records; the plan's row for it is the approval.3even when it reportedF312, and says how many requests sat on an open connection beside the advice; the line names both causes and how to give a slow API longer, and the manual's chapter 10 says the same. The count comes from the run loop, not from the faults reported, so it holds with the check off.engine.followRedirects, off by default).report.jsonhas no JSON Schema yet, by the maintainer's choice: it is left to the next change ofreport.json's shape, which changes it more than one optional member.TransportFailurewas dropped, having no caller, andInteraction.timedOutWaitingForAReply()was added so that the rule and the run loop share one check. The amendments are dated 17 October 2026, the day they were written; the decisions they record were taken on the 15th. The tests that need an address that takes connections and never answers use a socket that listens and is never read, whose connections the operating system completes on its own.TransportFailureto rebuild it with a new reason, which onlySecretswould call. One shared way of writing a number of seconds: the three that exist are a line each, in modules that cannot see one another. A shorter whole-request limit in the test of a status line that trickles in, which would save under a second at the price of the margin a busy build machine needs.Open questions
None.
masterAGENTS.mdholddocs/DESIGN.mdwithout explicit approval🤖 Generated with Claude Code