fix(bff): cap page.limit and rate-limit agent requests - #1858
Conversation
1 new issue
|
|
Coverage Impact This PR will not change total coverage. Modified Files with Diff Coverage (7)
🛟 Help
|
8a8c0e3 to
a0d1aee
Compare
a0d1aee to
e44c369
Compare
nbouliol
left a comment
There was a problem hiding this comment.
Reviewed the two guards. The cap side is clean: MAX_PAGE_LIMIT is defined once and reused by the published prose, so the document and the runtime can't drift, and I confirmed the limiter really does sit after createAuthModeMiddleware/apiKeyStep, so anonymous and rejected-key traffic 401s without ever allocating a bucket. Test coverage on the limiter is well above the usual bar.
The main thread below is the published contract: the 429 and Retry-After descriptions hold for the quota path but not for the saturation path the same code can produce.
|
Second push, from the review pass. The two new env vars were undocumented. Two notes I am leaving as they are, with the reasoning: The
Commit titles on this branch: one is 73 chars and one uses the |
nbouliol
left a comment
There was a problem hiding this comment.
Re-reviewed at fe244e5. All five comments from the last pass are addressed, and the fail-closed test was properly renamed and re-asserted rather than left pinning the old behaviour — I checked that nothing legitimate now hits the new throw, since createCorsMiddleware short-circuits OPTIONS at 204 before the chain.
Three things below. The first is the one that matters: the 429 split tells callers to branch on message text, which this package's own error contract rules out.
One more that falls outside the diff so I couldn't anchor it: README.md:114 still says the request edge "enforces three cross-cutting concerns" (Auth-mode / CORS / Timezone) — the limiter makes four, and it's currently documented only as env rows.
Unrelated to this PR: the red check is packages/ai-proxy/test/llm.integration.test.ts:503 (39 passed, 1 failed), a live-provider test in a package this branch doesn't touch.
| 415: 'The request declares a character set the server cannot decode. Other content types are NOT rejected: a form-urlencoded body is parsed and validated like JSON (its values arrive as strings, so typed fields such as page.limit fail with 400), while any other non-JSON content type is read as an absent body, silently dropping filters and pagination', | ||
| 422: 'A field is unknown, not filterable, or is a nested relation path', | ||
| 429: 'The agent rate-limited the request', | ||
| 429: 'The BFF rate-limited the request, for one of two reasons: the caller identity exceeded its per-window budget, or the limiter is saturated and cannot open a window for a new identity. The message distinguishes them, and Retry-After carries the seconds to wait. On data and action routes the agent may also rate-limit the request itself', |
There was a problem hiding this comment.
The message distinguishes them asks callers to branch on message text, which README:116 rules out ("consumers branch on error.type, never on message text") — and both throw sites carry the same too_many_requests. Give saturation its own type or a details discriminator, or say plainly that the two are indistinguishable to a caller?
| - A malformed value (a non-http(s) `*_URL`, a `HTTP_PORT` that is not a decimal integer in 0–65535, | ||
| a non-IANA `BFF_DEFAULT_TIMEZONE`, a non-boolean `BFF_OPENAPI_ENABLED`) fails fast at boot: the | ||
| process exits with a clear error and never echoes the offending value. | ||
| a non-IANA `BFF_DEFAULT_TIMEZONE`, a non-boolean `BFF_OPENAPI_ENABLED`, a `BFF_RATE_LIMIT_*` that is |
There was a problem hiding this comment.
BFF_RATE_LIMIT_MAX_REQUESTS=20000 and BFF_RATE_LIMIT_WINDOW_MS=500 are positive decimal integers that still fail boot (max 10 000, min 1 000). Worth stating the ranges here and in the two rows above, the way HTTP_PORT and BFF_AI_TIMEOUT_MS do?
|
|
||
| const key = bucketKeyOf(ctx.state as AuthEdgeState); | ||
|
|
||
| if (key === undefined) throw unauthorized(); |
There was a problem hiding this comment.
unauthorized() takes the default "Missing or invalid credentials", which reads like a real credential failure on a path that should be unreachable — permissions-routes-middleware.ts:42 passes 'No caller identity for this request' for the same condition.
nbouliol
left a comment
There was a problem hiding this comment.
Third pass, at 2637468. All three comments from the last round are fixed, and the 429 one properly — details.cause is a real machine-readable discriminator rather than a reworded sentence, which is the right answer to the type-first contract. I checked the README ranges against env-config.ts:176-187 and they agree exactly on both bounds, and tooManyRequests has only the two callers so the new parameter regressed nothing.
Two things left, both about the new cause contract rather than the limiter itself.
| 415: 'The request declares a character set the server cannot decode. Other content types are NOT rejected: a form-urlencoded body is parsed and validated like JSON (its values arrive as strings, so typed fields such as page.limit fail with 400), while any other non-JSON content type is read as an absent body, silently dropping filters and pagination', | ||
| 422: 'A field is unknown, not filterable, or is a nested relation path', | ||
| 429: 'The agent rate-limited the request', | ||
| 429: 'The BFF rate-limited the request, for one of two reasons: the caller identity exceeded its per-window budget, or the limiter is saturated and cannot open a window for a new identity. The `details.cause` field of the error body distinguishes them (`limit_exceeded` or `limiter_saturated`), and Retry-After carries the seconds to wait. On data and action routes the agent may also rate-limit the request itself', |
There was a problem hiding this comment.
A relayed agent 429 never carries cause — mapAgentError sets details to the agent's own payload — so a caller branching on it sees undefined or arbitrary data. The Retry-After description just below (72-73) already spells out the parallel case; worth the same clause here?
| throw tooManyRequests( | ||
| retryAfterSeconds(earliestReset, current), | ||
| 'Too many requests: the rate limiter is saturated', | ||
| { cause: 'limiter_saturated' }, |
There was a problem hiding this comment.
Now that clients branch on these, the two values live in 7 places across 5 files with no shared source (here, :101, the OpenAPI prose at :40, README.md:98, and both tests) — a typo in any one silently breaks the contract. Export a RateLimitCause union next to tooManyRequests and reference it?

What
Two guards on the agent BFF edge:
page.limitcapped at 1000. Above it: 400invalid_request— rejected, not clamped, so a caller cannot believe it got what it asked for. The cap is published asPage.properties.limit.maximum./agent/*request: 429too_many_requestswith an integerRetry-After. Defaults 300 requests / 60 s, tunable throughBFF_RATE_LIMIT_MAX_REQUESTSandBFF_RATE_LIMIT_WINDOW_MS; an invalid value fails boot rather than falling back.fixes PRD-1100
Why
A valid key could pull an entire collection in one request:
limit: 1000000000was forwarded verbatim aspage[size]. And nothing throttled the cadence — 400 concurrent requests, zero 429.How
api-key:/oauth:). Rejected keys and anonymous requests die at 401 before the limiter and never allocate a bucket.maxRequestspass.Two different 429s
The same code emits 429 for two reasons, and the document now says so instead of blaming the caller for both:
Retry-Afteris when its window resets.Retry-Afteris a lower bound: the earliest reset among the identities currently holding a window, after which a slot may free up — the freed slot goes to whoever asks first.A client that backs off on the wrong signal is the reason this distinction is published rather than left in the message alone.
How to test
POST /agent/v1/users/listwith{"page":{"limit":1000000000,"offset":0}}returns 400invalid_request.BFF_RATE_LIMIT_MAX_REQUESTS=2, the third authenticated request in the window returns 429 withRetry-After.Known limitation
Requests rejected by the body parser (400/413) do not count toward the quota. They are bounded at 16 KB and never reach the agent.
Definition of Done
General
Security