Skip to content

test(matrix): fault suite for both frontends, and the defects it found - #323

Open
jaronoff97 wants to merge 9 commits into
stdio-deadlines-observabilityfrom
stdio-fault-matrix
Open

jaronoff97 wants to merge 9 commits into
stdio-deadlines-observabilityfrom
stdio-fault-matrix

Conversation

@jaronoff97

@jaronoff97 jaronoff97 commented Sep 16, 2026

Copy link
Copy Markdown
Contributor

Stack

PR What
1 #322 stdio inbound deadlines, and a log plus a status on every drop path
2 #323 (this one) the fault matrix, telemetry expectations, and the fixes it found

Merge bottom to top. Based on stdio-deadlines-observability, so review #322 first.

What

A fault matrix under bench/matrix/: faults injected on both sides of the edge — the agent that sends to us, and the intake we send to — with every case run against both frontends.

Neither frontend is the oracle. My first draft treated httpz as correct and marked every stdio difference as acceptable, which hid four httpz defects. Each case now asserts what a customer needs, and both frontends are judged against it. Behaviour we have already found is declared, not excused:

class Pipelined(MatrixCase):
    DEFECTS = {"httpz": "answers 400 to a pipelined pair"}

The assertion still runs at full strength. The runner reports xfail with the note, and XPASS the day it starts passing, which means the note must go.

The intake is the real echo-server, which gains ten fault modes armed over HTTP (POST /fault?mode=&arg=&count=): status, slow, hang, close_early, reject_early, reset, garbage, truncate, oversize, stale_keepalive. count faults only the next N requests, so a case can break the first attempt and require the retry to succeed.

34 cases, one per file, one class per case, over three surfaces: sender framing and encodings (a*), the upstream leg (b*), and capacity (c*). Every case also asserts the invariants that hold whatever the fault was: the process lives, health still answers, connection slots return to baseline, and every 5xx left a log line.

$ ./bin/uv run --python ./bin/python3 --with requests --with zstandard bench/matrix/run.py
case                                                     stdio     httpz
a04_slow_drip_body.test_a_dribbling_sender_is_cut_off    pass      xfail
a09_corrupt_gzip.test_..._cannot_decode_fails_open       xfail     xfail
a12_pipelined.test_pipelined_requests_are_both_served    pass      xfail
b09_stale_keepalive.test_a_dead_pooled_connection...     pass      pass
c02_health_under_burst.test_health_stays_fast...         pass      xfail
34 case(s), 0 failure(s), 9 known defect(s)

Defects found and fixed here

  1. stdio never retried any batch. Every body with a Content-Length was streamed, and a stream cannot be replayed, so one stale pooled connection lost it. Log intake clients do not retry, so this was silent data loss. Bodies below the streaming threshold are now resident, which is where httpz already drew the line. Caught by b04, b09, b10.
  2. A header flood answered 502, sending an agent into a retry loop it could never win. Our cap and the sender's fault, so it answers 431 now. Caught by a14.
  3. A retried dial was never counted, so a dial storm was invisible in edge_upstream_retries_total. Caught by b01.

Defects found and still open

Both frontends:

  • An undecodable body is dropped when policies are loaded, though service/datadog documents fail-open. The edge becomes the reason the data disappears. (a09)
  • A truncated intake response is reported as 202. An agent that reads 202 deletes its copy. (b11)
  • An unsupported content encoding drops the batch: httpz answers 502 and blames the intake, stdio answers 400. (a10)

httpz only:

  • A batch of up to 16 requests goes to one pool thread: a health probe that arrives with a burst waited 6.0 s against a 3 s intake. This is the reported ECS failure. (c02)
  • Pipelined requests refused with 400. (a12)
  • Headers above the cap dropped in silence, request still answers 202. (a14)
  • A stalled sender gets no status: partial head, short body, dribbled body all close with nothing. (a02, a03, a04)

stdio only:

  • A broken chunk waits for the request deadline instead of being refused at once, so bad framing holds a slot for 30 s. (a07)

IMPLEMENTATION-PROGRESS.md carries the full list and the task breakdown.

Notes

  • zig-out-stdio/ is now in .gitignore; the previous commit captured its 3.2 MB binary, and this one untracks it.
  • Python comes from hermit (3.12.3); requests and zstandard come from uv run --with. oha stays a brew dependency, as bench/perf/run.sh already assumes, and the load cases skip without it.
  • zstd batches pass on both frontends, which matters because current Datadog agents send them.

🤖 Generated with Claude Code

Injects faults on both sides of the edge — sender to edge, and edge to
intake — and runs every case against both frontends.

