Repository navigation
Report a request broken on purpose that the API accepted, and a server error other than 500 - #403
Merged
Merged
Conversation
…r error other than 500 Two more kinds of fault, each with a switch of its own and on by default: F206, a request RESTest changed so that its document forbids it, answered with a success (checks.invalidDataAccepted); and F121, a reply of 502 to 599 (checks.otherServerError). The stand-in APIs of three command-line tests now refuse what their own documents forbid. ADR-0016, ADR-0025 and ADR-0027 are amended. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
9 of 12 tasks
ssegura
added a commit
that referenced
this pull request
Oct 15, 2026
…er error (#404) **Related:** None. Follows #403, whose two new rules carried the seventh and eighth copies of the checks this removes. ## What you can do now that you could not before Nothing visible: no run sends, keeps, counts or reports anything differently. What changes is that "was this reply a success?" and "was it a server error?" now have one answer in the code instead of eight hand-written ones, so a later change to one module cannot leave the others behind. ## Try it yourself The change is not visible from the command line. The next best thing is to look for the hand-written checks before and after, and to run the test that pins the one that is left: git fetch && git switch chore/one-status-code-check git grep -nE '[<>]=? +(200|299|300|500|599|600)([^0-9]|$)' -- '*/src/main/*.java' Expected: restest-core/src/main/java/io/restest/core/execution/StatusCodes.java:47: return statusCode >= 200 && statusCode <= 299; restest-core/src/main/java/io/restest/core/execution/StatusCodes.java:60: return statusCode >= 500 && statusCode <= 599; restest-core/src/main/java/io/restest/core/store/InteractionQuery.java:73: if (code < 100 || code > 599) { The same search on `master` finds the five success checks written as `code >= 200 && code < 300` (in `AcceptedRequests`, `ObservedValues`, `Sequences`, `ConsoleReport` and `InvalidDataAcceptedOracle`); three more were written with named constants (`Phases`, `ServerErrors`, `OtherServerErrorOracle`), which this deletes. The `InteractionQuery` line asks something else: whether a number is a status code at all. ./mvnw -q -pl restest-core test -Dtest=StatusCodesTest -Dsurefire.failIfNoSpecifiedTests=false cat restest-core/target/surefire-reports/io.restest.core.execution.StatusCodesTest.txt Expected: Tests run: 10, Failures: 0, Errors: 0, Skipped: 0, Time elapsed: 0.099 s -- in io.restest.core.execution.StatusCodesTest ## How it works Every reply starts with a three-digit status code. RESTest asks of it, in many places, one of two questions: is it a success (200 to 299), which decides whether to learn from a request, whether a series of requests can go on, whether a request broken on purpose was wrongly accepted, and what the reports count; or is it a server error (500 to 599), which decides what the reports count and whether a reply of 502 to 599 is a fault. Those places live in three modules that cannot share code with each other, so each had written the range out for itself. The answer now lives once, in the module every other one depends on. Each place still reads the status where it did: some count a reply that broke off after its status code arrived, others only a reply read whole, and that difference is deliberate and untouched. Only the range moved. ## What changed in the code - **New:** `io.restest.core.execution.StatusCodes`, a final class with two static methods on a plain `int`: `isSuccess` (200–299) and `isServerError` (500–599). `StatusCodesTest` tries the edges of both and 999. - **Success, six places:** `AcceptedRequests.worthLearningFrom`, `ObservedValues` (its private `theApiWasHappy` deleted), `Sequences.heard` and `ConsoleReport.theApiAccepted` read `Interaction.statusCode()`, as before; `Phases` (its `LOWEST_SUCCESS`/`HIGHEST_SUCCESS` deleted) too; `InvalidDataAcceptedOracle` reads `interaction.response()`, as before. - **Server error, two places:** `ServerErrors` (its `LOWEST_5XX`/`HIGHEST_5XX` deleted, `FELL_OVER = 500` kept) reads `Interaction.statusCode()`; `OtherServerErrorOracle` (its `LAST = 599` deleted) is now `isServerError(code) && code >= FIRST`, `FIRST = 502` kept, and reads `interaction.response()` as before. Both oracles now take the status code out of the reply once instead of reading it two or three times; their findings' wording is unchanged. - The paragraph of `ServerErrors.note()` about codes such as 999 is shortened there, and its point now sits on `isServerError`. - **Left alone, on purpose:** - `== 500` in `ServerErrorOracle` and `ServerErrors`: one named code, not a range (see "Decisions taken"). - The SQL filter in `SqliteInteractionStore` (`status_code >= ? AND status_code < ?` for `InteractionQuery.serverErrors()`): it runs inside SQLite, where a Java method cannot. It agrees with `isServerError`. - `InteractionQuery`'s check that a code lies between 100 and 599: whether a number is a status code at all. - `RequestBuilder`'s `response.status().startsWith("2")`: a status *declared in the document* (`"200"`, `"2XX"`, `"default"`), a string, not one received. - The family label `(code / 100) + "xx"` in `Phases`, `ConsoleReport` and `JsonReport`, and `"XX"` in `ResponseModel.rangeOf`: a label for display rather than a yes-or-no question; sharing it is a change of its own. ## Evidence - [x] Tests: 10 added (`StatusCodesTest`), all green; no existing test changed. `./mvnw verify` passes locally. — CI: <!-- link once it runs --> - [x] Architecture tests: unchanged — none weakened; all pass. - [x] Mutation score of `restest-oracles`: 97% (previous 97%) — 194 mutants, 188 killed, against 200 and 194 on `master` at 3d02961. The six fewer mutants were in the range comparisons the two oracles no longer hold: those now live in `StatusCodes`, in `restest-core`, which is not mutation-tested; its edges are pinned by `StatusCodesTest` and by the oracles' own tests. - [x] Behaviour held by the callers' tests: `InvalidDataAcceptedOracleTest` tries 199, 200, 299 and 300; `OtherServerErrorOracleTest` 501, 502, 599, 600 and 999; both check that a reply broken off after its status line is ignored, which is the source of the status those rules must keep. `AcceptedRequestsTest` tries 199 and 300, and `ServerErrorsTest` 599, 600, 999 and a reply broken off after its status line. The other callers' tests use ordinary codes; their source of the status did not change, only the range check. - [ ] Per-request overhead: not measured — a static method in place of the same comparison. - [ ] Smoke run, two containerised APIs: not run — nothing a run does changes; CI runs it. - [ ] Idle time: not measured, for the same reason. - [x] Artefact: the `git grep` above, on `master` and on this branch. ## Decisions taken - **A static check on the number, not a method on a reply.** Every caller already holds an `int`, from one of two sources that differ on purpose: `Interaction.statusCode()` counts a reply that broke off after its status line, `HttpResponseRecord.statusCode()` only a reply read whole. A check on the number fits both without changing which replies anybody counts. Methods on `StatusLine` would have needed `Interaction` to hand out a second, `Optional<StatusLine>` accessor beside `statusCode()`; methods on `Interaction` would not have fitted the callers that count only whole replies without changing what they count. - **Public, in `io.restest.core.execution`**, beside `StatusLine`, `HttpResponseRecord` and `Interaction`: the three modules that use it need it exported, and that package already is. It becomes part of what the library offers, which is harmless for two facts of HTTP. - **500 stays each place's own named constant.** `ServerErrorOracle` and `ServerErrors` each say "500" with a name and a reason of their own, and they deliberately count different replies (the report counts a 500 that broke off mid-body, the rule does not). A shared constant would share a number, not a behaviour, and a single code has no edge to get wrong. Read strictly, "no status code compared against 500 by hand" would include them; I read it as "no 5xx range written by hand". - **`OtherServerErrorOracle` keeps `FIRST = 502`** rather than `!= 500 && != 501`: it is the form already reviewed in #403, and its comment says why the first two codes are left out. - **`ConsoleReport.theApiAccepted` is kept** although it has one caller: its name says what the line that uses it means. - **Tuning numbers rule:** 200, 299, 500 and 599 are facts of HTTP, not decisions, so they are constants with no setting. ## Open questions - Should 500 also live in `StatusCodes`, as a constant used by `ServerErrorOracle` and `ServerErrors`? Left out for the reason above; a one-line change if wanted. --- - [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>
12 tasks done
ssegura
added a commit
that referenced
this pull request
Oct 17, 2026
…ed waiting (#405) **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 `noReply` in a count, and a run whose only problem was an operation that hangs ended with `no 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: `/pets` answers at once, `/kennels` takes a minute. The run waits two seconds for a reply instead of thirty, so that it is quick. ```bash git fetch origin && git switch feat/m3-1c-no-reply-in-time ./mvnw -q install -DskipTests mkdir -p try cat > try/Stand.java <<'EOF' import com.sun.net.httpserver.HttpServer; import java.net.InetSocketAddress; import java.util.concurrent.Executors; /** A stand-in API: /pets answers at once, /kennels takes a minute. */ 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 -> { byte[] body = "[{\"name\": \"Rex\"}]".getBytes(); exchange.getResponseHeaders().add("Content-Type", "application/json"); exchange.sendResponseHeaders(200, body.length); exchange.getResponseBody().write(body); exchange.close(); }); server.createContext("/kennels", exchange -> { try { Thread.sleep(60_000); } catch (InterruptedException stopped) { return; } exchange.sendResponseHeaders(200, -1); exchange.close(); }); server.setExecutor(Executors.newVirtualThreadPerTaskExecutor()); server.start(); } } EOF cat > try/kennels.yaml <<'EOF' openapi: 3.0.3 info: {title: Kennels, version: "1"} paths: /pets: get: operationId: listPets responses: "200": {description: the pets} /kennels: get: operationId: listKennels responses: "200": {description: "the kennels, after a minute"} EOF java try/Stand.java & ./restest run try/kennels.yaml --url http://127.0.0.1:8765 --budget 10s --seed 1 --set engine.readTimeout=2s ``` Real output, from a fresh clone of this branch (the first two of the faults printed, then the end); the run ends with `1`: ``` RESTest testing Kennels at http://127.0.0.1:8765 2 of 2 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 2 setting(s) are not what RESTest does by default; --print-settings lists them with where each came from F312 Call Timeout listKennels - GET http://127.0.0.1:8765/kennels -> no reply no reply within 2.0s: a connection to the API was open, and RESTest stopped waiting before a reply's status line and headers came back curl -i -X GET 'http://127.0.0.1:8765/kennels' -H 'Accept: */*' -H 'User-Agent: RESTest/2.0' F312 Call Timeout listKennels - GET http://127.0.0.1:8765/kennels -> no reply no reply within 2.0s: a connection to the API was open, and RESTest stopped waiting before a reply's status line and headers came back curl -i -X GET 'http://127.0.0.1:8765/kennels' -H 'Accept: */*' -H 'User-Agent: RESTest/2.0' ... 82 requests to 2 operations in 14.5s, 3% of it idle opening lap: 2 requests in 2.0s, 1 of 2 operations answered 2xx 41 2xx, 41 no reply 41 faults: 41 x F312 Call Timeout report written to restest-out/report.json (13.5 KiB) the run itself was not kept; pass --store to keep every request and reply ``` Every request to `/kennels` is a fault; every one to `/pets` was answered and is not. The check switches off in one line, and the run then reports nothing, as `master` does — the requests are still counted, under `no reply`: ```bash ./restest run try/kennels.yaml --url http://127.0.0.1:8765 --budget 10s --seed 1 --set engine.readTimeout=2s --set checks.noReplyInTime=false ``` ``` 3 setting(s) are not what RESTest does by default; --print-settings lists them with where each came from 99 requests to 2 operations in 12.4s, 3% of it idle opening lap: 2 requests in 2.0s, 1 of 2 operations answered 2xx 50 2xx, 49 no reply no faults found ``` That run ends with `0`. Stop the stand-in with `kill %1` and remove `try/` and `restest-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`: ```bash ./mvnw -pl restest-cli -am test -Dtest='RestestTest#an_address_that_never_answers*' -Dsurefire.failIfNoSpecifiedTests=false | grep "Tests run:" | tail -2 ``` ``` [INFO] Tests run: 2, Failures: 0, Errors: 0, Skipped: 0, Time elapsed: 2.891 s -- in io.restest.cli.RestestTest [INFO] Tests run: 2, Failures: 0, Errors: 0, Skipped: 0 ``` ## 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 as `SocketTimeoutException: 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.json` and in the stored run, as `"timedOutWaitingFor": "reply"`, so a stored run can be judged again later. A run in which nothing answered still ends with `3` ("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.TransportFailure` gains `Optional<Wait> timedOutWaitingFor`, with `Wait` either `CONNECTION` or `REPLY`; `Interaction` gains a `transportFailure(...)` that takes it, and `timedOutWaitingForAReply()`, the one check the rule and the run loop share. `InteractionDocument` writes `timedOutWaitingFor` only 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`, `SettingKey` and `Settings`: `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)`: a `SocketTimeoutException`, or an `InterruptedIOException` on a thread that is not interrupted (OkHttp's limit on the whole request), is a timeout; anything else is not. - **`restest-oracles`**: `NoReplyInTimeOracle`, registered in `module-info.java`, the services file and last in `Oracles.standard`: 100, 121, 200, 206, 312. - **`restest-cli`**: `RunLoop` counts the requests that timed out waiting for a reply (`Outcome.noReplyInTime`); `RunCommand` says so in the line a run ends with when nothing was answered. - **Tests**: `TimedOutWaitingForTest` (9 cases) names each kind of failure; `EngineFaultsTest` makes 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`; `OraclesTest` and `SettingsTest` over five switches; `CheckSwitchesCommandTest` with 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. - **Documentation**: `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). - **Left for later**: a reply that arrives but slower than a limit (what Schemathesis reports as `312`); splitting `noReply` in `report.json` by cause; `report.json`'s JSON Schema, which is the next change to `report.json`'s. ## Evidence - [x] Tests: about fifty new cases, in two new classes and nine changed ones; `./mvnw verify` green on all nine modules, 4,422 tests — CI: CI_LINK - [x] Architecture tests: unchanged, all green (the documentation rules hold the settings, switches and command-line pages to the code) — none weakened - [x] Mutation score, `restest-oracles`: 97% (195 of 201; 194 of 200 before), and every mutant in the new rule and in `Oracles` killed - [x] Per-request overhead: the rule is one look at how the attempt ended, and the engine adds two writes of a flag per request. On the smoke APIs, three runs each, 92,267–98,525 requests on the pet shop (86,250 before) and 52,479–61,166 on the scouts' catalogue (53,268–62,072 before): no difference beyond noise - [x] Smoke run, the pet shop and the scouts' catalogue, 10 s, seed 20260914, three runs: operations tested 19 and 49 (19 and 49), answered 2xx in the opening lap 14 and 18 (14 and 18), operations answering 500 16 and 12 (16 and 12), kinds of fault 3 and 2–3 (3 and 2–3), operations with faults 17 and 23 (17 and 23). No request went unanswered on either - [x] Idle time: 3.9–4.3% on both smoke APIs (4.5% before) - [x] Artefact: fifteen APIs of the Web Fuzzing Dataset run locally for a minute each before the rule, their stored runs read back: two had requests that timed out on an open connection with no reply, 42 on six operations, all at the 30-second read timeout — catwatch's `fetch`, 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 30 `F312`, all on `fetch`; 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 code - [x] Size: no dependency added; the archive and the image grow by the new classes only ## Decisions taken - **What counts, decided with the maintainer**: a request on an open connection to which no status line and headers came back, whichever of the read, write or whole-request limits ran out. Not a connection that could not be made in time, and not a reply whose status line and headers arrived and whose body stalled, which stays a malformed reply counted under its status code. A request still being sent when time ran out counts too: OkHttp throws the same exception for a stalled write and a stalled read, and the bodies RESTest sends are far smaller than what the operating system buffers, so a write stalls only when the API stops reading a connection it holds open. - **On by default**, chosen by the maintainer, as CATS and RESTler report it; EvoMaster has its own equivalent, code 960, off by default; Schemathesis reports a request with no reply as an error of the run and uses `312` for 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. - **The code is `F312`**, the catalogue's *Call Timeout*. The catalogue files it among its security kinds, but it is not one of the security oracles `docs/DESIGN.md` keeps out of scope: it sends nothing and judges an outcome every run already records; the plan's row for it is the approval. - **The exit codes are unchanged**: a run where nothing answered ends with `3` even when it reported `F312`, 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. - **A redirection followed to an address that takes the request and never answers is reported on the operation**, and documented as a known limitation, rather than told apart: redirections are followed only when a run asks for it (`engine.followRedirects`, off by default). - **`report.json` has no JSON Schema yet**, by the maintainer's choice: it is left to the next change of `report.json`'s shape, which changes it more than one optional member. - **Departures from the approved plan.** The closing line's count comes from the run loop instead of from the faults reported, found by the review of the code's design. The one-argument constructor of `TransportFailure` was dropped, having no caller, and `Interaction.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. - **Review findings not acted on.** A method on `TransportFailure` to rebuild it with a new reason, which only `Secrets` would 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. --- - [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>
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
What you can do now that you could not before
A run now tells you when the API accepted a request RESTest broke on purpose — a required value
left out, a word where a number goes, a value off a closed list, a body that is not JSON — and when
it answered a server error other than 500, such as 502, 503 or 504. Until now both happened
unseen: an API that accepts anything ended a run with
no faults found.Try it yourself
With only Java and git. The "API" is the small file server that comes with Java: it serves a file
whatever the query string says, so it accepts every broken request.
Real output (the first two of fifty faults printed, then the end); the run ends with
1:Every one of the 58,038 changed requests was accepted, and each is a fault. The 19,187 that only
pushed awkward values are not: nothing in particular was expected of them. On
masterthe samecommand ends with
no faults foundand exit code0, after 61,478 changed requests accepted.The check switches off in one line, and the run says a setting is not the default:
./restest run --print-settingsnow lists four checks:A 503 needs an API that answers one; the end-to-end test does it with a stand-in:
Stop the file server with
kill %1and removetry/.How it works
Part of every run takes a request the API has just accepted and sends it again with exactly one
thing changed, so that the API's own document forbids it. Each such request has always recorded
that a refusal was expected and what was changed. A new rule reads that record and the status code
that came back: a success (2xx) means the API let through something its document says it never
takes, and the fault's sentence names the change. Only those requests count. A request that reads
again a thing the API said it had deleted also expects a refusal, but a success there says
something about deleting — a different kind of fault, left for later.
A second rule reports every reply from 502 to 599. 500 keeps its own kind, and 501, not
implemented, stays unreported, as the shared catalogue of faults keeps it apart. These replies were
already counted under
serverErrorsinreport.json; now they are faults too, and that count isunchanged.
Both kinds are named by the shared catalogue (Web Fuzzing Commons, Faults 0.9.0):
F206,Received Success Response When Sending Wrong Data, and
F121, HTTP Status 5xx Other Than 500And 501. Each has a switch,
checks.invalidDataAcceptedandchecks.otherServerError, on bydefault; a check switched off is not made at all.
What changed in the code
restest-oracles:InvalidDataAcceptedOracle(F206: intentREFUSAL_EXPECTEDwith aMutation, an answered 2xx) andOtherServerErrorOracle(F121: an answered 502–599). Bothregistered in
module-info.javaandMETA-INF/services, and put inOracles.standardin theorder of the catalogue's numbers: 100, 121, 200, 206.
ServerErrorOracle's Javadoc points at thenew rule.
restest-core:CheckSettingsgainsotherServerErrorandinvalidDataAccepted;SettingKeyandSettingsread and write them. Ninety-three settings in nine groups.restest-report: one sentence ofServerErrors's Javadoc, which said the catalogue names therest of the 5xx as nothing in particular.
InvalidDataAcceptedOracleTest(23 cases) andOtherServerErrorOracleTest(17),covering status boundaries (199/200/299/300, 499/500/501/502/599/600/999), a malformed reply, no
reply, every intent, a step of a series and a format-only change;
OraclesTestparameterised overthe four switches;
SettingsTestlikewise;CheckSwitchesCommandTestwith a second document onthe same stand-in, wrong in every way a check looks for, and an "every check off" test that
switches off every key of the group. Three command-line tests had stand-in APIs that accepted
requests their own documents forbid —
WholeRunTest(POST /petswith any body,GET /pets/0),RestestTest(GET /vets/10against a 1–9 bound) — andF206caught them; thestand-ins now refuse what their documents forbid, and no expected result was added or relaxed.
docs/faults.md(a section for each kind, the leniencies of common requestreaders, a real screen example),
docs/switches.md,docs/settings.md(and how to check asettings file),
docs/schemas.md(why settings have no schema),docs/known-limitations.md(a 503the run's pace provoked is reported),
docs/DESIGN.md, the manual's chapters 1, 4, 10 and 11, theREADME. Amendments to ADR-0016 (
F121besideserverErrors), ADR-0025 (the two switches) andADR-0027 (the rule that reads the intent, with the measurement below).
F113), a reply thatnever comes (
F312), a status code or content type the document does not declare, a method itdoes not declare, and a 501 on a declared operation (
F205).Evidence
OraclesTestandSettingsTest, 2 more end-to-end runs;./mvnw verifygreen on all nine modules (4,361 tests) —CI: https://github.com/isa-group/RESTest/pull/403/checks
switches pages to the code) — none weakened
restest-oracles: 97% (194 of 200 killed; threshold 85%), and every mutant inthe two new rules and in
OracleskilledF206one at therequest's intent. On the file server above, 10 s each, one run:
master205,715 requests; thisbranch with
F206off 204,443; with it on, reporting 58,038 faults, 194,063 (−5.7%, the cost ofannouncing and counting a fault for three requests in ten). On scout-api, three runs each,
48,765–71,959 against 53,268–62,072: no difference beyond noise
operations tested 19 and 49 (19 and 49), answered 2xx in the opening lap 14 and 18 (14 and 18),
operations answering 500 16 and 12 (15 and 12); kinds of fault 3 and 3 (2 and 1), with 154 and
1,475
F206; operations with faults 17 and 23 (17 and 12). No 5xx other than 500 on eitherrule, and the stored runs read back: of 225,006 requests changed to expect a refusal, 24,726 (11%)
were accepted, on 70 of 316 operations and 12 of the 15 APIs. All 12 had also answered 500, so none
changes its exit code. Per kind of change and the comparison with other tools: ADR-0027's
amendment. After the rule, pet-clinic, user-management and the Swagger pet store reported 557, 417
and 1,132
F206in a minute; pet-clinic's 557 equals the stored run's countDecisions taken
formatincluded (adateof2021-02-30, a number pastint32), chosen by the maintainer. The rule reads the intent thegenerator records, which says these expect a refusal; Schemathesis, CATS and RESTest 1.x count
them too. Leaving two kinds out was a list kept as a constant, and leaked: an empty date is sent
by another kind.
F206is on by default although part of it is a library's leniency, chosen by the maintainer:in the fifteen APIs, at least 45% of what was accepted, once format-only changes are set aside, was
Jackson or Spring turning
trueinto a word, ignoring text after the JSON, trimming" 1", orreading an empty optional value as absent. Every tool checked that has this rule ships it on.
docs/faults.mdlists the leniencies; the measurement of noise before 2.2.0, and the severitiesstill to come, may make part of it a warning.
Retry-Afteris reported like any other, chosen by the maintainer: no toolchecked exempts it, and the known limitations say a 503 the run's own pace provoked is reported
until RESTest waits when told to.
F100, 501 notF121, 502–599F121; a code of600 or more is not a server error. A 5xx the document declares is still reported, as a declared
500 is.
contextword on anF206. The kind of change is already written in every faultreport.jsonwrites out, and nothing reads a context yet.F113, a kind of itsown.
CheckSwitchesCommandTestreads one stand-in through twodocuments instead of starting a second server.
WholeRunTestchecks a body with a small predicateof its own: WireMock's
matchingJsonSchemaneeds version 1 of the networknt validator, and thebuild carries a later major version. A third test,
RestestTest, needed its stand-in fixed, foundby the full build.
WholeRunTestgained the catch-all 404RestestTestalready had.restest-core, now written in five places across four modules, is left for a change of its own,since it touches the generator and the reports. The three lists that register a rule
(
module-info.java, the services file,Oracles.standard) are the documented design and stay.The README's first run and the summary
docs/faults.mdquotes from it come from a run againstthe public pet store; 2.2.0's release redoes them from real runs with the rest of the manual's
output.
Open questions
None.
masterAGENTS.mdholddocs/DESIGN.mdwithout explicit approval🤖 Generated with Claude Code