Skip to content

barrage v1: rate-controlled load generator over the shared core - #18

Merged
saifullah4khan merged 7 commits into
mainfrom
feat/barrage
Jul 16, 2026
Merged

barrage v1: rate-controlled load generator over the shared core#18
saifullah4khan merged 7 commits into
mainfrom
feat/barrage

Conversation

@saifullah4khan

Copy link
Copy Markdown
Owner

Barrage v1 per assignments 05 and 06. 448 passed under bare pytest -q. Built in four committed steps, plus a bug fix and the backlog reconciliation the freshness guard forced.

Where Blast proves the parser is correct under messy input, Barrage proves the pipeline holds under load. Same suite, same installable, same core. Clean load only, never blast/corrupt.py.

  • barrage/runner.py — closed-loop fixed-concurrency and open-loop fixed-arrival-rate, warmup ramp, steady-state hold, and a hard rate-and-duration ceiling requiring an explicit flag.
  • barrage/payloads.py — reuses blast.generate.generate_corpus, seeded.
  • barrage/report.py — throughput achieved versus target, latency p50/p90/p99, error rate over time, the knee. JSON artifact plus human summary.
  • barrage/fire.pytestinghq barrage fire, dry-run default, plus replay.

core/ untouched, verified by diff.

Safety controls, verified end to end in a container, not asserted

dry-run is the default (no --send)          -> no network calls made
--rate 100000 --duration 3600 --send        -> REFUSED: exceeds the 50 req/s ceiling,
                                               needs explicit --allow-high-rate
--target not-configured --send              -> REFUSED: not in the configured target list
--target prod-real (-> api.stripe.com) --send -> REFUSED: non-reserved public host

That last one is the important one. The target is in the config and is still refused because the resolved URL points at a real public host. It proves the CLI passes both the name (allow-list) and the resolved URL (public-host check) through require_configured_target. Passing only the name leaves the hardening inert, because a single-label name has no dot and classifies as internal. That bug was found in my own instructions earlier today; it went into this brief and landed right first time.

The rate ceiling, the configured-target rule, and the dry-run default are what keep this a load tester against infrastructure you own rather than a flooding tool.

A real bug in core/ratelimit.py, found here, deliberately not fixed here

TokenBucket.acquire() spins forever under a purely additive injected clock when the rate's reciprocal is not exactly representable in binary. It computes wait_for = deficit / rate, advances by exactly that, refills by wait_for * rate, which rounds to just under deficit. Residual ~1e-16, next wait_for ~1e-17, and adding 1e-17 to a clock reading ~0.67 is a no-op at float precision: elapsed becomes 0, no refill, infinite loop.

Rates 2 and 4 are binary-exact and never trip it, which is why every existing test passes. Rate 10 hangs. Under a real monotonic clock it self-heals, so it is invisible in production and fatal under the injected clocks this repo mandates for hermetic tests.

The implication worth reading twice: tests/security/test_rate_limit_gate_contract.py passes today because it happened to pick a representable rate, not because the code is correct. A guardrail test passing by coincidence of a parameter choice.

This lane worked around it in runner.py and correctly did not reach into core/. Root cause is still on main and is filed for Lane A in the backlog, including parameterising both regressions over non-representable rates so neither can pass by luck again.

The freshness guard fired on its first live run

Landing testinghq/barrage/runner.py turned tests/test_backlog_freshness.py red, because the backlog still claimed Barrage was in progress. 447 passed, 1 failed — exactly as designed. This PR could not go green until the docs matched the code. That is #17 doing its job on contact and "keep docs current in the same PR" being enforced rather than requested.

It also exposed a flaw in the guard: the documentation example used testinghq/barrage/runner.py as its sample path, so it was a live marker rather than an illustration and armed itself the moment Barrage landed. Rewritten to name a non-existent path with the marker word split. An example of a tripwire must not be a tripwire.

Also fixed by the agent unprompted: it had added a tests/unit/barrage/__init__.py that no other test directory carries, and removed it to match convention.