Neither frontend is the oracle. The first draft treated httpz as correct and
marked every stdio difference as acceptable, which hid four httpz defects.
Each case now asserts what a customer needs, and both frontends are judged
against it. Behaviour we have already found is declared with
DEFECTS = {frontend: note}: the assertion still runs at full strength, the
runner reports it as xfail with the note, and reports XPASS the day it starts
passing.

The intake is the real echo server, which gains ten fault modes armed over
HTTP (POST /fault?mode=&arg=&count=): status, slow, hang, close_early,
reject_early, reset, garbage, truncate, oversize, stale_keepalive. `count`
faults only the next N requests, so a case can break the first attempt and
require the retry to succeed. /stats reports whether the fault fired.

34 cases, one per file, over three surfaces: sender framing and encodings,
the upstream leg, and capacity. Every case also asserts the invariants that
hold whatever the fault was: the process lives, health still answers,
connection slots return to baseline, and every 5xx left a log line.

Three defects found and fixed here:

1. stdio never retried any batch. Every body with a Content-Length was
   streamed, and a stream cannot be replayed, so a single stale pooled
   connection lost it. Log intake clients do not retry, so this was silent
   data loss. Bodies below the streaming threshold are now resident, which is
   where httpz already drew the line.
2. A header flood answered 502, sending the agent into a retry loop it could
   never win. Our cap and the sender's fault, so it answers 431.
3. A retried dial was never counted, so a dial storm was invisible in
   edge_upstream_retries_total.

Eight remain open and are declared, three of them on both frontends: an
undecodable body is dropped when policies are loaded rather than failing open;
a truncated intake response is reported as 202; an unsupported encoding drops
the batch. IMPLEMENTATION-PROGRESS.md carries the full list.

zig-out-stdio/ is now ignored; the previous commit captured its binary.
@macroscopeapp

macroscopeapp Bot commented Sep 16, 2026

Copy link
Copy Markdown

Approvability

Verdict: Not approved

Macroscope's review found this PR not approvable — Although the PR adds a valuable development-only fault matrix, it also changes production routing, retry and buffering semantics, overload handling, policy failure behavior, response relaying, and shutdown across both frontends. Its 101-file, 4,499-line scope and cross-component runtime impact require human review.

Not approved because:

  • Credit balance exhausted. Approvability relies on correctness review in order to determine eligibility

Review your spending limits in Billing settings. You can add or adjust custom eligibility rules. Learn more.

Before this, 19 of 31 case files asserted nothing about metrics or logs, and
the one universal rule was weak: if a 5xx appeared, some line mentioning
`request.failed` or `upstream` had to appear somewhere. A fault an operator
cannot see is still a fault, so telemetry is now part of the expectation.

Cases declare it:

    EXPECT_METRICS      {series: minimum delta} for both frontends
    EXPECT_METRICS_FOR  {frontend: {...}} for a frontend specific series
    EXPECT_LOGS         substrings that must appear
    EXPECT_LOGS_FOR     {frontend: [...]}
    FORBID_LOGS         substrings that must NOT appear

FORBID_LOGS is the half that earns its keep: it is how a sender-side fault is
kept from ever reading as an intake fault, which is the confusion that sent us
to the wrong subsystem in the first place.

On top of that, a counter that moved must be explainable from the log
(METRIC_NEEDS_LOG), so a case that declares nothing still cannot pass with
silent telemetry. 24 of 30 case files now declare expectations: 19 metric
assertions, 13 log assertions, 14 forbidden-line assertions. Verified by
breaking one expectation on purpose and watching the case fail.

Five harness defects surfaced while adding this, each of which had produced a
false finding:

- Both edge streams were captured into one file. The edge writes INFO and WARN
  to stdout and ERROR to stderr through independent buffers, so the file
  interleaved and cut lines in half: `request.failed` landed at byte 0, ahead
  of the startup lines. The streams are captured separately now.
- Case-scoped logs were sliced by byte offset, which assumes one
  append-ordered file. They are diffed by line now.
- Log assertions read once, so a line that arrived just after the response
  read as missing. They poll now, and the forbidden-line check settles first.
- "Every 5xx must leave a log line" was wrong: a 5xx relayed from the intake
  is not our failure and owes no log. The pairing rule replaces it.
- FORBID_LOGS = ["upstream"] matched the startup line `upstream.configured`.

Also corrects b01: a refused dial logs `upstream.retried`, not
`upstream.connection.error`.
…elay

Three fixes the fault matrix found, all in our own code.

