Skip to content

Report a request broken on purpose that the API accepted, and a server error other than 500 - #403

Merged
ssegura merged 1 commit into
masterfrom
feat/m3-1a-invalid-data-and-other-5xx
Oct 15, 2026
Merged

ssegura merged 1 commit into
masterfrom
feat/m3-1a-invalid-data-and-other-5xx

Conversation

@ssegura

@ssegura ssegura commented Oct 15, 2026 •

Copy link
Copy Markdown
Collaborator

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.

git fetch origin && git switch feat/m3-1a-invalid-data-and-other-5xx
./mvnw -q install -DskipTests
mkdir -p try && echo '[{"name": "Rex"}]' > try/pets.json
cat > try/pets.yaml <<'EOF'
openapi: 3.0.3
info: {title: Pets on a file server, version: "1"}
paths:
  /pets.json:
    get:
      operationId: listPets
      parameters:
        - {name: limit, in: query, required: true, schema: {type: integer, minimum: 1, maximum: 10}}
      responses:
        "200": {description: the pets}
EOF
java -m jdk.httpserver -p 8765 -d "$PWD/try" > /dev/null 2>&1 &
./restest run try/pets.yaml --url http://127.0.0.1:8765 --budget 10s --seed 1

Real output (the first two of fifty faults printed, then the end); the run ends with 1:

RESTest testing Pets on a file server at http://127.0.0.1:8765

1 of 1 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

F206  Received Success Response When Sending Wrong Data
      listPets - GET http://127.0.0.1:8765/pets.json  ->  200
      the API accepted a request it should have refused: moved the required query parameter 'limit' to a cookie
      curl -i -X GET 'http://127.0.0.1:8765/pets.json' -H 'Accept: */*' -H 'Cookie: limit=8' -H 'User-Agent: RESTest/2.0'

F206  Received Success Response When Sending Wrong Data
      listPets - GET http://127.0.0.1:8765/pets.json  ->  200
      the API accepted a request it should have refused: left out the required query parameter 'limit'
      curl -i -X GET 'http://127.0.0.1:8765/pets.json' -H 'Accept: */*' -H 'User-Agent: RESTest/2.0'
...
194063 requests to 1 operations in 10.0s, 3% of it idle
  opening lap: 1 request in 72ms, 1 of 1 operations answered 2xx
  194063 2xx
  19187 of them were pushing at the API with values nobody sensible would send
  58038 of them changed one thing in a request the API had accepted
58038 faults:
  58038 x F206  Received Success Response When Sending Wrong Data

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 master the same
command ends with no faults found and exit code 0
, 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 try/pets.yaml --url http://127.0.0.1:8765 --budget 10s --seed 1 --set checks.invalidDataAccepted=false
  1 setting(s) are not what RESTest does by default; --print-settings lists them with where each came from
...
no faults found

./restest run --print-settings now lists four checks:

checks:
  # report a reply of 500, which says the API fell over while handling the request (F100)
  internalServerError: true   # default
  # report a reply of a server error other than 500 and 501, such as 503 (F121)
  otherServerError: true      # default
  # report a reply whose body is not the shape the document gives for its status code and media type (F200)
  replyOffSchema: true        # default
  # report a request broken on purpose, with one thing the document forbids, that the API accepted (F206)
  invalidDataAccepted: true   # default

A 503 needs an API that answers one; the end-to-end test does it with a stand-in:

./mvnw -pl restest-cli -am test -Dtest=CheckSwitchesCommandTest -Dsurefire.failIfNoSpecifiedTests=false | grep "Tests run:"
[INFO] Tests run: 5, Failures: 0, Errors: 0, Skipped: 0, Time elapsed: 1.410 s -- in io.restest.cli.CheckSwitchesCommandTest
[INFO] Tests run: 5, Failures: 0, Errors: 0, Skipped: 0

Stop the file server with kill %1 and remove try/.

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 serverErrors in report.json; now they are faults too, and that count is
unchanged.

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 500
And 501
. Each has a switch, checks.invalidDataAccepted and checks.otherServerError, on by
default; a check switched off is not made at all.

