S3 sends 200 OK before it knows whether the upload completed
reads as
CompleteMultipartUpload returns HTTP 200. Conclusion drawn: the object is assembled and present.
actually
S3 sends the 200 header first, then keeps the connection alive with whitespace while assembly runs, which can take minutes. A failure after that point is delivered as an `<Error>` document in the body of the response whose status line already said 200. The API reference states it directly: a 200 OK response can contain either a success or an error.
blind because
The status line is written before the outcome is known, so it cannot encode the outcome. A client that reads the status and closes has read a value committed in advance of the fact it is taken to report.
the check
Parse the body even on 200 and look for an `<Error>` root element; or confirm independently with HeadObject and compare ContentLength and ETag against what was uploaded. Both differ between a completed and a failed assembly; the status code does not.
cost of missing
An upload pipeline records success for an object that does not exist. The gap is found by whatever reads it next, typically much later and in another system.
generalises to
Any protocol that must acknowledge before it can know: streamed responses, long-polling, 202-style accepted work, write-behind caches.
A batch write returns 200 while handing back the items it did not write
reads as
BatchWriteItem returns HTTP 200 and the SDK raises no exception. Conclusion drawn: all 25 items were written.
actually
The individual puts and deletes are atomic but the batch is not. Operations that failed on throughput or an internal error are returned in `UnprocessedItems` inside the 200 body, and the caller is expected to resubmit them with backoff. The low-level client hands them back; only higher-level helpers, such as boto3's batch_writer, resubmit on their own.
blind because
Total success and partial success share a status code, an exception-free return and a well-formed body. The difference is one map that is empty in the first case and populated in the second.
the check
Assert the map is empty rather than assuming it: `sum(len(v) for v in resp.get('UnprocessedItems', {}).values()) == 0`. It is 0 on a full write and non-zero whenever items were dropped.
cost of missing
Rows go missing from a bulk load in proportion to how throttled the table was, with no error recorded anywhere, and the load is reported complete.
generalises to
Every bulk endpoint that reports transport success while carrying per-item failure in its payload.
A single-page app's catch-all rewrite answers 200 for URLs that do not exist
reads as
`curl -o /dev/null -w '%{http_code}' https://site/docs/pricing` returns 200. Conclusion drawn: the page exists and the link is good.
actually
The host rewrites every unmatched path to index.html so the client-side router can handle it. The bytes returned are the application shell; the router decides only in the browser that there is nothing at this route. Google names the pattern a soft 404 and notes that such apps report 200 instead of the appropriate status code.
blind because
The status is produced by the server before any router exists. Every path under the domain, real or invented, returns the same 200 with the same shell and the same content type.
the check
Compare against a path that certainly does not exist: `curl -s $BASE/zzz-not-a-real-path | md5sum` and `curl -s $URL | md5sum`. Identical hashes mean the catch-all answered both; different hashes mean the URL has its own document.
cost of missing
Link checks, sitemap validation and 'the page is live' claims all pass against URLs with nothing behind them.
generalises to
Any fallback that answers on behalf of everything unmatched: wildcard DNS, default vhosts, permissive proxy routes.
A truncated response arrives with its 200 already delivered
reads as
`curl -s -o data.json -w '%{http_code}' URL` prints 200 and data.json holds plausible content. Conclusion drawn: the document was retrieved.
actually
The status line is sent before the body. A connection that dies mid-body leaves a response RFC 9112 calls incomplete: 'a message body that uses the chunked transfer coding is incomplete if the zero-sized chunk that terminates the encoding has not been received', and a client that receives one 'MUST record the message as incomplete'. curl does record it, as exit code 18, but the status code stays 200 and the partial bytes stay on disk.
blind because
%{http_code} is a property of the header, which was true when it was sent. Nothing checks length, because chunked encoding exists precisely for bodies whose length is not known when the header is written.
the check
Read curl's exit status, not only the code it reports: `curl -s -o data.json -w '%{http_code}\n' URL; echo $?`. Observed against a local server that sends one chunk and closes the connection: `200` printed, exit status 18, and a 26-byte truncated prefix in data.json. `--fail` does not catch it, and `-s` suppresses the 'transfer closed with outstanding read data remaining' message that would otherwise appear.
cost of missing
A truncated CSV, NDJSON or log export is syntactically valid and merely shorter, so it loads without complaint and the missing records are indistinguishable from records that never existed.
generalises to
Every protocol that commits to a status before the payload is complete, and every consumer that validates syntax instead of completeness.
curl -I asks a different question from the one users ask
reads as
`curl -I https://site/report` returns 200 with a plausible Content-Length. Conclusion drawn: the page is up.
actually
-I sends HEAD. A server should send the same header fields it would for GET, but it is not obliged to do the same work: frameworks, proxies and CDNs commonly answer HEAD from metadata or cache without invoking the handler that renders the body. The GET a user or crawler performs can fail on the same URL at the same instant.
blind because
Status codes are per-method. A 200 to HEAD is a true statement about HEAD and says nothing whatever about GET.
the check
Request the body and discard it: `curl -s -o /dev/null -w '%{http_code}\n' URL`. Observed against a local server whose HEAD path returns metadata and whose GET path fails: HEAD 200 and GET 500 on the same URL in the same second, with `curl -I --fail` exiting 0 while `curl --fail` exited 22.
cost of missing
Uptime monitors, link checkers and post-deploy smoke tests built on -I stay green throughout an outage every real request is experiencing.
generalises to
Every cheap probe standing in for the operation being assured: a TCP connect for a request, a ping for a service, a dry run for a run.
curl drops the Authorization header when a redirect crosses to another origin
reads as
`curl -sL -u user:pass https://host/private` returns 200 with a body. Conclusion drawn: the credentials were accepted and the private resource is reachable.
actually
'Authorization:' and 'Cookie:' headers are explicitly not passed on in HTTP requests when following redirects to other origins, unless --location-trusted is used. The first hop redirected elsewhere, curl reissued the request without the credentials, and the 200 belongs to whatever that origin serves anonymously: a login page, a public shell, or an empty result set.
blind because
Every hop succeeded. The status code, the effective URL and the body are all consistent with an authenticated request, and the difference is a header removed by the client, which therefore appears in none of the server logs being consulted.
the check
Ask the final host what it received, or compare the effective URL against the origin the credentials were issued for: `curl -sL -w '%{url_effective}\n' ...`. Observed with two local servers, the second echoing the header it received: `curl -sL -u alice:secret http://127.0.0.1:8933/data` printed 'AUTH RECEIVED: None' with status 200, as did the same request carrying an explicit `-H 'Authorization: Bearer ...'`; `--location-trusted` printed the Basic credential. 127.0.0.1 and localhost count as different origins.
cost of missing
An access-control change is signed off against a response that was never authenticated. The converse is worse: the same mechanism makes a broken authentication path look like a working one.
generalises to
Every hop that rewrites a request: proxies stripping headers, SDK retries losing context, message buses dropping attributes.
A GraphQL endpoint returns 200 for a response whose data never arrived
reads as
The POST returns HTTP 200 with a JSON body of the expected shape. Conclusion drawn: the query succeeded and the body holds the data.
actually
Where the operation is executed and no request error is raised, the server should respond with 200 — 'this is the case even if a GraphQL field error is raised' during execution. Field errors are reported in an errors array while the corresponding data fields are null. Servers predating the GraphQL-over-HTTP specification answer 200 for request errors as well, so validation failures arrive the same way.
blind because
The status code describes the transport; the outcome of the operation lives in the payload. A body carrying only errors is a well-formed 200 with the correct content type.
the check
Assert on the payload: `jq -e 'has("errors") | not' resp.json`, and treat null leaves as failures rather than as absent data. Observed against the public countries.trevorblades.com endpoint: a query naming a non-existent field returned http_code 200 with a body containing only an errors array and no data entry; a valid query returned http_code 200 with a data entry.
cost of missing
A pipeline stores the null as a real value. The failure surfaces later as missing data, far from the query, with a 200 in the access log at the point where it actually went wrong.
generalises to
Every API carrying per-operation outcome in the body: JSON-RPC, batch endpoints, SOAP faults, webhook receivers that acknowledge before processing.
A response saved without decompression is stored as its compressed bytes
reads as
`curl -H 'Accept-Encoding: gzip' -o data.json URL` reports 200, exits zero, and data.json is the expected size. Conclusion drawn: the document was fetched.
actually
curl decompresses only when it negotiated the encoding itself. --compressed 'Request[s] a compressed response using one of the algorithms curl supports, and automatically decompress[es] the content'; a hand-written Accept-Encoding header asks for gzip without arranging for it to be undone. The file on disk begins 1f 8b and is a gzip member, not JSON.
blind because
Status, exit code and transferred byte count are identical to a successful plain fetch. %{size_download} counts wire bytes, so it agrees under both hypotheses: 67 bytes either way.
the check
Ask what the file is rather than how big it is: `file -b data.json`. Observed on curl 8.5.0 against a local gzip-encoding server: with a hand-set header the file was 'gzip compressed data' and `grep -c alpha data.json` found no match and exited 1; with --compressed the same URL produced 'JSON text data' and the same grep printed 1.
cost of missing
A search over the artifact returns nothing and the absence is read as a fact about the content. Tools that treat the body as opaque, such as archivers, uploaders and checksums, propagate the encoded bytes without ever failing.
mitigation
curl's manual warns that saved response headers are not modified, so a stored header still claims the content is compressed after curl has decompressed it. The header is not a reliable record either way.
generalises to
Every transport-level transformation the receiver must undo: content-encoding, transfer-encoding, base64 envelopes, client-side decryption.
A 200 carrying an Age header was answered by a cache, not by the origin
reads as
`curl -sI https://site/` returns 200 and a Date header. Conclusion drawn: the origin is serving this, now.
actually
RFC 9111: 'When a stored response is used to satisfy a request without validation, a cache MUST generate an Age header field, replacing any present in the response with a value equal to the stored response's current_age.' A nonzero Age is a shared cache stating how old the body is, and the Date header records when that stored response was generated rather than when the request was made.
blind because
A status code does not identify the responder. Every hop returns 200 on a cache hit, and the body is a complete, valid, previously correct document.
the check
Read the caching headers alongside the status: `curl -sI URL | grep -iE '^(date|age|x-cache|cf-cache-status):'`. Observed at 20:07:28 UTC: https://vercel.com/ returned `age: 551` with `date: Sun, 23 Aug 2026 19:58:15 GMT`, a body nine minutes old, under `cache-control: public, max-age=0, must-revalidate`; https://developer.mozilla.org/ returned `age: 3066` with `x-cache: MISS, HIT, HIT`; a Cloudflare-fronted origin returned `cf-cache-status: DYNAMIC` and no Age at all.
cost of missing
A deploy is verified against content that predates it, and the verification is stable: repeating the request returns the same stored copy with a larger Age, which reads as consistency.
mitigation
A cache-busting query string proves the origin is correct but says nothing about what visitors receive. Both readings are needed, and they answer different questions.
generalises to
Every layer that may answer on another's behalf: CDNs, reverse proxies, resolvers, package mirrors, SDK-level response caches.
A request issued from the origin host does not travel the path visitors take
reads as
`curl -s -o /dev/null -w '%{http_code}' https://example.com/` run on the server returns 200. Conclusion drawn: the site is reachable and correct for visitors.
actually
The name resolved to an address that short-circuits the public path: an /etc/hosts entry, a split-horizon resolver, or the machine's own public address. The origin answered directly, and the CDN, WAF, redirect rules and edge certificate that every visitor traverses were not involved. A failure in any of them is unreachable by this request.
blind because
A status code records that something answered. Which of several layers answered is encoded nowhere in it, and a healthy origin behind a broken edge returns the same 200 as a healthy edge.
the check
Record who answered, not just what: `curl -s -o /dev/null -w 'code=%{http_code} remote=%{remote_ip}\n' URL`. Compare that address against the origin you deployed to. A proxied domain answers from the proxy's address whether or not the origin behind it is alive; an unproxied one answers from the origin itself. Only the second reading tells you the origin is serving.
cost of missing
Edge misconfiguration, an expired certificate, a wrong origin rule or a route blocked by a WAF is confirmed working by a check structurally incapable of reaching it.
generalises to
Any probe issued from inside the system it measures: internal health checks, same-network monitoring, tests that resolve names through a private zone.
A Content-Length shorter than the body truncates the response with no error anywhere
reads as
`curl -s -o data.json -w '%{http_code}' URL` prints 200, curl exits zero, and the bytes written match the Content-Length the server declared. Conclusion drawn: the document was retrieved intact.
actually
RFC 9112 makes the declared length authoritative: if a valid Content-Length header field is present without Transfer-Encoding, its decimal value defines the expected message body length in octets. The client reads that many and stops, whatever else is on the connection. The commonest cause is a handler that computes the length in characters and writes the body in UTF-8, so the response is cut short by exactly the number of extra bytes the non-ASCII characters cost. RFC 9112 anticipates the remainder: a user agent MAY discard the remaining data or attempt to determine if that data belongs as part of the prior message body, which might be the case if the prior message's Content-Length value is incorrect.
blind because
Every length-based check agrees with itself. The header says 67, the file on disk is 67 bytes, and `%{size_download}` is 67. Unlike a connection that dies mid-body, nothing here is incomplete from the transport's point of view, so no exit code, no warning and no retry is produced.
the check
Validate the body on its own terms rather than on the sender's: `curl -s URL | python3 -c 'import sys, json; json.load(sys.stdin)'`. Observed against a local handler serving a 73-byte UTF-8 JSON document under `Content-Length: 67`, the length of the same text in characters: curl reported code=200 size_download=67 and exited 0; the saved file ended `"ok":` and json.load raised `JSONDecodeError: Expecting value: line 1 column 62`. The identical handler taking its length from the encoded bytes returned 73 and parsed. A separate server declaring 16 against a 131-byte body gave curl, Python's http.client and Node all the same silent 16-byte prefix with status 200.
cost of missing
A record set is short by a few entries, a token is cut mid-string, a page loses its closing markup. Each looks like a data problem upstream, and re-requesting reproduces it exactly, which reads as confirmation rather than as a framing bug.
mitigation
Check completeness at the semantic layer for anything whose length matters: parse it, verify its terminator, or compare a digest the origin computed over the same bytes.
generalises to
Every length or checksum supplied by the same party that produced the payload, and every count taken in one unit and spent in another.
Two field lines with one name collapse into one value, and clients disagree about which
reads as
`resp.headers['X-Frame-Options'] == 'DENY'` and the assertion passes. Conclusion drawn: the response carries the header the policy requires.
actually
The response carried the field twice, DENY then ALLOWALL, because two layers each added it. RFC 9110 permits recombination only in limited circumstances: a sender MUST NOT generate multiple field lines with the same name in a message unless that field's definition allows multiple field line values to be recombined as a comma-separated list. Senders do it anyway, and recipients then differ. Some return the first value, some the joined list, some keep both. X-Frame-Options is not a list-valued field, so a browser receiving it twice with conflicting values has no defined behaviour to fall back on.
blind because
A header dictionary maps one name to one string. It has no way to represent 'this name appeared twice with contradicting values', so the single reading that satisfies the assertion is the only reading the instrument can produce.
the check
Read the field lines rather than the parsed mapping: `curl -sD - -o /dev/null URL | grep -ci '^x-frame-options:'`, and treat any count above one as a failure. Observed against a local server sending X-Frame-Options twice (DENY, ALLOWALL) and Cache-Control twice (no-store, max-age=31536000): curl printed both lines each time; Python's urllib.request returned 'DENY' and 'no-store' from `headers[name]` while `headers.get_all` returned both values; `http.client.getheader` returned the joined 'no-store, max-age=31536000'; Node 20.20.2 returned the joined 'DENY, ALLOWALL'. One response, three clients, three different answers.
cost of missing
A security-header audit passes on a response no browser will honour, and a caching audit reads no-store while a shared cache reads the joined value and stores the response for a year.
mitigation
Assert on the count of field lines as well as on the value, and use the client API that preserves multiplicity (`get_all`, `rawHeaders`) wherever a header carries policy.
generalises to
Every flattening of a multi-valued source into a scalar: repeated query parameters, environment variables set twice, merged configuration layers, duplicate rows collapsed by a join.
Overriding the Host header leaves the TLS handshake pointing somewhere else
reads as
`curl -H 'Host: app.example.com' https://<origin-ip>/` returns 200 with a plausible page. Conclusion drawn: the origin serves app.example.com correctly.
actually
The hostname in the URL, not the Host header, supplies the TLS server_name extension. RFC 6066: a server that receives a client hello containing the server_name extension MAY use the information contained in the extension to guide its selection of an appropriate certificate to return to the client, and/or other aspects of security policy. curl documents the split plainly for --connect-to, which is only used to establish the network connection and does NOT affect the hostname/port that is used for TLS/SSL (e.g. SNI, certificate verification). With a bare IP in the URL no SNI is sent at all, so a terminator that selects a certificate, a backend or a WAF policy by SNI falls through to its default.
blind because
The 200 is real and the body may well be the right site, because the default virtual host is often the site under test. Nothing in the status, the body or the headers records which name the handshake carried.
the check
Keep the hostname in the URL and move only the address: `curl -sk --resolve app.example.com:443:<ip> https://app.example.com/`. Observed against a local TLS server that reports both values in its body: `curl -sk -H 'Host: canary.example' https://127.0.0.1:8443/` returned `SNI=None HOST=canary.example`, while `curl -sk --resolve canary.example:8443:127.0.0.1 https://canary.example:8443/` returned `SNI=canary.example HOST=canary.example:8443`. `openssl s_client` split the same way: no -servername, no SNI.
cost of missing
A certificate, a routing rule or a security policy bound to the hostname is never exercised, so the probe passes for a name that would fail for every real client, and the failure appears only when DNS is cut over.
mitigation
Use --resolve or --connect-to, which change where the connection goes without changing what the request and the handshake claim to be.
generalises to
Every request whose identity is asserted at more than one layer: SNI against Host, DNS name against certificate subject, connection string host against database catalogue, tenant header against signed token.
The first status line in a response may belong to an interim response
reads as
`curl -sD headers.txt URL` succeeded and headers.txt begins with a status line followed by a header block. Conclusion drawn: those are the response's status and its headers.
actually
An origin sending 103 Early Hints emits a complete status line and header section before the real one. RFC 9110 requires clients to cope: a client MUST be able to parse one or more 1xx (Informational) responses received prior to a final response, and such a response terminates when the header section ends. A parser that stops at the first blank line, which is what the message grammar tells it to do, reads the interim block, whose header section commonly contains nothing but `link`.
blind because
Both blocks are well-formed HTTP with the same shape. Nothing marks the first as provisional except its status code, which is precisely the field being taken on trust, and whether the interim block appears at all depends on the protocol version negotiated rather than on the URL.
the check
Count the status lines before reading any of them: `curl -sD - -o /dev/null --http2 URL | grep -c '^HTTP/'`, and treat anything above one as two blocks to disentangle. Observed at 00:28 UTC on 2026-08-24 against https://www.cloudflare.com/: 2 under --http2, with `head -1` returning `HTTP/2 103` and `%{http_code}` returning 200; 1 under --http1.1, where the same origin sent only `HTTP/1.1 200 OK`.
cost of missing
A header audit reads the Early Hints block, finds one `link` field, and reports HSTS, CSP, Content-Type and Cache-Control as absent from a response that carries all four. A status check that reads the first line records a 103 and either alerts or, worse, treats an unknown class as a pass.
mitigation
Take the status from the client's own final-response accessor (`%{http_code}`, `response.status`) rather than from the first line of a header dump, and parse header dumps as a sequence of blocks.
generalises to
Every stream where a provisional message precedes the real one: 1xx responses, redirect chains, retried requests, partial results emitted before a final aggregate.
Field names arrive lowercased over HTTP/2, so a case-sensitive check passes vacuously
reads as
The audit greps the response headers for `^Set-Cookie:` lines lacking the Secure attribute, finds none, and records a pass. Conclusion drawn: no insecure cookie is being set.
actually
RFC 9113 requires that field names MUST be converted to lowercase when constructing an HTTP/2 message. Over HTTP/2 the field arrives as `set-cookie:` and a pattern anchored to `^Set-Cookie:` matches nothing at all, neither the compliant cookies nor the insecure ones. Which casing arrives is decided by the version negotiated for that particular request, which is a property of the connection rather than of the URL, so the same command can examine everything in one environment and nothing in the next.
blind because
An absence-based assertion cannot separate 'the condition does not occur' from 'the field was never examined'. Both return zero matches, exit the same way, and read as a clean result.
the check
Prove the pattern matches something before trusting that it matched nothing, and record the version alongside it: compare `curl -sD - -o /dev/null --http1.1 URL | grep -c '^Content-Type:'` against the same command with `--http2`. Observed at 00:28 UTC on 2026-08-24 against https://www.cloudflare.com/: 1 under --http1.1, 0 under --http2, and 1 under --http2 for `grep -c '^content-type:'`. `--http2` had also negotiated 1.1 without complaint against a local HTTP/1.1-only server, reporting `version=1.1 code=200` and exiting 0.
cost of missing
A control that has never once evaluated its subject reports a pass on every run, and the run history makes it look like a stable, long-standing green.
mitigation
Match case-insensitively, and give every absence-based check a positive control that must match for the check to be considered to have run.
generalises to
Every check whose passing condition is an empty result set: greps for a forbidden pattern, queries returning no rows, log searches finding no errors, linters with a misconfigured include path.