Dev.to WebDev πŸ›  Dev πŸ‘ 0 πŸ“– 9 min read

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 429 with X-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 429 with 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 the 429. Under a bare 429 the 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:

  1. It doesn't count the rejected event. quantity: 7, but 8 errors were lost: boom #3 got the 429 and boom #4 to #10 were dropped locally. If you build an ingest, count your own 429s as well.
  2. It's on a timer. clientReportFlushInterval defaults to 60000 ms (from the SDK's type docs). Run 2 got its report at close(). With REPORT_MS=3000 (run-6-report-3s.txt) it came in batches: quantity 3, 3, then 1 at close. A process that dies hard before either never reports.
  3. An all-category limit eats it. With a bare 429, run-4b-bare-429.txt shows no client_report at 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

  1. Watching for exceptions. Nothing throws at the cap, and captureException still returns an event id for an event that never leaves the process.
  2. Filtering out debug logs. One warn for the first 429, then only log-level "Recording outcome" lines.
  3. Expecting a retry. The event that got the 429 is gone, and so is everything captured during the window (7 of 10 in run 2).
  4. Trusting client reports for the full count. They skip the 429ed event, arrive on a 60s timer or at close(), and vanish under an all-category limit.
  5. Sending a bare 429 from your own proxy or ingest. It blocks every category for 60s, client reports included. Send X-Sentry-Rate-Limits with a category if you only mean errors.
  6. Limiting the wrong category in a proxy. 11.11.0 sent span items, not transaction. Check the item types on your wire.
  7. 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?

πŸ“° Read the original article on Dev.to WebDev

Originally published by Dev.to WebDev. Aggregated on AIWithGhost for educational purposes β€” full credit and traffic to the original publisher.