The streamed policy path dropped a body it could not decode. With a policy
loaded, an undecodable batch answered 400 and never reached the intake, while
execPipeBuffered already failed open for the same condition. The edge must
never be the reason data disappears: only the intake can accept or reject a
payload. Both stages now forward the raw batch, emit policy.failed.open with
the stage, and count a module error. Caught by a09.

A truncated intake response was reported as 202. An intake that declares more
body than it sends has not accepted the batch, and an agent that reads 202
deletes its copy. relayResponse now compares the bytes copied against the
declared content-length and fails with UpstreamResponseTruncated (502), so the
frontend closes without finishing the body and the sender retries. Caught by
b11.

httpz dropped request headers above its own cap in silence. Its default
max_header_count is 32, below our 64-header forward cap, so a request with
more headers was forwarded incomplete and still answered 202. The httpz config
now sits above our cap, so our cap refuses the request with 431 instead.
MAX_FORWARD_HEADERS moves to limits.zig as the single source of truth. Past
httpz's raised cap it truncates again, which needs a fix in httpz itself.

Two earlier findings were wrong and are corrected in the suite:

- httpz handles an unsupported request encoding correctly. The 502 came from
  our own std.http.Server based echo intake refusing brotli. Verified against
  a lenient intake: httpz forwards the batch and relays 200. stdio still
  cannot accept it, because std maps content-encoding through
  ContentEncoding.fromString and fails the whole head for anything else.
- httpz does cut off a dribbling sender: its request timeout runs from accept,
  not per read. The runner reported XPASS and the declared defect was removed.

The suite also gained a guard against a mistake of mine: a stdio build without
--prefix overwrites zig-out/bin/edge, so the harness now reads the frontend
each binary reports at startup and fails if it does not match.
One row per case: what the fault injects, what must happen, and whether a
frontend carries a declared defect for it. Three tables, one per surface:
the sender to the edge, the edge to the intake, and capacity.
…challenges

Fourteen cases from BACKLOG.md, chosen by whether a customer would hit them
and whether the harness can express them honestly. The framer and OTLP
torture cases are left out: they belong next to the framers in the Zig unit
tests, which already cover escapes, brackets in strings and truncation. The
idle-close race is left out because the backlog is right that it cannot be
hit deterministically. Soak mode and the tiny profile are runner projects.

Three claims were verified against the running edge before any case was
written, and two of them did not hold as stated:

- a22 holds, and is worse than described: both frontends forward HEAD and
  POST /_health to the intake, because the route is GET-only and everything
  else falls through to the passthrough. An ALB or ECS check configured for
  HEAD tests Datadog, not the edge, and fails whenever the intake is
  unreachable.
- a30 holds, but not against a stale pooled connection, which is caught
  before the body moves. The failure that matters is mid-exchange: a 300 KiB
  batch cannot be replayed and becomes a 502, while a 60 KiB batch is retried
  and succeeds. Agent batches routinely exceed the threshold.
- a32 does not reproduce. A query with a bare percent sign and a space
  forwarded fine on both frontends.

The two challenges are now settled by tests rather than by argument.

"Log intake clients do not retry" is wrong, and several of my earlier
severity claims rested on it. From comp/logs-library/client/http/destination.go
in DataDog/datadog-agent: 400, 401, 403 and 413 increment payloads_dropped and
are never resent, while every other error status and every transport failure
becomes a RetryableError with exponential backoff. harness/agent.py records
that with the citation, and a new invariant asserts it: answering a
drop-class status for a batch the intake never received is permanent data
loss. That raises the stakes on our 4xx choices and lowers them on our 5xx
choices.

The b11 note was half right. b11b separates the halves with a new echo mode.
When the intake truncates before reading the body it received nothing, so our
502 and the agent's retry repair it. When it reads the whole batch and then
truncates, the batch is already in, and our 502 makes the agent deliver a
second copy: measured at exactly two.

New findings from the cases:

- stdio takes 30 s to shut down with idle keep-alive connections, against
  httpz's 2 s, because shutdown waits out the idle deadline. Every deployment
  stalls a task against the orchestrator's kill timeout. (d03)
- A body that expands past the decoded cap answers 413, which the agent
  discards for good, rather than forwarding what we cannot decode. (a35)
- stdio forwards an absolute-form target upstream as a path. (a22)
- Health is shed at capacity rather than reserved. (c05)

Also adds three universal invariants, each of which caught something the
per-case assertions missed: no phantom success, in-flight back to zero, and
descriptors back to baseline.
…de budget

Four decisions from the backlog review, three of them fixed here.

