# Web platform runbook What an operator needs when the HTTP boundary misbehaves. Organised by what you observe, because that is what you have at 3am — not by which module owns it. ## Reading the two "too much traffic" answers The platform never answers these two interchangeably, and the difference tells you where to look. | You see | It means | Where to look | | --- | --- | --- | | `429 RATE_LIMITED` climbing for one caller | that caller exceeded its quota | the caller. Nothing is wrong with the service. | | `429` climbing across **all** callers | quota is being charged for requests the service then shed | look at 503 first; see below | | `503 ADMISSION_REJECTED` | the service has no capacity | saturation: check the admission profile's concurrency and what is holding slots | The second row is a real consequence of the design and worth knowing before it confuses you. Quota is charged *before* capacity is requested, deliberately — the reverse order lets a caller who is about to be rate-limited occupy a slot on the way to being told so. Under sustained overload every caller's quota therefore drains on requests that never ran. **Rising 429 during an incident is a symptom of shedding, not of callers misbehaving.** Read the 503 rate first. ## A control that appears configured and does nothing This has happened here, and the failure is silent by construction. A configured-but-unwired control behaves exactly like a working one until it is needed. - **Check first:** the platform snapshot's `uninstalledControls()`. `WebPlatformStartupValidator` fails startup on a missing required control, so a running instance with one missing means it was not in the required list. - **The instance found the hard way:** the problem catalog was complete, fully unit-tested, and reached by nothing on the framework's error path. Spring answered failures with its own `ProblemDetail` — RFC 9457-shaped, so it looked correct — carrying no `code` field. It was found by a cross-stack parity recording, not by any test of the catalog. - **How to confirm quickly:** send a request that must fail validation and check the body has a `code`. No `code` means the platform's handler is not installed. ## A response is truncated or the connection dies mid-document Two different causes, distinguished by the status the client did receive. - **Client got a status, then nothing** — the response budget was crossed after commit. The platform cannot retract a status, so it ends the connection: a truncated response the client rejects is better than a short one it accepts as complete. Raise `maxResponseBytes` for that operation's profile, or make the endpoint paginate. - **Client got nothing at all** — either the request budget was crossed before headers, in which case there is a problem document, or the proxy refused it. `client_max_body_size` in nginx is below the application's body budget by default, and nginx answers with its own HTML rather than a problem document. See `repository-adaptation.md` §8 for which bound fires where. ## A retried write happened twice The idempotency key is the control, and there are exactly three ways it fails to apply. 1. **The client did not send one.** The operation profile says `OPTIONAL`, so it ran unguarded. Change the profile to `REQUIRED` if a duplicate is unacceptable. 2. **The client sent a different body.** Answered `422 IDEMPOTENCY_KEY_REUSED`, never a replay — replaying would hand back a receipt for a request the caller never made. 3. **The record expired.** TTL is per-operation with a 72h cap. A retry after expiry is a new request by definition. A `409 IDEMPOTENCY_REQUEST_IN_PROGRESS` is not a failure: an earlier attempt is still running and the caller should retry after `Retry-After`. ## Rolling deploy stalls with instances half-drained `shutDownGracefully` waits for in-flight work, and on Reactor Netty an open-but-idle keep-alive connection counts as in-flight. Calling `stop()` while that wait is in progress hangs. - **Symptom:** an instance neither serving nor exiting, no error in its log. - **Cause:** an unbounded graceful wait. `GracefulShutdownProbe` bounds it at a stated grace period and stops the server regardless once it elapses; a deployment must do the same. - **Setting:** `spring.lifecycle.timeout-per-shutdown-phase`. Without a bound the deploy waits for a connection that may never go idle. ## Metrics stopped arriving, or the bill jumped Almost always a high-cardinality tag. `WebMetricCardinalityPolicy` refuses anything off an eight-name allowlist at the point of recording, so a new tag cannot appear by accident — but a `routeTemplate` carrying a *resolved* path can, and that is one series per resource. - **Check:** the tag values in the metrics backend for `routeTemplate`. Braces mean templates; identifiers mean the resolved path leaked through. - **Never tags:** the URL, the query string, any identifier, the tenant, a key, a token, a cookie, a body. A tag value reaches the metrics backend unredacted and usually a third-party SaaS with it. ## Cross-origin requests fail only in the browser The API answers correctly and the browser refuses the response. Everything below is refused at startup by `WebCorsPolicyValidator`, so a running instance with one of these means the profile was built somewhere that does not validate. - `*` with credentials — no browser honours it. - An origin with a path, a trailing slash, or uppercase — never matches what the browser sends. - `https://*.example.com` — matches nothing; CORS compares origins exactly. - Wildcard `allowedHeaders` on a credentialed profile — not honoured with credentials. A preflight answered `401` means CORS ran after authentication. Preflights carry no credentials by design; the order is asserted by `WebPipelineOrderContract`. ## Two access-log lines for one request An async request passes through the servlet filter chain twice — once for the initial request and again on the ASYNC redispatch. A completion recorded without checking `isAsyncStarted()` is written both times, and every latency percentile computed from that data is wrong while looking plausible. `WebPipelineOrderContract` asserts exactly one observation per logical request on every container. ## Verification commands ```bash cd src ./gradlew :adapter:inbound:web:test # unit, boundary, architecture ./gradlew :adapter:inbound:web:webCrossStackParityTest # Tomcat vs Jetty vs Reactor Netty ./gradlew :adapter:inbound:web:webNginxProxyTest # real proxy; needs Docker, fails without it ./gradlew :adapter:inbound:web:webJettyCompatTest # second servlet container ./gradlew :adapter:inbound:web:webFluxContractTest # reactive stack ```