Files

117 lines
6.7 KiB
Markdown

# 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
```