The health route was GET-only, so `HEAD /_health` and `POST /_health` fell
through to the wildcard passthrough and travelled to Datadog. A load balancer
configured for HEAD therefore tested the intake rather than this process, and
failed whenever the intake was unreachable — the same shape as the incident
this work started from. The route claims every method now: 200 for GET and
HEAD, which RFC 9110 requires wherever GET is served, and 405 for the rest.
`/_edge/*` had the same hole in both frontends and gets the same treatment.

The phantom-success invariant then caught a second layer: `classifyKnownPath`
matched the control paths by method, so a HEAD probe was labelled `other` and
counted in the data-path series. Both control paths are labelled by path now.

stdio took 30 s to shut down with idle keep-alive connections, against httpz's
2 s. `Lifecycle.shutdown` cancels the group, but cancellation does not reach a
task parked in a poll, so every connection waited out its own idle deadline
while the orchestrator counted down its kill timer. The conn slab records the
socket for each claimed slot, and `stopAccepting` interrupts them all — the
same technique the upstream watchdog already used. Exit is now under 2 s.

A body that expanded past the decoded cap answered 413, and the Datadog agent
discards a 413 permanently (see bench/matrix/harness/agent.py). The two size
limits mean different things to a sender, so they are split: the raw cap keeps
413, because the sender can split the batch, and a decoded-size overrun fails
open, because the sender cannot see our decode budget and the intake is the
party entitled to judge the payload.

The fourth, a batch above the streaming threshold not being replayable, is
documented rather than changed. The agent retries a 5xx with backoff, so the
cost is a delayed batch and a duplicate risk, not lost data, and making every
batch replayable would hold up to `max_body_size` per concurrent request: the
memory a policy deployment already pays and a passthrough deployment does not.
The trade is recorded at the decision in stdio/conn.zig and in the a30 case;
exposing the threshold as configuration is the follow-up.

a22, a35 and d03 now pass on both frontends, and the health route gains three
unit tests.
Twenty-eight cases from the backlog, everything with a category and number
that does not need a particular platform, plus the stdio fixes they justify.

Fixes:

- stdio keeps a control reserve. Two slots are held back from ordinary
  connections; a connection taken from the reserve serves one control request
  and closes, and anything else is shed after the head rather than before it.
  A health probe now answers while the slab is full, instead of being shed and
  restarting the sidecar during the very spike that filled it. (c05)
- stdio accepts an absolute-form target and routes on its path, per RFC 9112
  §3.2.2. A sender configured with a proxy was having the whole URL treated as
  a path and forwarded to the intake. (a22)
- httpz relays response headers up to our own cap. Its default of 16 truncated
  an intake answer in silence, so a Retry-After on a 429 vanished while the
  request still reported 202: the mirror of the request-side fix. (b17)

New intake fault modes: keep_alive_off, bodiless, redirect, header_flood,
http10_no_length, slow_body, slow_read, accept_silence, read_then_close and
truncate_after_read, plus the last request target in /stats for target
fidelity.

Two corrections to claims I made earlier:

- `.*` is not a policy defect. Hyperscan refuses a pattern that can match an
  empty buffer, so `.*` never compiles, and `.+` or `^.*$` is how to say
  "everything". The real finding is narrower and worse: the matcher builds
  nothing (`matcher.index.build.completed ... policy_count=0`) while the
  loader reports `loaded_count=1 failed_count=0` and `/_edge/policies` lists
  the policy as enabled, so a rule that cannot compile looks live. Declared
  on both frontends. (a41)
- The 204 re-framing is stdio only; httpz keeps it bodiless. (b16)

Also found and declared: stdio refuses an uppercase `GZIP` content-encoding
with 400, which the agent discards permanently, because `std.http.Server`
matches content codings case-sensitively though RFC 9110 §8.4.1 says they are
case-insensitive. (a34)
The two outstanding defects that were ours rather than a dependency's.

A policy whose pattern the matcher refuses stays in the snapshot and evaluates
nothing. policy-zig already records the reason per policy, and the edge never
read it, so an operator saw `loaded_count=1`, `failed_count=0` and
`enabled=true` for a rule that does nothing at all. `/_edge/policies` now
leads with `# REJECTED id=<policy>: log: match[0]: invalid regex ".*"`,
`edge_policies_rejected` counts them, and `policies.rejected` warns when the
count changes. Hyperscan refusing `.*` is correct; hiding it was not.

The stdio relay opened a streamed body for every status, so a bodiless 204
came back framed as chunked, which some clients reject outright. 204, 304 and
1xx now answer directly and the relay writes into a discard.

Both cases flipped from declared defects to passing on both frontends: 80
cases, 0 failures, 6 known defects, all of which now need a change in std, in
httpz, or a decision about the streaming threshold.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Development

Successfully merging this pull request may close these issues.

1 participant