Skip to content

Commit 2de33b3

Browse files
committed
refactor: replace CF budget tracking with in-memory invalid requests tracker
- Removed the CF budget tracking mechanism and its associated files. - Introduced an in-memory invalid requests tracker that counts 401, 403, and non-shared 429 responses. - Updated the Proxy to use the new invalid requests tracker for rate limiting decisions. - Adjusted related documentation and admin commands to reflect the changes in tracking invalid requests. - Ensured that multi-replica deployment remains incompatible due to the nature of the in-memory tracking.
1 parent 829cc93 commit 2de33b3

14 files changed

Lines changed: 708 additions & 93 deletions

File tree

CONTEXT.md

Lines changed: 4 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -12,17 +12,17 @@
1212

1313
- **Passthrough** — any non-crosspost Discord REST request from the bot's discord.js Client. Forwarded by the Proxy unchanged with all `x-ratelimit-*` headers preserved, since the bot's Client uses them to maintain its handler/hash collections.
1414

15-
- **CF budget** — the rolling 10-minute count of invalid requests (401/403/429) Cloudflare uses to ban an egress IP. Tracked in-memory via `RESTEvents.InvalidRequestWarning`. Single-replica only.
15+
- **Invalid requests** — the rolling 10-minute count of invalid requests Cloudflare uses to ban an egress IP. Counts 401, 403, and 429 except those with `X-RateLimit-Scope: shared`. Tracked in-memory via `RESTEvents.Response`. Single-replica only.
1616

1717
- **Sublimit** — Discord's per-channel 10/hour shared 429 on crossposts. Surfaces as `RateLimitError` with `scope: 'shared'`.
1818

1919
- **Blocked cache** — negative TTL'd record marking a channel as un-crosspostable (401/403). Cleared by the bot via `DELETE /internal/blocked/:id` on permission updates.
2020

2121
## Internal Proxy modules
2222

23-
- **Gateway** — owns the `REST` instance, the `rejectOnRateLimit` predicate, REST event listeners, the CF-budget tracker, and the passthrough Express route. Pure rate-limit-sync concern.
23+
- **Gateway** — owns the `REST` instance, the `rejectOnRateLimit` predicate, REST event listeners, the invalid-requests tracker, and the passthrough Express route. Pure rate-limit-sync concern.
2424

25-
- **Crosspost** — owns the BullMQ queue + worker, the gate (CF-budget + blocked + sublimit), the Discord-error classifier, the Redis-backed caches, and the enqueue Express route.
25+
- **Crosspost** — owns the BullMQ queue + worker, the gate (invalid-requests + blocked + sublimit), the Discord-error classifier, the Redis-backed caches, and the enqueue Express route.
2626

2727
The Crosspost module calls `Gateway.rest.post(...)` directly (in-process function call, not HTTP).
2828

