What the Sentry SDK drops after a 429 quota response
I'm building an error tracker, and I needed to know what an official Sentry SDK does when my ingest answers "you're over quota". I couldn't find anyone showing the wire, so I put a stub server in front of @sentry/node an
I'm building an error tracker, and I needed to know what an official Sentry SDK does when my ingest answers "you're over quota". I couldn't find anyone showing the wire, so I put a stub server in front of @sentry/node and logged every request it got.
The failure is silent. A Sentry issue from 2025 says users "struggle with βwhy isnβt it workingβ" until someone sends them to the Stats & Usage page. Nothing throws. Events just stop showing up.
Node v24.22.1, @sentry/node 11.11.0 (run-0-versions.txt), all local on 2026-11-09. The server is a 55-line stub I wrote, not Sentry. What Sentry's servers do comes from their public docs.
TL;DR
- After one
429withX-Sentry-Rate-Limits: 60:error:organization:usage_exceeded, I captured 10 errors and only 3 hit the wire: 2 accepted, 1 rejected. The other 7 never left the process. No queue, no retry, and one warn-level debug line. - The SDK backs off for exactly what the header says. With a 5s window it sent one probe every ~5s. A bare
429with no headers blocked everything for 60s. - The SDK does report the loss in a
client_report, but the report said 7 when 8 were lost, because it skips the event that got the429. Under a bare429the report itself gets blocked: 11 local drops, none reported.
How the SDK handles a rate limit
It keeps a "disabled until" timestamp per data category, sets it from the response headers, and silently drops matching items before they hit the network until that time passes. There's no buffer of "send later" events.
That matches Sentry's rate limiting spec for SDKs: "Events, transactions, sessions, and/or any other supported payload type, depending on the communicated limits, are to be discarded during the period that the rate limit is in place." The billing side says the same in I've exhausted my quota, what now?: "When your quota is exceeded, events are dropped and cannot be queued or stored."
The parsing order in @sentry/core 11.11.0 (ratelimit.js). Rows 1, 3 and 4 are in my runs below; row 2 I only read in the source:
| Response | What the SDK blocks | For how long |
|---|---|---|
X-Sentry-Rate-Limits: 60:error:... |
only the listed categories (error) |
the first number, in seconds |
X-Sentry-Rate-Limits: 60::... (empty category) |
everything | the first number |
429 + only Retry-After: 5
|
everything | Retry-After |
429 with neither header |
everything | 60s |
"Everything" includes the SDK's own client_report items. That matters later.
Step 1: a stub that plays "quota exceeded"
The stub accepts /api/<id>/envelope/, logs one JSON line per request, and answers 429 after CAP items of one category. Trimmed to the item loop and the cap decision:
// stub.js: fake Sentry ingest. Logs every envelope request to JSONL and can play "quota exceeded".
// Env: OUT (jsonl), CAT (error|transaction|span), CAP (items of CAT accepted before the cap),
// HEADER (full|retry|none), WINDOW (seconds in the headers), RESET_MS (quota refills after this)
// ...
const typeToCat = { event: 'error', transaction: 'transaction', span: 'span', client_report: 'internal' };
// ...
for (let i = 1; i < lines.length && lines[i]; i += 2) {
const head = JSON.parse(lines[i]);
items.push(head.type);
if (head.type === 'client_report') report = JSON.parse(lines[i + 1]);
if (head.type === 'event') items.message = JSON.parse(lines[i + 1]).exception?.values?.[0]?.value;
}
if (RESET_MS && cappedAt && Date.now() - cappedAt >= RESET_MS) { used = 0; cappedAt = 0; }
const mine = items.filter(t => typeToCat[t] === CAT).length;
let status = 200, headers = { 'content-type': 'application/json' };
if (mine && used + mine > CAP) {
status = 429;
if (!cappedAt) cappedAt = Date.now();
if (HEADER === 'full') {
headers['x-sentry-rate-limits'] = `${WINDOW}:${CAT}:organization:usage_exceeded`;
headers['retry-after'] = WINDOW;
} else if (HEADER === 'retry') {
headers['retry-after'] = WINDOW;
}
} else {
used += mine;
}
usage_exceeded is my own reason code; SDKs ignore it.
Step 2: an app that captures on a timer
The app captures boom #1, boom #2 and so on, then calls Sentry.close(3000) so pending sends and client reports flush. Trimmed to the init and the afterSendEvent hook, which prints what the transport got back for each error:
// app.js: capture N errors (and optionally a transaction each tick) and print what the SDK reports back.
// ...
Sentry.init({
dsn: 'http://[email protected]:9999/1',
debug: process.env.DEBUG === '1',
tracesSampleRate: process.env.TX === '1' ? 1 : undefined,
clientReportFlushInterval: Number(process.env.REPORT_MS || 60000),
});
// Detection hook: fires after every captured error, with the transport's response.
Sentry.getClient().on('afterSendEvent', (event, res) => {
const msg = event.exception?.values?.[0]?.value;
const verdict = res.statusCode === undefined ? 'NOT SENT' : res.statusCode;
console.log(`${clock()} afterSendEvent ${msg} -> ${verdict}${res.headers?.['x-sentry-rate-limits'] ? ' limits=' + res.headers['x-sentry-rate-limits'] : ''}`);
});
Each scenario is one line in run-all.sh, which starts the stub and the app with the same env (after npm i --save-exact @sentry/[email protected]):
shot() { # $1 name, rest = env for both stub and app
local name=$1; shift
env OUT=stub-$name.jsonl "$@" node stub.js > /dev/null & local stub=$!; sleep 0.5
{ echo "## env: $*"; env "$@" node app.js 2>&1; sleep 0.3; echo "## stub saw:"; node summary.js stub-$name.jsonl; } > run-$name.txt
kill $stub; wait $stub 2>/dev/null || true
}
# ...
shot 2-cap-60s N=10 EVERY_MS=500 CAP=2 HEADER=full WINDOW=60
The baseline (run-1-baseline.txt, no cap) is boring in a good way: requests=5 error_events_on_wire=5 accepted=5.
What happens at the cap: 7 of 10 errors never leave the process
After the 429, the next errors are dropped locally. No wait, no retry, and the stub never sees them.
App side, from boom #2 to boom #5 (run-2-cap-60s.txt):
17:57:35.383 captured boom #2
17:57:35.387 afterSendEvent boom #2 -> 200
17:57:35.885 captured boom #3
17:57:35.888 afterSendEvent boom #3 -> 429 limits=60:error:organization:usage_exceeded
17:57:36.388 captured boom #4
17:57:36.388 afterSendEvent boom #4 -> NOT SENT
17:57:36.889 captured boom #5
17:57:36.890 afterSendEvent boom #5 -> NOT SENT
boom #6 to boom #10 look exactly like #4. The stub's view of the same run:
17:57:34.902 200 [event] boom #1
17:57:35.386 200 [event] boom #2
17:57:35.887 429 [event] boom #3 {"x-sentry-rate-limits":"60:error:organization:usage_exceeded","retry-after":"60"}
17:57:39.912 200 [client_report] discarded=[{"reason":"ratelimit_backoff","category":"error","quantity":7}]
requests=4 error_events_on_wire=3 accepted=2
10 captured. 2 stored. 1 rejected. 7 dropped in the SDK. boom #3 is never resent. And captureException kept handing back event ids the whole time (run-8-eventid.txt: boom #4 id=5bc619400e85411889c667014b50bbc1 for an event that never left).
With debug: true (run-2b-cap-debug.txt, 4 errors), these are the lines around the cap. The rest is 27 "Integration installed" lines and init noise:
Sentry Logger [warn]: Sentry responded with status code 429 to sent event.
17:57:41.978 afterSendEvent boom #3 -> 429 limits=60:error:organization:usage_exceeded
Sentry Logger [log]: Captured error event `boom #4`
17:57:42.477 captured boom #4
Sentry Logger [log]: Recording outcome: "ratelimit_backoff:error"
17:57:42.478 afterSendEvent boom #4 -> NOT SENT
One warn for the 429. Every drop after that is a log line. Keep only warnings and you see the first one and nothing else.
Category limits only block that category
A limit on span doesn't touch errors. The SDK keeps one timer per category, so errors kept flowing while spans were blocked. run-3-span-only.txt used CAT=span TX=1, so each tick sent one error and one span envelope:
17:57:45.100 200 [event] boom #3
17:57:45.600 429 [span] {"x-sentry-rate-limits":"60:span:organization:usage_exceeded","retry-after":"60"}
17:57:45.602 200 [event] boom #4
17:57:46.105 200 [event] boom #5
17:57:46.606 200 [event] boom #6
17:57:47.109 200 [client_report] discarded=[{"reason":"ratelimit_backoff","category":"span","quantity":3}]
Note the item type: with tracesSampleRate: 1, 11.11.0 sent span items, not transaction (stub-3-span-only.jsonl). If your proxy limits transaction, check what you actually receive.
How long it backs off, and when it tries again
It waits exactly the window it was given, then sends the next real event as a probe. If that one also gets a 429, the window starts over. With WINDOW=5 and one error per second (run-5a-window-5s.txt):
17:59:22.491 429 [event] boom #3 {"x-sentry-rate-limits":"5:error:organization:usage_exceeded","retry-after":"5"}
17:59:27.501 429 [event] boom #8 {"x-sentry-rate-limits":"5:error:organization:usage_exceeded","retry-after":"5"}
17:59:32.509 429 [event] boom #13 {"x-sentry-rate-limits":"5:error:organization:usage_exceeded","retry-after":"5"}
Under a still-exhausted quota you get one request per window, and that event is lost too. When the stub refilled the quota 6s after the cap (run-5b-refill.txt), events flowed again on the first probe:
17:59:42.635 429 [event] boom #8 {"x-sentry-rate-limits":"5:error:organization:usage_exceeded","retry-after":"5"}
17:59:47.644 200 [event] boom #13
17:59:48.645 200 [event] boom #14
boom #9 to #12 were dropped even though my stub would have taken them from about 17:59:43. The SDK can't know the quota is back until its window ends.
Without the rate-limit header, Retry-After: 5 gave the same 5s rhythm (run-4a-retry-only.txt). A bare 429 gave the spec's 60s default (run-4b-bare-429.txt):
17:58:11.283 429 [event] boom #3
17:59:11.330 429 [event] boom #15
The client report says 7. I lost 8
The SDK counts its drops and sends a client_report envelope. Raw line from stub-2-cap-60s.jsonl:
{"at":"17:57:39.912","ms":5717,"path":"/api/1/envelope/","items":["client_report"],"status":200,"client_report":{"timestamp":1794243459.91,"discarded_events":[{"reason":"ratelimit_backoff","category":"error","quantity":7}]}}
ratelimit_backoff comes from Sentry's client reports spec. Three things I only learned by running it:
-
It doesn't count the rejected event.
quantity: 7, but 8 errors were lost:boom #3got the429andboom #4to#10were dropped locally. If you build an ingest, count your own429s as well. -
It's on a timer.
clientReportFlushIntervaldefaults to 60000 ms (from the SDK's type docs). Run 2 got its report atclose(). WithREPORT_MS=3000(run-6-report-3s.txt) it came in batches:quantity3, 3, then 1 at close. A process that dies hard before either never reports. -
An all-category limit eats it. With a bare
429,run-4b-bare-429.txtshows noclient_reportat all for 11 local drops. The debug run shows why (run-4c-bare-debug.txt):
Sentry Logger [log]: Sending outcomes: [ { reason: 'ratelimit_backoff', category: 'error', quantity: 1 } ]
Sentry Logger [log]: Recording outcome: "ratelimit_backoff:internal"
The report is an internal item, and the spec says the "all" limit applies to internal too. So the report was blocked and counted as one more drop. run-4a-retry-only.txt only got its report because close() landed 5.007s after the last 429, just past the window.
How to detect it from your app and your ingest
In the app, the afterSendEvent hook from Step 2 is the detector. It fired for every captured error: statusCode: 429 for the rejected one, none for local drops. Swap my console.log for a counter or a console.warn.
I only tested rate limits. From the SDK source, a network error also comes back with no statusCode, so this lumps both together. Spans don't go through this hook.
If you run the ingest, sum client_report discards plus your own 429s per DSN. discards.js does that over the stub logs (run-7-detect.txt, first rows):
stub-2-cap-60s.jsonl 429s=1 reported_lost={"ratelimit_backoff:error":7}
stub-2b-cap-debug.jsonl 429s=1 reported_lost={"ratelimit_backoff:error":1}
stub-3-span-only.jsonl 429s=1 reported_lost={"ratelimit_backoff:span":3}
stub-4a-retry-only.jsonl 429s=2 reported_lost={"ratelimit_backoff:error":8}
stub-4b-bare-429.jsonl 429s=2 reported_lost={}
The last row is the trap. Two 429s, zero reported losses, 11 events actually dropped.
Failure checklist
-
Watching for exceptions. Nothing throws at the cap, and
captureExceptionstill returns an event id for an event that never leaves the process. -
Filtering out debug logs. One
warnfor the first429, then onlylog-level "Recording outcome" lines. -
Expecting a retry. The event that got the
429is gone, and so is everything captured during the window (7 of 10 in run 2). -
Trusting client reports for the full count. They skip the
429ed event, arrive on a 60s timer or atclose(), and vanish under an all-category limit. -
Sending a bare
429from your own proxy or ingest. It blocks every category for 60s, client reports included. SendX-Sentry-Rate-Limitswith a category if you only mean errors. -
Limiting the wrong category in a proxy. 11.11.0 sent
spanitems, nottransaction. Check the item types on your wire. -
Skipping
Sentry.close()in short jobs. Pending client reports flush there. No close, no report.
Run it yourself
The listings above are trimmed. The full lab is run-all.sh (30 lines) plus stub.js, app.js, eventid.js, summary.js and discards.js: 11 scenarios in about 2.5 minutes, mostly the 60s bare 429. A second full run in a fresh copy gave the same counts.
I'm building Epure, a small self-hosted error tracker (errors only) that accepts events from Sentry SDKs and runs as an app plus Postgres in Docker Compose (v0.x, Apache 2.0). This lab is how I'm deciding what my own 429 should look like. Nothing above depends on it. The tap method on its own is in Log what your Sentry SDK actually sends, and the flood side is in Your sampleRate won't save you from an error flood.
If you've hit a quota cap in production, how did you first find out events were being dropped?
Originally published by Dev.to WebDev. Aggregated on AIWithGhost for educational purposes β full credit and traffic to the original publisher.