What changed in the code

  • restest-oracles: InvalidDataAcceptedOracle (F206: intent REFUSAL_EXPECTED with a
    Mutation, an answered 2xx) and OtherServerErrorOracle (F121: an answered 502–599). Both
    registered in module-info.java and META-INF/services, and put in Oracles.standard in the
    order of the catalogue's numbers: 100, 121, 200, 206. ServerErrorOracle's Javadoc points at the
    new rule.
  • restest-core: CheckSettings gains otherServerError and invalidDataAccepted;
    SettingKey and Settings read and write them. Ninety-three settings in nine groups.
  • restest-report: one sentence of ServerErrors's Javadoc, which said the catalogue names the
    rest of the 5xx as nothing in particular.
  • Tests: InvalidDataAcceptedOracleTest (23 cases) and OtherServerErrorOracleTest (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; OraclesTest parameterised over
    the four switches; SettingsTest likewise; CheckSwitchesCommandTest with a second document on
    the 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 /pets with any body,
    GET /pets/0), RestestTest (GET /vets/10 against a 1–9 bound) — and F206 caught them; the
    stand-ins now refuse what their documents forbid, and no expected result was added or relaxed.
  • Documentation: docs/faults.md (a section for each kind, the leniencies of common request
    readers, a real screen example), docs/switches.md, docs/settings.md (and how to check a
    settings file), docs/schemas.md (why settings have no schema), docs/known-limitations.md (a 503
    the run's pace provoked is reported), docs/DESIGN.md, the manual's chapters 1, 4, 10 and 11, the
    README. Amendments to ADR-0016 (F121 beside serverErrors), ADR-0025 (the two switches) and
    ADR-0027 (the rule that reads the intent, with the measurement below).
  • Left for later: a thing still there after the API said it deleted it (F113), a reply that
    never comes (F312), a status code or content type the document does not declare, a method it
    does not declare, and a 501 on a declared operation (F205).

Evidence

  • Tests: 40 new cases in two new classes, 4 more parameterised cases in OraclesTest and
    SettingsTest, 2 more end-to-end runs; ./mvnw verify green on all nine modules (4,361 tests) —
    CI: https://github.com/isa-group/RESTest/pull/403/checks
  • Architecture tests: unchanged, all green (the documentation rules hold the settings and
    switches pages to the code) — none weakened
  • Mutation score, restest-oracles: 97% (194 of 200 killed; threshold 85%), and every mutant in
    the two new rules and in Oracles killed
  • Per-request overhead: the two rules are one look at the status code, and for F206 one at the
    request's intent. On the file server above, 10 s each, one run: master 205,715 requests; this
    branch with F206 off 204,443; with it on, reporting 58,038 faults, 194,063 (−5.7%, the cost of
    announcing 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
  • Smoke run, the pet shop and the scouts' catalogue, 10 s, seed 20260914, one run each:
    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 either
  • Idle time: 4.5% on both smoke APIs (4.2% and 4.3% before)
  • Artefact: fifteen APIs of the Web Fuzzing Dataset run locally for a minute each before the
    rule, 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 F206 in a minute; pet-clinic's 557 equals the stored run's count

Decisions taken

  • Every kind of change counts, one that breaks only a format included (a date of
    2021-02-30, a number past int32), chosen by the maintainer. The rule reads the intent the
    generator 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.
  • F206 is 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 true into a word, ignoring text after the JSON, trimming " 1", or
    reading an empty optional value as absent. Every tool checked that has this rule ships it on.
    docs/faults.md lists the leniencies; the measurement of noise before 2.2.0, and the severities
    still to come, may make part of it a warning.
  • A 503 that carries Retry-After is reported like any other, chosen by the maintainer: no tool
    checked exempts it, and the known limitations say a 503 the run's own pace provoked is reported
    until RESTest waits when told to.
  • The codes are those Schemathesis writes: 500 F100, 501 not F121, 502–599 F121; a code of
    600 or more is not a server error. A 5xx the document declares is still reported, as a declared
    500 is.
  • No context word on an F206. The kind of change is already written in every fault
    report.json writes out, and nothing reads a context yet.
  • Steps of a series expecting a refusal are left alone: a success there is F113, a kind of its
    own.
  • Departures from the approved plan. CheckSwitchesCommandTest reads one stand-in through two
    documents instead of starting a second server. WholeRunTest checks a body with a small predicate
    of its own: WireMock's matchingJsonSchema needs version 1 of the networknt validator, and the
    build carries a later major version. A third test, RestestTest, needed its stand-in fixed, found
    by the full build. WholeRunTest gained the catch-all 404 RestestTest already had.
  • Review findings not acted on. A shared check for "success" and "server error" in
    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.md quotes from it come from a run against
    the public pet store; 2.2.0's release redoes them from real runs with the rest of the manual's
    output.

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

…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>
@ssegura
ssegura merged commit 3d02961 into master Oct 15, 2026
11 checks passed
@ssegura
ssegura deleted the feat/m3-1a-invalid-data-and-other-5xx branch October 15, 2026 16:13
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>
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>
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