@@ -32,4 +32,4 @@ The Crosspost module calls `Gateway.rest.post(...)` directly (in-process functio
3232

3333
- **Explicit RPC bot↔proxy for crossposts.** Bot calls `POST /crosspost/:channelId/:messageId` (empty body) instead of letting the Proxy intercept Discord-shaped URLs. Keeps the async-queue semantics visible at the call site.
3434

35-
- **CF budget in-memory.** Relies on single-replica deployment. Multi-replica deployment requires moving this back to Redis.
35+
- **Invalid-requests in-memory.** Relies on single-replica deployment. Multi-replica deployment requires moving this back to Redis.

docs/PROXY_MIGRATION.md

Lines changed: 586 additions & 0 deletions
Large diffs are not rendered by default.

docs/adr/0001-proxy-stays-single-process.md

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -12,11 +12,11 @@ The `refactor/v7` branch experimented with splitting these concerns into two ser
1212

1313
## Decision
1414

15-
The Proxy is one Node process. The Crosspost module calls the Gateway's REST instance directly via in-process function calls — no HTTP hop between them. Scaling out to multiple processes is not a goal; if it becomes one, this ADR is reopened together with [ADR 0003](./0003-cf-budget-in-memory.md).
15+
The Proxy is one Node process. The Crosspost module calls the Gateway's REST instance directly via in-process function calls — no HTTP hop between them. Scaling out to multiple processes is not a goal; if it becomes one, this ADR is reopened together with [ADR 0003](./0003-invalid-requests-in-memory.md).
1616

1717
## Consequences
1818

1919
- One Docker container, one port, one lifecycle to manage.
2020
- The Crosspost worker shares the same REST bucket state as passthrough — both contribute to and observe the same rate-limit handlers.
21-
- Multi-replica deployment is not currently supported. Doing so would require sharing CF-budget state ([ADR 0003](./0003-cf-budget-in-memory.md)) and routing crosspost traffic to a single replica or sharding by `channelId`.
21+
- Multi-replica deployment is not currently supported. Doing so would require sharing the invalid-requests tracker state ([ADR 0003](./0003-invalid-requests-in-memory.md)) and routing crosspost traffic to a single replica or sharding by `channelId`.
2222
- The internal split into `Gateway` and `Crosspost` modules is justified by testability and locality, not by anticipating a future service split.

docs/adr/0003-cf-budget-in-memory.md

Lines changed: 0 additions & 27 deletions
This file was deleted.
Lines changed: 42 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,42 @@
1+
# ADR 0003: Invalid requests tracked in-memory via REST events
2+
3+
## Status
4+
5+
Accepted — 2026-05-08
6+
Updated — 2026-05-10 (switched source from `InvalidRequestWarning` to `Response` event to exclude shared-scope 429s)
7+
8+
## Context
9+
10+
Cloudflare bans the bot's egress IP if more than ~10 000 invalid requests (401/403/429) are seen in a 10-minute rolling window. The Proxy must shed crosspost work before this threshold is crossed.
11+
12+
Per [Discord's docs](https://docs.discord.com/developers/topics/rate-limits#invalid-request-limit-aka-cloudflare-bans), 429 responses with `X-RateLimit-Scope: shared` are **not** counted by Cloudflare. Sublimit responses on the crosspost route arrive with `scope=shared` and must be excluded.
13+
14+
`@discordjs/rest` exposes two relevant events:
15+
16+
- `RESTEvents.InvalidRequestWarning` — fires on 401/403/429 but does **not** check `X-RateLimit-Scope` before counting (see `incrementInvalidCount` in `dist/index.js`). Inflates the count by every shared 429.
17+
- `RESTEvents.Response` — fires once per response with the raw `Response` object, allowing direct inspection of `X-RateLimit-Scope`.
18+
19+
The previous design hand-counted invalid responses via Redis with unique UUID keys and a 10-min TTL. Single-process Proxy ([ADR 0001](./0001-proxy-stays-single-process.md)) makes Redis unnecessary for this counter.
20+
21+
## Decision
22+
23+
The invalid-requests tracker is in-memory. It listens on `RESTEvents.Response` and increments only when:
24+
25+
- `status === 401` or `status === 403`, OR
26+
- `status === 429` AND `X-RateLimit-Scope !== 'shared'`
27+
28+
It stores `{ count, expiresAt }` with a fixed 10-minute window — first counted request after expiry resets the window and starts at count=1, mirroring the library's internal logic.
29+
30+
The crosspost gate reads this synchronously to decide whether to shed.
31+
32+
`InvalidRequestWarning` is no longer used. `invalidRequestWarningInterval` is dropped from REST options.
33+
34+
## Consequences
35+
36+
- Excludes shared-scope 429s correctly — produces a count consistent with what Cloudflare actually sees on its end.
37+
- Strictly more accurate than the prior hand-counted tally and than `InvalidRequestWarning`.
38+
- One fewer Redis database, one fewer connection, one fewer failure mode.
39+
- Gate decisions are synchronous on this dimension (no Redis round-trip).
40+
- **Multi-replica deployment is now incompatible.** Two Proxy replicas behind one egress IP would each track only their own slice of invalid requests, undercounting against the CF budget. If multi-replica becomes a goal, the tracker moves back to a shared store and this ADR is reopened.
41+
- Restarting the Proxy resets the count. Acceptable because Cloudflare's window is also rolling and the Proxy is rarely restarted under load.
42+
- Fixed-window approximation: count snaps to 0 at window expiry rather than decaying continuously. Cheaper than a sliding window and accurate enough for a safety-net threshold of 5 000.

services/bot/src/services/info.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -12,7 +12,7 @@ const get = async () => {
1212
handlers: number;
1313
activeHandlers: number;
1414
hashes: number;
15-
cfBudget: {
15+
invalidRequests: {
1616
count: number;
1717
expiresInMs: number;
1818
};

services/bot/src/utils/admin-commands/info.ts

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -21,9 +21,9 @@ export default new AdminCommand(CommandNames.INFO, async ({ channel }) => {
2121
'### Channels:',
2222
`> Sublimited: ${data?.sublimitCount}`,
2323
`> Blocked: ${data?.blockedCount}`,
24-
'### CF budget (10min window):',
25-
`> Invalid requests: ${data?.rest.cfBudget.count ?? 0}`,
26-
`> Window remaining: ${Math.round((data?.rest.cfBudget.expiresInMs ?? 0) / 1_000)}s`,
24+
'### Invalid requests (10min window):',
25+
`> Count: ${data?.rest.invalidRequests.count ?? 0}`,
26+
`> Window remaining: ${Math.round((data?.rest.invalidRequests.expiresInMs ?? 0) / 1_000)}s`,
2727
];
2828
channel.send(parsedData.join('\n'));
2929
});

services/proxy/src/crosspost/gate.ts

Lines changed: 4 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,7 @@
1-
import type { CfBudget } from '../gateway/cfBudget.js';
1+
import type { InvalidRequestsTracker } from '../gateway/invalidRequests.js';
22
import type { BlockedCache, SublimitCounter } from './caches.js';
33

4-
export type GateRejectReason = 'cf_budget' | 'blocked' | 'sublimit';
4+
export type GateRejectReason = 'invalid_requests' | 'blocked' | 'sublimit';
55

66
export type GateVerdict =
77
| { kind: 'allow' }
@@ -12,12 +12,12 @@ export type Gate = {
1212
};
1313

1414
export const createGate = (deps: {
15-
cfBudget: CfBudget;
15+
invalidRequests: InvalidRequestsTracker;
1616
blocked: BlockedCache;
1717
sublimit: SublimitCounter;
1818
}): Gate => ({
1919
evaluate: async (channelId) => {
20-
if (deps.cfBudget.isOverThreshold()) return { kind: 'reject', reason: 'cf_budget' };
20+
if (deps.invalidRequests.isOverThreshold()) return { kind: 'reject', reason: 'invalid_requests' };
2121
if (await deps.blocked.isBlocked(channelId)) return { kind: 'reject', reason: 'blocked' };
2222
if (await deps.sublimit.isOverLimit(channelId)) return { kind: 'reject', reason: 'sublimit' };
2323
return { kind: 'allow' };

services/proxy/src/crosspost/queue.ts

Lines changed: 6 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -12,7 +12,7 @@ const QUEUE_NAME = 'crosspost';
1212
const QUEUE_DB = 0;
1313
const QUEUE_HIGH_WATER = 10_000;
1414
const RATE_LIMIT_RETRY_CAP_MS = 5 * 60 * 1_000;
15-
const CF_BUDGET_DELAY_MS = 60_000;
15+
const INVALID_REQUESTS_DELAY_MS = 60_000;
1616
const CHANNEL_ID_PATTERN = /^\d{17,19}$/;
1717

1818
export type CrosspostJobData = {
@@ -89,9 +89,9 @@ export const createCrosspostQueue = (deps: {
8989
const { channelId, messageId } = job.data;
9090
const verdict = await deps.gate.evaluate(channelId);
9191
if (verdict.kind === 'reject') {
92-
if (verdict.reason === 'cf_budget') {
93-
logger.warn({ event: 'crosspost.shed.cf_budget', channelId, messageId });
94-
await job.moveToDelayed(Date.now() + CF_BUDGET_DELAY_MS, job.token);
92+
if (verdict.reason === 'invalid_requests') {
93+
logger.warn({ event: 'crosspost.shed.invalid_requests', channelId, messageId });
94+
await job.moveToDelayed(Date.now() + INVALID_REQUESTS_DELAY_MS, job.token);
9595
throw new DelayedError();
9696
}
9797
logger.debug({ event: 'crosspost.skipped', channelId, messageId, reason: verdict.reason });
@@ -126,8 +126,8 @@ export const createCrosspostQueue = (deps: {
126126

127127
const verdict = await deps.gate.evaluate(channelId);
128128
if (verdict.kind === 'reject') {
129-
if (verdict.reason === 'cf_budget') {
130-
logger.warn({ event: 'crosspost.rejected.cf_budget', channelId, messageId });
129+
if (verdict.reason === 'invalid_requests') {
130+
logger.warn({ event: 'crosspost.rejected.invalid_requests', channelId, messageId });
131131
res.setHeader('Retry-After', '60').status(503).end();
132132
return;
133133
}

services/proxy/src/gateway/cfBudget.ts

Lines changed: 0 additions & 35 deletions
This file was deleted.

0 commit comments

Comments
 (0)