server: set ReadTimeout, WriteTimeout, and IdleTimeout (closes #99) #118
Reference in New Issue
Block a user
Delete Branch "fix/99-server-timeouts"
Deleting a branch is permanent. Although the deleted branch may continue to exist for a short time before it actually gets removed, it CANNOT be undone in most cases. Continue?
Closes #99.
internal/server/server.goconstructed itshttp.Serverwith onlyReadHeaderTimeoutset.ReadTimeout,WriteTimeout, andIdleTimeoutdefaulted to zero, which innet/httpmeans no limit — past the header phase a peer could hold a connection open forever, responses had no write deadline, and keep-alive connections were never reaped.REPO_POLICIES.mdrequires all four before 1.0.Values
ReadHeaderTimeoutReadTimeoutWriteTimeoutIdleTimeoutAll four are named
consts in a single documented block inserver.goalongside the existingreadHeaderTimeout.ReadTimeout15s — every route in this service is a bodylessGET, so the read deadline only ever needs to cover headers. The 5s overReadHeaderTimeoutis slack, not a real allowance; it exists so a body dribbled a byte at a time cannot hold the read side open indefinitely. It must be >=ReadHeaderTimeoutor the header deadline becomes unreachable.WriteTimeout75s — must exceed the 60schimw.Timeout(requestTimeout)handler budget. 60s + 15s of response-flush allowance.IdleTimeout120s — the only clients are browsers on the dashboard and a Prometheus scraper. 120s sits above the common scrape intervals (15s/30s/60s) so the scraper reuses its connection rather than reconnecting every cycle, while an abandoned connection is still reaped inside two minutes.The
WriteTimeoutvs handler-budget relationshipnet/httparms the write deadline in a deferred call at the end ofconn.readRequest— i.e. once the request headers have been read — so on a plaintext connection it covers handler execution and the response write, not just the write:If
writeTimeoutwere <=requestTimeoutthe server would sever the connection before a handler that legitimately consumed its full 60s budget could emit anything, making that budget unreachable in practice. The const block's comment states this, andTestWriteTimeoutExceedsHandlerBudgetpins it so a future edit to either number fails the build rather than silently breaking the invariant.Structure
The
http.Serverliteral moved into an unexportednewHTTPServer(listenAddr, handler)in the same file, called fromRun(). This keeps the literal inserver.go(DoD 1) while making the configuration assertable without binding a socket.Tests
New
internal/server/export_test.go(matching the existingexport_test.goconvention ininternal/handlersandinternal/notify) andinternal/server/server_test.goinpackage server_test:TestHTTPServerTimeoutsAreSet— all four fields non-zero (DoD 4).TestWriteTimeoutExceedsHandlerBudget—WriteTimeout>requestTimeout.TestReadTimeoutCoversHeaderTimeout—ReadTimeout>=ReadHeaderTimeout.TestHTTPServerAddrAndHandler— the constructor cannot drop the address or handler.Every assertion compares configured field values. Nothing measures elapsed time, so these cannot flake the way the two recent duration-asserting tests did.
Docs
The timeouts are compile-time constants, not env vars, so per DoD 5 there is nothing to add to the README env-var table. A
Server timeoutssubsection underHTTP APIdocuments the four values and the handler-budget relationship so they are discoverable.TODO.mdis updated in the same commit as the work.Verification
make checkgreen: 4.8s wall withGOFLAGS=-count=1(3.1s warm). Well under the 20s policy ceiling.GOFLAGS=-count=1 make testrun 10 consecutive times under-racewith the cache bypassed: 10 pass, 0 fail, noFAILline in any package on any run.docker build --no-cache .(not barescript/cibuild, which would have been served from the layer cache and reported a false green — see #115): real build, 1m20s total, withRUN make checkgenuinely executing in-container for 35.1s and reporting0 issues..golangci.ymluntouched:sha256sumstill021cc83f4e6fc7c31b95b34b846723dfcf20b66b7baeea1dc40406e643346bcb. golangci-lint pin unchanged.Scope
Confined to
internal/server/server.goplus new test files, README, andTODO.md.routes.go,internal/middleware,internal/watcher, andinternal/resolverare untouched, so there is no conflict with PR #97 or PR #112. Security headers (#98), rate limiting (#100),http.MaxBytesReader(#101), and CORS scoping are not folded in. No DNS is involved and none is mocked.Verification summary
Single commit
02b63a4, branchfix/99-server-timeouts, based onorigin/mainat9347a28.TODO.mdis in the same commit as the work.What changed
internal/server/server.go— the four timeouts as named constants in one documented block; thehttp.Serverliteral extracted intonewHTTPServer(listenAddr, handler)and called fromRun().internal/server/export_test.go(new) — exportsnewHTTPServerand therequestTimeouthandler budget for the external test package, following theexport_test.goconvention already used ininternal/handlersandinternal/notify.internal/server/server_test.go(new) — four tests, all asserting on configured field values.README.md—Server timeoutssubsection underHTTP API.TODO.md— dated Completed Steps entry.How the
WriteTimeoutrelationship was verifiedRead
conn.readRequestin the local Go stdlib (/usr/local/go/src/net/http/server.go:993-997): the write deadline is installed by adeferthat runs whenreadRequestreturns, i.e. after headers are parsed and before the handler runs, so the deadline spans handler execution plus the response write. That is the reasonwriteTimeout(75s) must be strictly greater thanrequestTimeout(60s);TestWriteTimeoutExceedsHandlerBudgetencodes the invariant so it cannot regress silently. I deliberately did not write a test that starts a server and measures elapsed time — this repo has already produced two flaky duration-asserting tests, and the invariant is fully expressible as a comparison of two constants.Runs
GOFLAGS=-count=1 make checkGOFLAGS=-count=1 make testx10,-raceFAILline in any package on any run;internal/server1.02-1.05s eachdocker build --no-cache .RUN make checkreally executed (35.1s in-container,0 issues)I ran
docker build --no-cache .rather than plainscript/cibuildon purpose: on this treescript/cibuildis baredocker build ., soRUN make checkcomes back from the layer cache in under a second having run nothing (#115). The 1m20s figure above is a genuine cold build.Not satisfied / caveats
Nothing in the definition of done is unmet. Two notes for the reviewer:
make fmtin this repo formats Go only (gofmt -s,goimports); there is no prettier config, so the Markdown edits were hand-formatted to match the surrounding style..golangci.ymland the golangci-lint pin are untouched.routes.go,internal/middleware,internal/watcher, andinternal/resolverare untouched, so this does not collide with PR #97 or PR #112. No DNS anywhere in this change, mocked or otherwise.Independent review of PR #118 — verdict: PASS
Reviewed at head
02b63a4, basemain9347a28, in an isolated worktree. Nothing was modified or committed; all mutations described below were reverted and the tree verified clean (git diff HEAD --statempty, no untracked files).Definition of done
server.gointernal/server/server.go:124-137—newHTTPServersetsReadTimeout,ReadHeaderTimeout,WriteTimeout,IdleTimeout. Literal is still inserver.go.consts in the same fileinternal/server/server.go:36-81, one documented block alongsidereadHeaderTimeout. No inline magic numbers.WriteTimeout> handler budget, relationship stated in a commentserver.go:36-51.make checkgreen,TODO.mdsame commit02b63a4contains both.Verification of the central technical claim
The claim that
WriteTimeoutmust exceed the 60s handler budget is correct, and I verified it against the pinned toolchain rather than the local one. The local Go here isgo1.26.5; the Dockerfile digestsha256:f6751d82...resolves togo1.25.7, andgo.moddeclaresgo 1.25.5. I extractednet/http/server.gofrom the pinned image directly.conn.readRequestthere reads:The
deferfires whenreadRequestreturns — after headers are parsed, beforeServeHTTPis dispatched atserver.go:2109— so the write deadline does span handler execution plus the response flush. The deadline is cleared afterfinishRequest(c.rwc.SetWriteDeadline(time.Time{})). Claim holds at bothgo1.25.7andgo1.26.5; the code is byte-identical at those lines. The author's citation was against the unpinned local stdlib, but the conclusion is unaffected.Coherence of the four values
IdleTimeout120s. Confirmed the zero-value hazard was real: pinned stdlibServer.idleTimeout()returnss.IdleTimeoutif non-zero elses.ReadTimeout. Before this change both were zero, so there was genuinely no idle reaping at all. Now correctly non-zero. 120s above 15/30/60s scrape intervals is sound.ReadTimeout15s vsReadHeaderTimeout10s. The ordering is correct and 15s is sufficient. I specifically checked the obvious failure mode — whether a 15s read deadline could kill a handler legitimately running for 60s — and it cannot: for a bodyless requestconn.servecallsw.conn.r.startBackgroundRead()before dispatching the handler, andstartBackgroundReaddoescr.rwc.SetReadDeadline(time.Time{}), clearing the read deadline for the duration of the handler. Every route in this service is a bodylessGET, so this path always applies. No defect.WriteTimeout75s. 15s of flush allowance over the budget is defensible and is reasoned rather than asserted.Test quality — proven by mutation, not by reading
TestWriteTimeoutExceedsHandlerBudgetis not tautological.requestTimeoutis the real const atroutes.go:15and is aliased (not copied) byexport_test.go:19asconst RequestTimeout time.Duration = requestTimeout. I proved the test reaches both sides independently:IdleTimeout:field fromnewHTTPServerserver_test.go:53: IdleTimeout must be non-zero, got 0swriteTimeout75s to 30s inserver.goWriteTimeout (30s) must exceed handler budget (1m0s)requestTimeout60s to 90s inroutes.go(untouched by this PR)WriteTimeout (1m15s) must exceed handler budget (1m30s)The third mutation is the decisive one: the test catches drift originating in a different file that this PR does not modify, so the invariant is genuinely pinned rather than restated. All three reverted, tree clean.
Declining to write a timing-based test was the right call given this repo's flake history, and the invariant is fully expressible as a value comparison. Using
<= 0rather than== 0is also the more correct predicate, since a negativeIdleTimeout/ReadHeaderTimeoutmeans "no timeout" innet/http.Gate results (run by me, not taken on trust)
GOFLAGS=-count=1 make check: green 4 consecutive runs, 8.8s / 4.1s / 3.7s / 4.6s.make fmt-checkclean,0 issuesfrom lint.docker build --no-cache .: 1m22s, green. I did not rely onscript/cibuild— per #115 it is baredocker build .and would have servedRUN make checkfrom the layer cache.internal/server, re-randocker build --no-cache .— build failed in 1m15s with exactly the predicted output (REVIEWER_NEGATIVE_CONTROL_SENTINEL, thenprocess "/bin/sh -c make check" did not complete successfully: exit code: 2). A cached layer cannot produce a predicted failure, so the suite provably executes in the containerised build. Sentinel file removed; tree clean.02b63a4:success(check / check (push), 37s).02b63a4fast-forwards fromorigin/main9347a28(merge-base equals the main tip). No conflicts possible.Hard constraints
.golangci.ymlsha256021cc83f4e6fc7c31b95b34b846723dfcf20b66b7baeea1dc40406e643346bcb— unchanged.c0d3ddc9cf3faa61a4e378e879ece580256d76e5unchanged in bothDockerfile:8andscript/bootstrap:14.go.mod/go.sumunchanged; no new dependency.dnsmatches in the diff are the module pathsneak.berlin/go/dnswatcher/....Co-Authored-By, no assistant/vendor references anywhere in the diff, commit message, or PR body.(closes #99);TODO.mdin the same commit; single commit; inclusive terminology clean.export_test.gois a genuine_test.gofile, so it does not ship in the production binary. It exposes onlyNewHTTPServerandRequestTimeout, and follows the exact comment convention already used ininternal/handlers/export_test.goandinternal/notify/export_test.go. It does not repeat theinternal/state/state_test_helper.gomistake tracked in #111.git diff 9347a28 02b63a4 --stattouches onlyREADME.md,TODO.md,internal/server/server.go, and two new test files.routes.go,internal/middleware,internal/watcher,internal/resolveruntouched — no collision with #97 or #112. No rate limiting, nohttp.MaxBytesReader, no CORS change, no security headers.make fmt-checkpasses. Added README lines are all 79 columns or less with aligned table pipes, consistent with the surrounding section and with the 80-column policy atREPO_POLICIES.md:177. The absent prettier tooling is a repo gap, not a defect of this PR.Non-blocking findings
internal/server/server_test.go:83— the stated rationale is backwards. The comment says "a smaller ReadTimeout would make ReadHeaderTimeout unreachable". Per the pinned stdlib,readHeaderTimeout()returnss.ReadHeaderTimeoutwhenever it is non-zero, andc.rwc.SetReadDeadline(hdrDeadline)applies it directly — so the header phase keeps its full 10s no matter how smallReadTimeoutis. What actually breaks is the opposite:readRequestlater doesif !hdrDeadline.Equal(wholeReqDeadline) { c.rwc.SetReadDeadline(wholeReqDeadline) }, so aReadTimeoutbelowReadHeaderTimeoutwould install an already-expired whole-request deadline and kill any body read instantly. The asserted invariant (ReadTimeout >= ReadHeaderTimeout) is right and worth pinning; only the explanation is inverted. The same inverted wording appears in the PR body and in the plan comment on #99. Acceptable would be: "a smaller ReadTimeout would install an already-expired whole-request deadline once the headers are read." Comment-only; no behavioural impact.Nothing pins that
Run()actually usesnewHTTPServer. All four tests exercisenewHTTPServerdirectly. If a future refactor revertedserver.go:145to an inline&http.Server{...}literal without the timeouts, every test in this PR would still pass and the exposure would silently return — which is precisely the regression DoD 4 exists to prevent. The extraction into a constructor is the right structure; the test just stops one call short. Acceptable would be a test that constructs aServer, invokes the wiring, and asserts ons.httpServer's four fields, or minimally a comment onRun()noting the constructor is the single source of truth. Not blocking, since the current tests do satisfy DoD 4 as written and the mutation results above show they bite.TestHTTPServerAddrAndHandleris low value. It asserts a two-field struct literal copies its own arguments. Harmless, cheap, and it does document intent, but it is close to testing the compiler.Verdict
PASS. The change does what it claims, the central
net/httpargument is correct at the pinned toolchain, the values are reasoned rather than asserted, the tests are non-vacuous under mutation, the containerised suite provably runs, CI is green, the branch fast-forwards ontomain, and every hard constraint holds. The three findings above are comment- and coverage-polish, not defects in the shipped behaviour.[manager] Independent adversarial review returned PASS with no blocking findings — see the reviewer's verdict above. Labeling
merge-readyand assigning to @sneak.This is the most rigorously verified PR in the 1.0 series so far, and the verification methodology is worth recording because it is now the standard here.
What made this review conclusive
A negative control on the Docker gate. The reviewer planted a failing test, ran
docker build --no-cache ., and got a failure in 1m15s carrying the predicted sentinel plusprocess "/bin/sh -c make check" did not complete successfully: exit code: 2. Then reverted and confirmed the tree was clean. A cached layer cannot produce a specifically predicted failure, so this is proof the suite ran — not the inference from wall-clock time that #115 showed can be wrong.Mutation testing that reached across files. Three mutations, all failing as required, all reverted with
git diff HEAD --statempty:IdleTimeout→IdleTimeout must be non-zero, got 0swriteTimeout75s→30s →WriteTimeout (30s) must exceed handler budget (1m0s)requestTimeout60s→90s in the untouchedroutes.go→WriteTimeout (1m15s) must exceed handler budget (1m30s)That third one is the important one. It proves
TestWriteTimeoutExceedsHandlerBudgetreaches the real cross-file constant rather than a local copy — which is exactly the tautology I asked the reviewer to rule out.The stdlib claim was checked against the pinned toolchain, not the local one. The author cited local Go; the reviewer resolved the Dockerfile digest to go1.25.7 (local is go1.26.5), extracted
net/http/server.gofrom the pinned image, and confirmed theWriteTimeoutdeferinconn.readRequestis byte-identical at both versions and does span handler execution. The conclusion holds, but it now holds on evidence from the toolchain that actually builds this.The obvious attack failed cleanly.
ReadTimeout15s against a 60s handler looks like it should sever long requests — it does not, becausestartBackgroundReadcallsSetReadDeadline(time.Time{})before dispatch on bodyless requests. No defect. Also confirmedidleTimeout()falls back toReadTimeoutwhen zero, meaning the pre-existing hole this PR closes was real: both were zero.Also worth noting the author's judgement call was right — they deliberately wrote no timing-based test, citing this repo's two recent flaky duration-asserting tests, and asserted configured values instead. That is the correct lesson to have drawn from #113.
Non-blocking findings — tracked, not forgiven
Filed as #120. Two of the three deserve comment:
The rationale comment at
internal/server/server_test.go:83is inverted. It claims a smallerReadTimeoutwould makeReadHeaderTimeoutunreachable. It would not — the pinned stdlib'sreadHeaderTimeout()returnss.ReadHeaderTimeoutwhenever non-zero and applies it directly, so the header phase keeps its full 10s. What actually breaks is the whole-request deadline, which gets installed already-expired. The asserted invariant is correct; only the explanation is backwards. I am letting this merge rather than spending a rework-plus-fresh-review cycle on a comment, but it is not cosmetic: a confidently wrong explanation of subtle stdlib deadline semantics is the kind of thing a future maintainer reasons from, and the same wording appears in the PR body and in the plan comment on #99.Nothing pins that
Run()actually usesnewHTTPServer. All four tests call the constructor directly. A refactor revertingserver.go:145to an inline&http.Server{...}without timeouts would leave every test green. That is a partial miss of DoD item 4, whose stated purpose was "so a future refactor cannot silently drop one" — the tests protect the constructor, not the call site. I am recording it as a miss rather than pretending the contract was fully met.Neither justifies blocking a correct, well-evidenced change, but both are on the 1.0 milestone.
Constraints verified
.golangci.ymlsha256 exact match; lint pin unchanged inDockerfile:8andscript/bootstrap:14;go.mod/go.sumuntouched; no DNS; no vendor references or attribution trailers; title ends with(closes #99);TODO.mdin the same commit; scope clean — noroutes.go, middleware, watcher, or resolver changes, so no collision with #97 or #112.export_test.gois a genuine_test.gofile and does not repeat theinternal/stateproblem tracked in #111. Fast-forwards fromorigin/main9347a28.make check: 4 consecutive cache-bypassed green runs (8.8s, 4.1s, 3.7s, 4.6s), well inside the 20s ceiling.[manager] Lint result revalidated —
merge-readystands.A host-wide defect came to light after this PR was labeled:
golangci-lintuses a single shared cache and lock per user account, and many concurrent sessions on this machine lint different repositories from throwaway worktrees. A run on a sibling repo returned 399 issues attributed to a worktree path belonging to another session, and runs can also fail withError: parallel golangci-lint is running— a non-result that reads as a failure. Filed as #121.Re-ran
make linton this PR's head02b63a4in a fresh worktree with an isolated cache (GOLANGCI_LINT_CACHEpointed at a dedicated temporary directory):Validity checked against both void conditions: no
parallel golangci-lint is running, and no file paths outside the worktree it ran in. Sound result; label unaffected.The reviewer's Docker negative control and cross-file mutation tests are unaffected by this defect — a planted failing test surfacing its predicted sentinel is test execution, not lint, and no lint cache can fabricate it. That evidence stands as recorded.
The only other output was a pre-existing
gomodguarddeprecation warning, unrelated to this change — now tracked in #123 (corrected from an earlier version of this comment, which cited the wrong number; #122 is a pull request).server: set ReadTimeout, WriteTimeout, and IdleTimeout (closes #99)to WIP: server: set ReadTimeout, WriteTimeout, and IdleTimeout (closes #99)WIP: server: set ReadTimeout, WriteTimeout, and IdleTimeout (closes #99)to server: set ReadTimeout, WriteTimeout, and IdleTimeout (closes #99)View command line instructions
Checkout
From your project repository, check out a new branch and test the changes.