saifullah4khan and others added 7 commits July 16, 2026 18:12
Barrage is a load generator that fires provider-shaped payloads at an
endpoint the operator controls, at a high but controlled rate, and reports
behaviour under sustained load. It is a load tester against your own
infrastructure. It is NOT an email sender, NOT a flooding tool, and NOT for
endpoints you do not own.

runner.py: open-loop (fixed arrival rate) and closed-loop (fixed
concurrency) modes, a warmup ramp plus steady-state hold, and a hard
rate-and-duration ceiling (50 req/s, 300s) that requires an explicit
allow_high_rate to raise. The ceiling is a safety control: a typo in --rate
must not become a self-inflicted denial of service. Pacing reuses
core.ratelimit.TokenBucket with injected clock and sleep.

payloads.py: reuses blast.generate.generate_corpus for realistic-but-valid
seeded bodies. Clean load only; blast/corrupt.py is deliberately untouched.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
The ramp path could block forever under an injected clock. Root cause is
not the ramp loop's exit condition and not a zero rate at ramp start:
TokenBucket.acquire() itself cannot be driven to completion by a purely
additive injected clock when the rate's reciprocal is not exactly
representable in binary. It computes wait_for = deficit / rate, sleeps
exactly that, then refills by wait_for * rate, which rounds to just under
deficit. The residual deficit is ~1e-16, so the next wait_for is ~1e-17,
and adding 1e-17 to a clock reading ~0.67 is a no-op at float precision:
elapsed becomes 0, no refill happens, and the loop spins forever.

Rates 2 and 4 are exactly representable and never trip it, which is why
the steady-state tests passed. A ramp to 10 in 5 steps yields rates
2, 4, 6, 8, 10, and rate 6 hung the run.

A real wall clock ticks forward on its own and masks this, so this is
latent in core/ratelimit.py rather than Barrage-specific. core is not this
lane's to edit and its own tests only exercise rates 1 and 2, both exactly
representable. Reported to the director rather than worked around silently.

Barrage now paces on an absolute per-stage schedule and uses the bucket as
the rate-authority gate, arriving only once a token is already earned, so
the bucket never enters its internal wait loop. Stage dispatch counts are
bounded integers, so a stage always terminates.

Regression tests pin it: parametrized over rates whose reciprocal is not
binary exact, and a BoundedSleeper that fails loudly on a degenerate
sub-nanosecond sleep or an implausible sleep count rather than spinning.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
tests/test_lane_hygiene.py documents that adding __init__.py under tests/
was tried and reverted because it breaks the integration lane's sibling
import of fake_sink. This was the only one in the repo. Barrage's test
basenames are unique, so it was never needed.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Where Blast's core/report.py asks whether the parser correctly rejected
garbage, Barrage asks whether the pipeline held. Mirrors core/report.py's
build_artifact/format_summary shape without importing its expectation
rules, which are about parser correctness and do not apply to load.

Reports throughput achieved versus targeted, latency p50/p90/p99, error
rate over time, and the knee where the endpoint starts shedding (errors
climb) or slowing (median latency exceeds 2x the healthy baseline). JSON
artifact plus a human summary that reads visibly differently for a run
that held and a run that fell over.

Reporting math is pure: no I/O, no network, no clock reads. Latency and
wall-clock enter only as caller-supplied data, so output is a pure
function of input and is tested entirely on synthetic samples.

Judgement calls worth review:
- Percentiles use nearest-rank, so a reported p99 is always a real
  request's latency rather than an interpolated number no request saw.
- Timeouts are excluded from the latency distribution and reported via the
  error rate instead: a timeout's latency reflects the timeout setting,
  not the endpoint's speed, and mixing it in skews every percentile.
- 4xx is not an error: that is the endpoint correctly rejecting something
  under load, not the pipeline failing to hold. 5xx and no-response are.
- Empty middle buckets are emitted, not skipped: a stretch where the
  endpoint accepted nothing is the shedding this report exists to show.
- Empty inputs report None, never 0.0, which would read as "extremely
  fast" when it means "no data".

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
testinghq barrage fire --target ... --rate ... --duration ...
--concurrency ... --send, plus replay. Dry-run is the DEFAULT; --send is
required to put anything on the wire.

