Skip to content

test(ci): probe the shared login counter key behind the 429/401 flake - #866

Merged
mforce merged 6 commits into
mainfrom
chore/840-login-counter-key-instrumentation
Sep 14, 2026
Merged

mforce merged 6 commits into
mainfrom
chore/840-login-counter-key-instrumentation

Conversation

@mforce

@mforce mforce commented Sep 14, 2026 •

Copy link
Copy Markdown
Owner

What

The instrument the census on #775 was missing, plus the written findings from a four-lane investigation of the remaining flakes in #840.

  • tests/Cluckwork.Api.IntegrationTests/LoginCounterKeyProbeTests.cs — boots a real serving child, reads the shared auth-login:127.0.0.1 counter straight out of Redis around a controlled burst, and reports what was already spent before it started.
  • docs/plans/840-integration-flakes/01-swarm-findings.md — the four lanes, with file:line evidence, including one refuted hypothesis and three corrections to this PR's own earlier claims.

The finding

The probe's first trustworthy run moved the counter by 40 on 14 requests.

40 = 14 (the probe's own) + 30 (someone else's)
30 = 3 classes x 10 = three neighbours, each draining the entire budget

That is the flake, with arithmetic. The login bucket key is the client address alone (DistributedIpFixedWindowPolicy.cs:44, RateLimitKey.cs:21-38), MultiInstanceRateLimitTests carries no [Collection] (:24) so it runs concurrently with other collections, and FakeRemoteIpStartupFilter rewrites Connection.RemoteIpAddress only when X-Test-Remote is present (:17-19) — which AuthBodyLimitTests never sets (:73-78). Several classes independently empty one bucket, and whichever runs last sees 429 where it expected 401.

The census's instrumentation almost certainly did see these foreign increments and attributed them to nothing, which is why it got 0 failures in 80 instrumented runs.

What the probe had to get right first

Its first result was a bug in the probe, and the record says so:

  • Readiness conflated with spending. It waited for 401 or 429 and called that live. 429 means the opposite here — a neighbour can empty the bucket before this child binds its port. Fixing it to wait for 401 alone then timed out, because a 900 s window spent by a neighbour stays spent, so the budget is cleared before the burst instead. That is legitimate only because the foreign-spend reading is taken before the clear and before the child exists.
  • The Postgres container is load-bearing. Program.cs:170 runs MigrateAsync before the web host starts, so a serving process with no reachable database never reaches its request pipeline. The limiter needs no database; the boot does.
  • No exact-count assertion. The bucket is shared, so a concurrent login racing the clear adds spend the probe did not cause. It asserts the shared key moved by at least the burst — which is what distinguishes the Redis counter from the in-process fallback, and the only claim a shared bucket supports.

Verification

Executed, not assumed:

  • 15/15 across the probe plus MultiInstanceRateLimitTests, AuthBodyLimitTests, DistributedRateLimiterWiringTests.
  • SchemaDocsTests 5/5 — the image-pin guard walks every tracked file, and this PR adds a tracked document naming a Postgres image.
  • Green in CI on the integration leg, with the CLUCKWORK_840_PROBE line present in the log and identical to the local run.

Not in this PR

Deliberately, from the same findings:

Does not close #840. This proves the mechanism behind the rate-limit flake and narrows the leader-gate one. Both fixes land separately, and #840 stays open until a flake stops flaking.

#840's census reproduced the rate-limit flake once in 36 runs and got 0
failures in 80 instrumented runs, because the instrumentation recorded what
the counter was asked to count and never who asked. The login bucket key is
auth-login:<client-ip> alone, so any concurrently running class that presents
the loopback address spends the budget MultiInstanceRateLimitTests measures.

The probe reads the shared key directly and reports the statuses it saw, the
keys present before and after, and what was already spent when it started.
It emits rather than asserts, so a green run is information too.

Also records the four-lane findings, including the refutation of the
thread-pool-starvation story for DurableJobWorkerLeaderGateTests and the
server-side read of the Npgsql Authenticate timeout.
@coderabbitai

coderabbitai Bot commented Sep 14, 2026 •

Copy link
Copy Markdown

Important

  • 🔍 Trigger review

This repository does not receive automatic reviews because it has fewer than 10 stars.

⚙️ Run configuration

Configuration used: Organization UI

Review profile: CHILL

Plan: Advanced

Run ID: 6aaf4d9f-7013-4b9d-a3ec-03759736ce4e


Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands.

Unused SubprocessExitTimeout, comments restating the code, and a
qualifier-per-call instead of one alias for the TestHarness name that
StackExchange.Redis also exports.
The probe booted a serving child pointed at an unreachable database and
waited on /health/ready. DatabaseReadyHealthCheck 503s that endpoint
whenever the database is unreachable, so readiness never arrived and the
wait ran to its 60s timeout - locally and on CI, identically.

The limiter runs before the endpoint, so the probe now boots against a
private Postgres it never queries, waits by polling /api/v1/auth/login for
401 or 429, and reads the shared key around a fourteen-request burst.
First real run: the bucket already held one spend from elsewhere in the
suite before the probe's first request, and moved 19 while the probe sent
14 - the collision is real. It also showed the live key shape is
{cluckwork:win:auth-login:<ip>}:<bucket>, not the namespace-only form.
CI is green on the probe. Trim the comments that restated the mechanism twice,
record the bucket-rollover confounder the first CI run exposed (a 900s window
turns over mid-burst, so a delta larger than the burst is not by itself proof
of a foreign writer), and assert the burst actually crossed the budget.
The mechanism claim needs both runs, not one: the CI line carries
bucketCountAfter and spendOnKeysThisProbeDidNotCreate, which is what rules
out a mid-burst window rollover as the reason the counter moved by 19 on a
14-request burst.
The probe waited for the login route to answer 401 or 429 and called that
ready. 429 means the opposite: the bucket is keyed on the loopback address
alone, so a concurrent class can empty it before this child binds its port.
The probe saw a foreign 429, burst into a dead bucket, and reported 14
refusals and 40 increments as its own result.

Waiting for 401 alone does not fix it - a 900s window spent by a neighbour
stays spent for the whole wait. The budget is now cleared before the burst,
which is legitimate only because the foreign-spend reading is taken first,
before the child exists. That reading is the finding; the clear removes the
confound.

The Postgres container stays. Program.cs migrates before the web host
starts, so a serving process with no reachable database never reaches its
request pipeline. The limiter needs no database; the boot does.

Also: the probe no longer asserts an exact count. The bucket is shared, so
a concurrent login racing the clear adds spend it did not cause.
@mforce
mforce merged commit 19868de into main Sep 14, 2026
16 checks passed
@mforce
mforce deleted the chore/840-login-counter-key-instrumentation branch September 14, 2026 17:28
mforce added a commit that referenced this pull request Sep 15, 2026
## What

Gives the login rate-limit counter a bucket the probe can measure
without the suite's own traffic in it. Follow-up to #866, which proved
the collision but did not remove it from the way.

`IsolatedLoginBucket` makes loopback a trusted proxy for one test host,
so `ForwardedHeadersMiddleware` honours `X-Forwarded-For` and the
limiter keys on an address the test names instead of on the socket peer.

## Two findings from building it

**A first cut minted buckets as `198.51.100.<a>.<b>`.** Five octets is
not a valid IPv4 literal. `ForwardedHeadersMiddleware` rejects it and
falls back to the socket peer, so every "independent" bucket silently
collapsed onto loopback — where the test's own exhaustion turned the
next client's first request into a 429. It looked like isolation not
working, not like a malformed address. Buckets are TEST-NET-3 `/32`s
now, and the helper says why that matters.

**The probe's own fixture spends against the probe's own bucket.** The
burst moved the counter by 18 on 14 requests, in a Redis container
private to that class — nothing external could have written it.
`CluckworkWebApplicationFactory` builds its host in the constructor,
before any test body runs, and the suite logs in constantly from
loopback. So #840's collision reproduces one level down, inside a single
class. That is why the assertion is `>=` and not `==`: an exact
assertion here would have made the file red on a working mechanism.

## Why configuration and not `PostConfigure<ForwardedHeadersOptions>`

`Program.cs:63` reads `RateLimiting:TrustedProxies` eagerly into a
registration record and hands that snapshot to
`AddCluckworkEdgeSecurity`, which clears `KnownIPNetworks` and rebuilds
them from it. A `PostConfigure` runs afterwards and is silently
overwritten — a helper written that way looks correct and does nothing
at runtime.

## Guard

A host that already sets `RateLimiting:TrustedProxies:0` is refused, not
merged. Such a host is exercising #143's anti-spoof boundary;
overwriting its proxy list would delete the thing under test while
leaving the test green.

## Verification

Executed, 32/32 across every class that touches the login limiter or the
forwarded-header trust decision:

- `RateLimitingTests`, `AuthRateLimitLoggingTests`,
`DistributedRateLimiterWiringTests`, `MultiInstanceRateLimitTests`,
`LoginCounterKeyProbeTests`, `SchemaDocsTests` — 20
- `ForwardedHeaderTrustKestrelTests`, `SecurityHeadersTests`,
`KestrelRequestBodyLimitTests` — 12

The second group matters most: those are the anti-spoof tests, and they
still pass because they configure their own proxies and therefore never
enter this helper.

## Not in this PR

The collision is still there for the ~102 classes that log in through
the shared factory. This isolates the probe. Extending it to the suite
is the next step and should be its own PR, because it means every
factory opting in and each one is a place to get the trust decision
wrong.

Does not close #840. No flake has been shown to stop flaking yet — that
needs repetition, not reasoning.

---------

Co-authored-by: mforce <mforce@users.noreply.github.com>
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.

CI: root-cause the four remaining integration flakes

1 participant