Skip to content

feat: add a qualified preview request-limiting profile - #18

Merged
joey-huckabee merged 1 commit into
mainfrom
feature/request-limiting-profile
Sep 16, 2026
Merged

joey-huckabee merged 1 commit into
mainfrom
feature/request-limiting-profile

Conversation

@joey-huckabee

Copy link
Copy Markdown
Contributor

Third of the remaining Package 3 example profiles. Preview/unqualified.

Profile

examples/profiles/rate-limited/nginx.conf
applies three independent budgets: request rate, concurrent connections, and
per-connection bandwidth. Setting only one leaves the others unbounded, which
is the usual way a "rate limited" service still falls over.

Both limits answer 429, not the NGINX default 503. A 503 is
indistinguishable from an outage and invites clients to retry harder against a
server that is already shedding load.

The health endpoint is outside every limit. A limited liveness probe turns a
traffic spike into a failed probe and a restart, removing capacity exactly when
it is needed. The test asserts /healthz still answers while the client's
request budget is exhausted.

The two ways these limits silently stop working

Both are documented in the config rather than left to be discovered:

  1. Keyed on the direct peer address, a per-client limit becomes a global
    cap
    the moment there is a load balancer or ingress in front, because every
    client presents the proxy's address. A deployment in that position needs a
    key derived from a forwarded address it actually trusts.
  2. Keyed on a client-controlled header, the limit ceases to exist while
    still looking configured — the client picks a fresh bucket per request.

Logging

Outcomes are logged; the limit key is not. Recording the outcome supports
capacity and abuse analysis. Recording the raw client address on every request
would put a personal identifier into an access stream that is otherwise free of
them.

Three findings from actually running this

Each would have produced a test that passed while proving nothing, or a
mystifying failure.

limit_req runs before limit_conn. Once the rate limit is rejecting, the
connection limit is never evaluated and records NOT_EVALUATED — not
PASSED. My first version tested connections after exhausting the rate
budget, so every rejection came from the rate limiter and the connection limit
was never exercised. The connection budget is now measured first, and the field
semantics are documented so NOT_EVALUATED is not misread as "allowed".

A response that fits the socket buffer never holds a connection. The first
payload was 64 KB; nginx handed it to the kernel immediately and considered the
request done, so no concurrency accumulated however slowly the client read.
--limit-rate on the client cannot change that. The payload now exceeds
limit_rate_after so the profile's own bandwidth limit paces the response
server-side — which is also why limit_rate belongs in this profile.

mktemp -d is 0700. The served directory could not be traversed by the
container identity, so every request answered 403 before any limit applied.
Same class as the TLS key-permission issue in #15.

Five consecutive green local runs on rootless Podman.

Validator

Log assertions can now require a field value (--require-field NAME=VALUE),
and a scenario that issues identical requests on purpose can opt into repeated
identities (--allow-repeated), where the assertion is satisfied by any one of
the matching events. That is what lets the connection test assert "at least one
of these was rejected by the connection limit specifically" when the same batch
also contains rate rejections and successes.

Not qualified

Workload tuning, zone sizing against real client populations, behaviour once a
zone is exhausted, and interaction with an upstream rate limiter.

Roadmap

Removes rate and connection limits from the two Package 3 items that listed
them. Extended health/readiness and ClickHouse remain.

🤖 Generated with Claude Code

Apply three independent budgets to a served tree: request rate, concurrent
connections, and per-connection bandwidth. A deployment that sets only one
leaves the others unbounded.

Both limits answer 429 rather than the NGINX default 503, which is
indistinguishable from an outage and invites clients to retry harder against a
server already shedding load. The health endpoint sits outside every limit: a
limited liveness probe turns a traffic spike into a restart, removing capacity
exactly when it is needed.

The limit outcome is logged; the limit key is not. Recording the outcome
supports capacity and abuse analysis, while recording the raw client address
per request would add a personal identifier to an access stream that is
otherwise free of them.

Documents the two ways these limits silently stop working: keyed on the direct
peer address they become a global cap behind a proxy, and keyed on a
client-controlled header they cease to exist while still looking configured.

Three findings from running the scenario:

- `limit_req` is evaluated before `limit_conn`, so a rate-rejected request
  records the connection limit as NOT_EVALUATED. The connection budget is
  therefore measured before the rate budget is spent, and the field meaning is
  documented so NOT_EVALUATED is not misread as "allowed".
- A response that fits the socket buffer is handed to the kernel immediately
  and never holds a connection, so no concurrency builds up however slowly the
  client reads. The payload now exceeds `limit_rate_after` and the profile's
  own bandwidth limit paces it.
- The served directory came from `mktemp -d`, which is 0700 and cannot be
  traversed by the container identity, so every request answered 403 before
  any limit applied.

Log assertions can now require a field value and, where a scenario issues
identical requests on purpose, be satisfied by any one of the matching events.
@joey-huckabee
joey-huckabee merged commit e3b5500 into main Sep 16, 2026
5 checks passed
@joey-huckabee
joey-huckabee deleted the feature/request-limiting-profile branch September 16, 2026 04:36
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