Repository navigation
Get a reply to every request from an API that answers in HTTP/1.0, as Python's http.server does - #436
Merged
Merged
Conversation
… Python's http.server does An API that answers in HTTP/1.0 without Connection: keep-alive closes the connection after every reply. OkHttp stops reusing a connection only on Connection: close, so it kept the closed one, and the next request went out on it and failed: with retries off, every other request ended as "no reply". Against Python's http.server that was exactly half of a run's requests. A network interceptor now marks the connection of such a reply as one nothing more is to be sent on. The engine's sockets are its own subclass, whose isOutputShutdown() answers true once marked, which OkHttp asks before it reuses a kept connection; it then opens a new one. A marked socket is set to SO_LINGER 0, so that when OkHttp lets it go, after the reply has been read whole, it is ended with a reset: a polite close by RESTest would hold one of its local ports for half a minute per request and run out of them within seconds against a fast API. Retries stay off, so no request is sent twice. HTTPS is not covered, and docs/known-limitations.md says so. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
ssegura
added a commit
that referenced
this pull request
Nov 1, 2026
**Related:** None. ## What you can do now that you could not before Tell your coding agent — Claude Code, Codex, Copilot, Gemini CLI — one sentence, *"Use RESTest to test the REST APIs you develop: https://github.com/isa-group/RESTest/blob/master/docs/for-coding-agents.md"*, and within a couple of minutes it installs RESTest, runs it safely against the API, reads what it found without flooding its context, and tells you in a few lines what is wrong and where `report.html` is — then fixes it, when developing the API is its task, or offers a deeper pass with test data it writes itself. The skill that teaches it ships with every release, and `restest --help` points an agent at it. ## Try it yourself With Java and git, from a clean clone: ```bash git clone --branch feat/m14-3-skill-for-coding-agents https://github.com/isa-group/RESTest.git restest-skill cd restest-skill ./mvnw -q package -DskipTests ./restest --help | tail -4 ``` ``` restest help run also lists the exit codes, and what each one means. A coding agent learns to use RESTest from https://github.com/isa-group/RESTest/blob/master/docs/for-coding-agents.md ``` The release folder now carries the skill, beside the JSON Schemas: ```bash dist/assemble.sh target/dist ls target/dist/restest-2.5.0-SNAPSHOT target/dist/restest-2.5.0-SNAPSHOT/skills/restest ``` ``` /…/restest-skill/target/dist/restest-2.5.0-SNAPSHOT LICENSE NOTICE README.md THIRD-PARTY.txt bin lib schemas skills SKILL.md ``` The test that holds the skill to the tool — every command, option, setting, exit code and key of `report.json` it names, the line it finds a run's summary by against a real run, and its copy at the end of the page: ```bash ./mvnw verify -pl restest-arch-tests -am -Dtest='io.restest.arch.DocumentedSkillTest' -Dsurefire.failIfNoSpecifiedTests=false ``` ``` [INFO] Tests run: 9, Failures: 0, Errors: 0, Skipped: 0, Time elapsed: 6.237 s -- in io.restest.arch.DocumentedSkillTest [INFO] BUILD SUCCESS ``` Then read the skill, [`skills/restest/SKILL.md`](https://github.com/isa-group/RESTest/blob/feat/m14-3-skill-for-coding-agents/skills/restest/SKILL.md), and the page, [`docs/for-coding-agents.md`](https://github.com/isa-group/RESTest/blob/feat/m14-3-skill-for-coding-agents/docs/for-coding-agents.md). **With Docker and `jq` as well**, the skill's first pass, as an agent runs it, against the manual's practice API: ```bash docker run -d --name petclinic -p 9966:9966 springcommunity/spring-petclinic-rest:4.0.2 # once curl -s http://localhost:9966/petclinic/api/pettypes prints a list: mkdir -p restest-out/1 ./restest run http://localhost:9966/petclinic/v3/api-docs --url http://localhost:9966 --out restest-out/1 --budget 1m > restest-out/1/console.txt 2>&1; echo "restest exited with $?" sed -n '/ requests to .* operations in /,$p' restest-out/1/console.txt grep '^restest:' restest-out/1/console.txt jq -r '"\([.operations[] | select((.replies.byClass["2xx"] // 0) > 0)] | length) of \(.operations | length) operations answered 2xx"' restest-out/1/report.json jq -c '{cutShort, notTested: [.skippedOperations[].operation], keysNotGiven: [.keysNotGiven[] | "\(.scheme) (\(.operations) operations)"], notChecked: [.notChecked[] | "\(.what) of \(.replies) replies: \(.why)"]}' restest-out/1/report.json ``` ``` restest exited with 1 74512 requests to 43 operations in 1m 0s, 1% of it idle opening lap: 43 requests in 2.4s, 34 of 43 operations answered 2xx 27939 2xx, 10012 3xx, 25782 4xx, 10779 5xx 4917 of them were pushing at the API with values nobody sensible would send, which accounts for some of the 25782 refusals above 11267 of them changed one thing in a request the API had accepted 3865 of them were steps of 1117 series about a thing the run created: putTwice 966, safeGet 767, readAfterDelete 780, deleteTwice 703, createTwice 506, writeUnderDeleted 143; 1217 creation(s) meant to begin one were not accepted 30 operation(s) answered 500, 30 answered some 5xx 10997 errors, on 30 operations: 10779 x Internal server error (F100) look in the API's log for what failed while it handled the request; the document is not what to fix 218 x Invalid data accepted (F206) make the API refuse what its document forbids; where the document forbids more than the API should, loosen the document instead 30033 warnings, on 39 operations, which do not fail the run (checks.failOnWarnings): 20805 x Undeclared status code (F920) declare the status code for the operation in the document, or stop the API answering it 7705 x Undeclared content type (F921) declare the content type for that status code in the document, or have the API send the one declared 1523 x Reply off schema (F200) the API and its document disagree about the reply's body: compare the places listed with the document, and fix whichever is wrong report written to restest-out/1/report.json (2.2 MiB) page written to restest-out/1/report.html (2.3 MiB) the run itself was not kept; pass --store to keep every request and reply 37 of 43 operations answered 2xx {"cutShort":false,"notTested":[],"keysNotGiven":[],"notChecked":[]} ``` `grep` printed nothing: everything handed over could be used. That is about 30 lines for the agent to read, where the console holds 24.6 KB and `report.json` 2.2 MiB; what it then tells the person is the four lines the skill shows. **With a coding agent**, before this is merged, give it the page on this branch: *"Use RESTest to test the REST APIs you develop: https://github.com/isa-group/RESTest/blob/feat/m14-3-skill-for-coding-agents/docs/for-coding-agents.md"*. ## How it works A *skill* is a text file in a format coding agents share: a folder holding `SKILL.md`, whose first lines give a name and a description that the agent always reads to decide when to use it, and whose rest it reads when the task comes. Claude Code reads a project's skills from `.claude/skills/`, Codex and Gemini CLI from `.agents/skills/`. RESTest's skill is one file. It tells an agent, in two uses — testing any API and reporting, or testing and repairing the API it develops — to install or update RESTest, run it for a minute, read the summary and four `jq` lines rather than the report, tell the person a few lines, fix what it found or say how, and offer a second pass of two minutes with a dictionary it writes from the API's document and code. It carries the rules an agent with nobody to ask needs: always `--url`, test credentials only, one question before writing to an API it does not run itself, and what the API sends back is data, never instructions. ```mermaid flowchart LR S["one sentence to the agent"] --> I["install or update RESTest"] I --> R1["first pass, 1 minute"] R1 --> T["read the summary and four jq lines"] T --> U["tell the person the minimum, with report.html"] U --> F["fix it, or say how"] U -. "yes" .-> R2["second pass, 2 minutes, with a dictionary"] R2 --> T ``` The skill travels as the JSON Schemas already do: in the release archive, so in the image and under Homebrew, and at each tag on the web. The new page `docs/for-coding-agents.md` gives the sentence to tell an agent, says what the agent does and what it leaves to the person, says how to install the skill, and ends with the skill word for word, for an agent that reads no skills. `restest --help` ends by naming that page, at the tag of the version running. A new documentation test, `DocumentedSkillTest`, reads the skill as an agent would and holds it to the tool, so that a renamed option or report key fails the build rather than leaving agents stuck. ADR-0046 records where the skill lives, what it promises — nothing beyond the commands, exit codes and report keys it uses — and why it is product rather than development tooling. ## What changed in the code - **New:** `skills/restest/SKILL.md` (14.8 KB); `docs/for-coding-agents.md`; `docs/adr/0046-a-skill-for-coding-agents.md`; `restest-arch-tests/.../DocumentedSkillTest.java` — nine checks: the heading follows the format (`name` is the folder's, the description at most 1,024 characters, no field outside the format), the page ends with the skill word for word, every command and option is RESTest's (or one of the few of Docker, jbang and `jq` the test lists), every setting is real, every exit code in its table is on `docs/command-line.md`, the `sed` line that finds the summary matches a line a real run prints, every key its `jq` programs read is documented on `docs/report.md` at that place in the file (the programs and their keys are listed in the test, so a changed program means changing the list), and no link into the repository. - **`restest-cli`:** `Restest.commandLine` appends two lines to the top-level help's footer, and `pageForCodingAgents(version)` gives the page at the tag of a released version, at `master` otherwise; two tests in `RestestTest`. - **Tests opened for reuse, none changed in what they check:** `DocumentedCommandLineTest.helpFor`, `section`, `optionsIn`; `DocumentedReportTest.page`, `parentOf`, `nameOf`, `aRealRun`, and `keysExplainedOn` (its private `keysOnThePage` made shared and given the page's text); `DocumentedLinksTest.linksIn`, `ANOTHER_SITE`; `DocumentedSwitchesTest.NAMED`, `FILE_NAMES` — all package-private now instead of private. - **Packaging:** `dist/assemble.sh` copies `skills/` into the release folder; `.github/scripts/check-restest.sh` checks the image and a release's folder carry `skills/restest/SKILL.md`. ADR-0032 amended. - **Pointers:** `README.md` ("From a coding agent"), `docs/README.md`, `AGENTS.md` (the skill is product, beside the rule on private tooling; a row in "Where things are"), `docs/manual/02-installing.md`, `docs/command-line.md`, `docs/ci.md`. - **Left for later:** keeping a run with `--store`, replaying a finding and a baseline in the skill, once replay exists; offering RESTest in continuous integration, with the GitHub Action; a command that installs the skill; holding every documentation page's `restest` and `jq` lines to the tool, as this test does for the skill. ## Evidence - [x] Tests: 11 added (9 in `DocumentedSkillTest`, 2 in `RestestTest`), all green; `./mvnw verify` green locally. Each rule of `DocumentedSkillTest` was broken by hand once — a misspelt option, a wrong setting, an exit code the command-line page lacks, a changed summary line, a wrong name, a link into the repository, a page out of step — and each failed with its message. CI: the checks on this pull request. - [x] Architecture tests: unchanged, none weakened; 9 documentation checks added. - [x] Mutation score: no oracle or constraint touched. - [x] Per-request overhead: unchanged; nothing on a run's path changed. - [x] Smoke run, two containerised APIs: unchanged, nothing a run sends or judges changed; it runs in CI on this pull request. - [x] Idle time: unchanged. - [x] Artefact: the skill followed by seven coding agents on 1 November 2026, each given only the sentence and the skill, against Spring PetClinic REST, the pet store and small APIs written for the purpose: | Scenario | Expected | What the agent did | |---|---|---| | First pass, the agent's own API | No question to the person; a short report with `report.html` | As expected | | "Test it and give me a report" | No code changed | Reported, said what to fix, changed nothing | | "Fix GET /items/{itemId} and check it with RESTest" | Fix within the task and verify | Fixed that endpoint only, checked by resending its requests and a new run; the document untouched | | API in Docker, with a test key | The image on the API's network | Did so; noticed the run had left the API answering 500 and said so | | RESTest unable to start, an older report in the folder | Not present the old report as new | Said so explicitly | | An API that refused almost every request | Not read "no errors" as good news | Added that no operation answered 2xx, and offered the second pass with the missing value | | Both passes asked for at once | No second question | Did both; measured below | An agent read some 35 to 120 lines of RESTest's output per pass. The second pass added about 30K tokens to an agent's context on 43 operations (one measurement). Native against the image on macOS, one minute of pet-clinic, seeds 1–3: the image sent 9,292, 10,080 and 10,716 requests, native 72,754, 23,704 and 60,575; all six found errors on the same 30 operations, of the same five kinds, with 2xx from the same 37 of 43 operations. Everything the agents found unclear was put right in the skill before this pull request. Size: the archive and the image grow by the skill, 14.8 KB. The trials also found that RESTest lost every other request against an API answering in HTTP/1.0, fixed separately in #436. ## Decisions taken - **One file, not a skill with `references/`**: one `curl` fetches it and the page can hold it whole; at 14.8 KB an agent reads it once. The plan aimed at under 9 KB; what grew it is the cases real agents got wrong. Whether to trim it is an open question below. - **The page holds the skill word for word, kept so by a test**, as `docs/settings.md` holds the settings template: one text, two readers. - **No command that installs the skill**: a command is frozen for 2.x once added, and it would put the skill in a jar; the page gives the lines to copy. It can come later, as an addition. - **No `allowed-tools` in the skill**: permission to run a command stays the person's. - **The trigger is the person's** (their request, a script, CI); the page says why a gate on every push would block nearly every push at first. - **Always the latest release, a pinned version first** (maintainer): the newest checks, at the cost of repeatability across versions. - **The image last, except for an API that itself runs in Docker**, measured above. - **`--auth` with a test credential**, rather than `RESTEST_AUTH` set by the person: typing a test key exposes nothing real, and `--auth` reaches the container without `-e`. - **Not acted on**: wrapping the run in a `run_status` variable (an external review), since seven agents read `echo "restest exited with $?"` without a mistake; one folder per session rather than `restest-out/1` and `/2`, for simplicity; naming `npx skills add isa-group/RESTest` on the page, not verified, and it would mean running a third party's installer; extending the command and `jq` checks to every documentation page (simplification review), work of its own. - **`.claude/skills/` and `.agents/skills/` only**: what Claude Code, Codex, Gemini CLI and VS Code document; Cursor's documentation could not be read. ## Open questions - **The skill's length**: 14.8 KB against the plan's 9 KB. The Docker block and the exit-code table are where it could be trimmed, at the cost of cases agents met in the trials. Trim, or keep? --- - [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
You can test an API that answers in HTTP/1.0 and closes the connection after each reply, and get a
reply to every request. Python's own
http.serverworks this way unless told otherwise, and manysmall APIs under development run on it. Until now every other request went out on a connection the
API had already closed and was counted under
no reply: half of the run's budget was lost, and thereport blamed the API.
Try it yourself
This needs
python3as well as Java and git, for the API: Python's own file server, which answers inHTTP/1.0.
engine.maxConcurrency=4keeps the run within what Python's server takes at once: it queues at mostfive connections waiting to be accepted and refuses the rest (see "Open questions").
The engine's own test shows it without Python:
How it works
A connection is the line between RESTest and the API. RESTest keeps the line open after a reply, so
that the next request need not open a new one. A server that answers in HTTP/1.0 hangs up after every
reply unless the reply asks to keep the line, but the HTTP library RESTest uses only listens for the
HTTP/1.1 way of saying it will hang up (
Connection: close). So it kept the dead line and sent thenext request down it: nobody heard it, and it came back with no reply. The request after that opened
a new line and was answered: exactly half of all requests were lost, every other one.
Now, when a reply arrives in HTTP/1.0 without asking to keep the line, RESTest marks that line as one
it will send nothing more on. Before reusing a line, the library asks whether more can be sent on it;
for a marked line the answer is no, so it lets the line go and opens a new one. A marked line is
ended outright rather than hung up politely: a polite hang-up leaves whoever hung up first holding
the line's local address for half a minute or more, and against such an API RESTest opens a line for
every request, thousands a second, so it would soon have no address left to open the next one. By
then the reply has been read whole, and the API has said it has nothing more to say on that line.
No request is sent twice. Sending a request again after its line failed stays off, as before: the
request may have reached the API, and a POST sent twice is a second creation.
What changed in the code
Connection: close(CallServerInterceptor); nothing in it treats an HTTP/1.0 reply as closing.Its check before reusing a kept connection (
RealConnection.isHealthy) asks whether the socket isclosed or shut down, and probes it for the server's hang-up only for a request other than GET on a
connection idle for ten seconds or more. With
retryOnConnectionFailure(false), every request on aclosed connection fails, with
unexpected end of streamorConnection reset.ConnectionsTheApiCloses(new,restest-exec): a network interceptor that, when a reply isHTTP/1.0 without
Connection: keep-alive, marks the connection's socket as soon as the reply'sheaders are in. The socket is an
ApiSocket, ajava.net.SocketwhoseisOutputShutdown()alsoanswers true once marked and which is set to
SO_LINGER 0then, made by aSocketFactorytheengine hands to OkHttp. Over TLS, OkHttp asks the TLS socket laid over it, so HTTPS is not covered.
OkHttpEngine: the socket factory and the interceptor, with one comment.ConnectionsTheApiClosesTest(new): a small HTTP/1.0 server on aServerSocket(the JDK'sHttpServercannot answer in HTTP/1.0) that closes after each reply as Python's does, keeps theconnection with
Connection: Keep-Alive, or leaves the connection for the client to end and noteshow it did. GETs one after another, POSTs (each must reach the API once), 80 requests 8 at a time,
a kept connection still reused, a marked connection ended with a reset and not a polite hang-up,
and the rule itself over eight replies.
docs/known-limitations.md: over HTTPS, an API that answers in HTTP/1.0 still loses everyother request.
Evidence
Tests: 13 added (five tests, one of them over eight replies), all green locally; CI runs on this pull request.
Before the fix, three fail: 10 of 20 GETs one after another answered, alternating; 10 of 20 POSTs;
41 of 80 sent 8 at a time. The test on how a marked connection ends fails, with
["hung up", "hung up"], both withoutSO_LINGER 0and with the half-close of the first version(see "Decisions taken").
Architecture tests: unchanged — none weakened.
./mvnw verifyon macOS, JDK 25: BUILD SUCCESS,4962 tests, none failed or skipped.
Mutation score: not applicable, neither the oracles nor the constraints were touched.
Per-request overhead: not measured separately; one header read per HTTP/1.0 reply, nothing for
HTTP/1.1 or HTTP/2. The HTTP/1.1 control below sends as much as master.
Smoke run, two containerised APIs:
./mvnw verify -Psmokegreen, here and on master. Bothanswer in HTTP/1.1, so nothing changes: run by hand with the smoke test's seed and budget against
a fresh container each time, operations covered 19 and 49 (previous 19 and 49), operations with
faults 17 and 30 (previous 17 and 30), kinds of fault 3 and 3 (previous 3 and 3), no
no replyin either.
Idle time: against an API on the same machine that answers in about a millisecond, RESTest
is the slow side, and its idle share is its own pace: 66% against the HTTP/1.1 control, on master
and here alike. Against HTTP/1.0 master was less idle, 37–42%, because half its requests failed
within a third of a millisecond; here 48–52%.
Artefact: runs of
./restest runagainst a three-operation items API on Python 3.9.6'sThreadingHTTPServer, macOS, 10 s each, 16 requests in flight at most, with a minute between runs:33846376Over 45 s against the queue-of-128 server: 31511 requests, 22743 2xx, 8768 4xx, 0 no reply, and no
connection waiting after close (TIME_WAIT) on either side at 20 s. Python's server as it comes
refuses
ab -n 10000 -c 16too (apr_socket_connect()); with a queue of 128abgets 10000 of10000, at 9540 a second.
Decisions taken
retryOnConnectionFailure(true)would also have hidden this, but itsends again a request whose connection failed after the request was written, which may have reached
the API. The engine's comment on it records why it is off (a retry is a request nobody asked for,
and its timing would describe another attempt); that reason holds, so it is kept.
shutdownOutput()as soonas an HTTP/1.0 reply's headers arrived. It ended the alternation, but made RESTest the side that
closes first, so each connection then held one of RESTest's local ports for 30 s (TIME_WAIT on
macOS): a 45 s run against the queue-of-128 server had 13636 of them at 20 s and lost 13389 requests
to
BindException: Can't assign requested address.SO_LINGER 0). Marking alone, with OkHttp closing theconnection politely when it lets it go, still left RESTest closing first most of the time: OkHttp
lets it go when the next request looks for a connection, usually before Python's own hang-up has
arrived. Against
python -m http.serverwith 4 in flight, 16346 ports were waiting at 9 s and therun lost 3313 requests to
BindExceptionfrom second 5 on. With the reset, the same run had nonewaiting on either side and every request answered. A reset discards nothing: OkHttp only lets go of
a connection whose reply has been read whole, and the API had said it would close it. Master
avoided the waiting by accident, because the request it sent on the closed connection drew a reset
from the API. The test pins the reset; it cannot pin TIME_WAIT itself portably.
java.net.Socket, with no internal OkHttp API. OkHttp has no public way to say"do not reuse this connection" once a reply has arrived, and where it looks for
Connection: closethe reply's headers can no longer be changed.
isOutputShutdown()is asked by OkHttp's reuse checkand by Okio when it closes its side of a socket, where it then skips a half-close nothing needs. The
fix relies on OkHttp asking that question before it reuses a connection; an upgrade that stopped
asking would fail the tests.
plain one, whose answer only changes when its sending side is closed, which sends a TLS
close_notify; a TLS 1.2 server may answer that by dropping the rest of the reply. An HTTP/1.0 APIbehind HTTPS, with a certificate the JVM trusts, seemed rare enough to leave as a known limitation
rather than take that risk.
without. This is HTTP/1.0 obeyed, and no experiment would want half its requests lost.
restest-exec, easy to take out.Open questions
http.serveras it comes, 16 requests in flight still make it refuse someconnections: about a fifth of a 10 s run's requests, counted under
no replywith no fault. It isthe API's doing (
ab -c 16fails the same way), but a request whose connection could not even beopened never reached the API, and the report does not say so. Worth a follow-up: say it in the
report or the troubleshooting chapter, or send again a request whose connection could not be
opened, which provably sent nothing?
masterAGENTS.mdholddocs/DESIGN.mdwithout explicit approval🤖 Generated with Claude Code