Skip to content

Report a request the API took and never answered before RESTest stopped waiting - #405

Merged
ssegura merged 5 commits into
masterfrom
feat/m3-1c-no-reply-in-time
Oct 17, 2026
Merged

ssegura merged 5 commits into
masterfrom
feat/m3-1c-no-reply-in-time

Conversation

@ssegura

@ssegura ssegura commented Oct 17, 2026

Copy link
Copy Markdown
Collaborator

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.

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:

./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:

./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

  • 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
  • Architecture tests: unchanged, all green (the documentation rules hold the settings, switches and command-line pages to the code) — none weakened
  • Mutation score, restest-oracles: 97% (195 of 201; 194 of 200 before), and every mutant in the new rule and in Oracles killed
  • 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
  • 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
  • Idle time: 3.9–4.3% on both smoke APIs (4.5% before)
  • 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
  • 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.


  • Targets master
  • English throughout, including comments and test names
  • The hard rules in AGENTS.md hold
  • Nothing from "Out of scope" in docs/DESIGN.md without explicit approval

🤖 Generated with Claude Code

ssegura and others added 5 commits October 17, 2026 10:40
…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>
@ssegura
ssegura merged commit 08d1eda into master Oct 17, 2026
11 checks passed
@ssegura
ssegura deleted the feat/m3-1c-no-reply-in-time branch October 17, 2026 09:36
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>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant