9 min read·Run it here

Stripe: Errors and Retry Policy

Which failures are worth retrying, and how. Classify on the error type and never the status code, back off with jitter, honour Retry-After in seconds, then bound the whole thing with a budget and a breaker that lets exactly one probe through.

Pattern
Classify by type -> jittered backoff -> budget + breaker
Time
O(attempts) per call
Space
O(retries) for the delay trail

Every infrastructure interview that touches a payments API arrives at the same question, usually phrased as "the call failed, what do you do". The wrong answer is a for loop with three attempts in it. A retry is a decision about someone else's system, and the whole craft is in knowing which failures are worth spending another request on, how long to wait, and when to stop asking.

The recipe

Say before you type: "I classify on the error type, never the status code. Then exponential backoff with jitter, the server's Retry-After wins, and the whole thing sits under a budget and a breaker."

Three layers, and they go in this order, because each one is meaningless without the one before it.

1. Classify, and do it on type

const RETRYABLE = new Set(["rate_limit_error", "api_error", "api_connection_error"]);
const BUGS = new Set(["invalid_request_error", "authentication_error", "idempotency_error"]);

classify(error) {
  const type = error ? error.type : undefined;
  if (!type) return "retry";
  if (RETRYABLE.has(type)) return "retry";
  if (type === "card_error") return "fail";
  return "bug";
}

Three verdicts, and they answer three different questions.

VerdictWhose problemStripe types
retryStripe's, or the network'srate_limit_error, api_error, api_connection_error
failThe customer's bankcard_error
bugYoursinvalid_request_error, authentication_error, idempotency_error

One naming thing to have straight before an interviewer asks. These snake_case strings are the shape of the raw API error body, and today's API reference narrows that type enum to four values: api_error, card_error, idempotency_error, invalid_request_error. The other three are real Stripe error categories, and every SDK exposes them, but they arrive as the library's own class names: in stripe-node err.type is "StripeRateLimitError" or "StripeCardError", and the raw body's type sits on err.raw.type. The drill uses the category names because those are what Stripe's own error-handling guide calls them and they are what you will say out loud. The logic is identical either way. What matters in the room is that you switch on the category and not the status code, and that you know which field your client actually puts it on.

The line to say out loud is that the type is the classifier and the status code is not. 402 is the dead card, 429 is the rate limit, and it is very easy to write a switch on HTTP status that gets those two backwards. A retrier that thinks a 402 is a rate limit hammers a card that is never going to work, three times per charge, for as long as the customer keeps clicking. The issuer sees every one of those attempts, and Stripe's own fraud signals remember them.

The same mistake in its more respectable form is "4xx means stop, 5xx means retry". That rule calls invalid_request_error a decline, which is wrong in a way that hides the bug, and it has nothing at all to say about a socket that died before any status existed.

Two cases people get lazy about:

  • An error with no type is a connection that never landed. Nothing answered, so nothing was charged, and the request can go again. That is a policy choice and not a law of nature: the conservative version, which the idempotency drill takes, treats an unrecognizable failure as a stop. Pick one and be ready to say why. This drill retries it, on the grounds that a request that never reached the API cannot have taken money.
  • A type you do not recognize is a bug, not a retry. No type at all means the call never landed. An unfamiliar type means Stripe answered with something you did not plan for, and a failure you cannot name is not one you may retry.

2. Back off, with jitter

const capped = Math.min(500 * Math.pow(2, retryIndex), 8000);
return Math.round(capped * (0.5 + 0.5 * rng()));

Base 500ms, doubling, capped at 8 seconds, three retries. The cap is the part people leave out, and without it the fifth retry is waiting eight minutes inside a request that timed out long ago.

Jitter is not decoration. Say what it is for: every client that broke on the same blip computed the same 500ms, so without jitter they all come back in the same millisecond and knock the service over a second time, right as it was recovering. Scaling by 0.5 to 1.0 smears the herd across the window. Inject the random source so the behavior is testable, which is also the honest answer to "how would you test this".

Retry-After is in seconds

const raw = error.headers["retry-after"];   // a string, and seconds
return Number(raw) * 1000;

This is the single most common unit bug in retry code. error.headers is the raw response, so the values are strings, and Retry-After has been seconds since HTTP was young. Everything else in your client is milliseconds. Forget the conversion and a server that asked you to wait thirty seconds gets hit again in thirty milliseconds, which is the worst possible moment to arrive.

When it is present it replaces your computed delay entirely: no jitter on top, no clipping to your cap, and it wins even when it asks for less than you had planned. The server knows when it will be ready and you do not. Some clients take the max instead, so that a bad upstream cannot talk them into hammering it; that is a defensible variant, and the thing to know in the room is that the tie-break is a choice while the unit is not.

Be precise about whose header this is. Stripe documents Stripe-Should-Retry and, on a 429, Stripe-Rate-Limited-Reason. It does not promise a Retry-After. Retry-After is the standard HTTP header that most rate-limited services send, so the right claim is "I honour a Retry-After when the response carries one", not "Stripe tells me how long to wait". The unit bug is the same wherever the header comes from, which is why it is worth drilling here.

Stripe-Should-Retry and the truthy string

if (error.headers["stripe-should-retry"] === "false") // stop

Stripe will sometimes tell you outright that a retry is pointless. Header values are strings, and "false" is a truthy string, so if (headers["stripe-should-retry"]) stops on the hint that says go ahead. Compare it. This drill lets the hint take a retry away and never add one, because a hint cannot make a dead card alive.

Do not record a wait you never take

if (attempts - 1 >= maxRetries) return { reason: "exhausted", delays };
delays.push(this.delayFor(attempts - 1, error, rng));

Discover that you are out of retries before you price the next delay. Push first and check second and your trail claims a fourth wait that never happened, which is exactly the kind of lie you will be reading at 3am when you are trying to work out where the latency went.

3. Bound it: a budget and a breaker

Three retries is a policy. It is not a bound on anything, because Retry-After can be minutes.

The budget is a wall on total elapsed wait, defaulted to 30 seconds because that is roughly what the caller upstream is willing to sit through. Before committing to a delay, check that you can afford it, and if you cannot, stop now rather than starting a wait you will be killed in the middle of. A 60 second Retry-After under a 30 second budget is a stop on the first attempt, not a minute of sleeping.

The breaker is the part that makes you sound like you have run something in production.

const UNHEALTHY = new Set(["exhausted", "budget"]);

What it counts is the whole design. It counts consecutive calls that say the dependency is unhealthy, and a card decline is not one of those. A decline is Stripe working perfectly. A breaker that counts declines trips the moment six customers in a row have insufficient funds and takes your entire payment path down with them, which is a far worse outage than the one you were protecting against. Your own invalid_request_error is not the dependency's fault either.

Then the state machine, and the only part of it that is subtle:

  • Five consecutive unhealthy calls and the breaker opens at that call's clock. While open, calls fail fast and never touch the dependency.
  • A call rejected while open is not a failure and must not push the open window forward, or a busy caller keeps the breaker open forever and it never recovers.
  • After the cooldown it is half open, and exactly one probe goes through. Take the slot before you await, not after. A gate that checks the clock and then awaits lets the whole backlog through in the same tick, which is precisely the stampede the breaker was added to prevent.
  • The probe is an ordinary call. Any answer closes the breaker and resets the count. An unhealthy answer re-opens it and restarts the cooldown, so a service that is still down gets one request per cooldown and not one per caller.

One small thing that bites: openedAt of 0 is a real timestamp. Track open state with null, not with truthiness, or your breaker silently stops working in every test that starts the clock at zero.

What the interviewer is listening for

They are not checking whether you can write exponential backoff. They are checking three sentences:

  1. "I classify on the error type, because a 402 and a 429 are the same status family and opposite decisions."
  2. "Jitter, because otherwise every client that failed together comes back together."
  3. "The breaker counts dependency failures, not declines, because a run of declines is the dependency working."

Have all three ready, and have the honest limit ready too: a retry is only safe because the idempotency key makes the second request the same request. Retry policy and idempotency are one answer, and if you can only say one of them you have not finished the sentence.