Orchestration lives in barrage/fire.py; cli.py only gains the barrage
subcommand and its parser, so nothing safety-relevant is decided in the
argparse layer. The blast subcommands are untouched and a test pins that.

Guardrails, imported and never reimplemented, called positionally:
- evaluate_send decides dry-run vs send.
- require_configured_target gates BOTH the target name (allow-list) and
  the resolved URL (public-host check). Checking only the name is the
  trap: a bare single-label name like "local" has no dot, so the
  public-host hardening classifies it internal and passes it
  unconditionally, leaving that hardening inert while looking correct.
  Follows testinghq/cli.py's existing blast fire path.
- require_synthetic_content runs over every address in the whole pool
  before any network call, so a bad payload aborts the run before the
  first request rather than after some prefix already fired.

The ceiling is checked in the CLI as well as inside run(), so a dry run
reports an over-limit plan as refused rather than previewing a run that
would never be allowed.

Dry-run's zero-network property is PROVEN, not asserted: the socket module
is patched to raise. Verified by deliberately sabotaging the dry-run path
to make a real call; the three socket-patch tests failed as they should,
and the sabotage was reverted.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
…example

The freshness guard from #17 fired on its first live run: landing
testinghq/barrage/runner.py turned tests/test_backlog_freshness.py red because the
backlog still claimed Barrage was in progress. That is the guard working as
designed, and this commit is what "keep docs current in the same PR as the code"
looks like when it is enforced rather than requested. The build would not go green
without it.

Two fixes.

1. Barrage v1 marked done, item by item, against what is actually in this branch.

2. The documentation example in the staleness-markers section named
   testinghq/barrage/runner.py as its sample path. It was a live marker, not an
   illustration, so it armed itself the moment Barrage landed and the guard began
   reporting its own instructions as a stale claim. Rewritten to name a path that
   does not exist and to spell the marker with the word split so the example
   cannot arm itself again. An example of a tripwire must not be a tripwire.

Also records the core/ratelimit.py TokenBucket precision bug that this lane found
and correctly worked around rather than reaching into another lane to fix. The
root cause is still on main and the item is filed for Lane A, including the part
that matters most: tests/security/test_rate_limit_gate_contract.py passes today
because it chose a binary-representable rate, not because the code is right.

448 passed.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Caught by actually running barrage against a local sink rather than by a
hermetic test: the first second of a 10 req/s run reported 15 req/s
achieved, and an 8 second run finished in 6.2 seconds.

Two defects, both in stage sequencing:

1. A stage ended the instant its last request was dispatched, up to one
   full interval early. With a 3s warmup over 10 steps the stages are 0.3s
   each, so round(rate * 0.3) is often 1: the stage fired its single
   request at stage_start and returned immediately, taking no time at all.
   The ramp collapsed into a burst.
2. A stage whose budget rounded to 0 (an early low-rate step too short to
   earn a whole request) was skipped outright via `continue`, so its window
   never elapsed and the ramp was silently shortened further.

Both are safety-relevant, not cosmetic. The warmup ramp exists to ease into
load rather than slam a cold endpoint, and it was doing the exact opposite:
overshooting the configured rate by 50% in the first second. A ramp that
fires everything at once is as much a defect as one that blocks forever.

Stages now hold to their nominal end, and a zero-budget stage still
occupies its window. Verified against the live sink: the ramp reads
4/s of 5/s, 7/s of 8/s, 9/s of 10/s, then 10/s steady, no bucket overshoots
its target, and an 8s run now takes 8.19s.

Tests pin it under an injected clock: the ramp occupies its full warmup,
a single-request stage still occupies its stage, steady-state dispatches
are never spaced tighter than the target interval, and the ramp carries
less load early than late.

README documents barrage now that it ships a usable command, including the
three controls that keep it a load tester rather than a weapon.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
@saifullah4khan
saifullah4khan merged commit 67f21bb into main Jul 16, 2026
3 checks passed
@saifullah4khan
saifullah4khan deleted the feat/barrage branch July 16, 2026 16:10
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant