GunSpec
Practice

Handle failure on purpose

Some failures are worth sending again and some can only fail again. Telling them apart is the difference between a slow minute and a wasted quota.

Branch on error.reason, never on the message: reasons are stable and never renamed, messages are written for people. The errors page lists every one of them.

ReasonStatusHow long to wait
RATE_LIMITED429Retry-After says 60s. Honour the header, not the number.
PAGINATION_BURST429Exponential backoff with jitter.
DAILY_CAP_EXCEEDED429Retry-After says 3600s. Honour the header, not the number.
DEPENDENCY_UNAVAILABLE503Exponential backoff with jitter.
MAINTENANCE503Exponential backoff with jitter.
INTERNAL_ERROR500Exponential backoff with jitter.

These are answers, not outages. Retrying them spends requests to receive the same sentence, and in a loop it is how a key reaches its daily cap by lunchtime.

ReasonStatusWhat actually fixes it
KEY_INVALID401Check the key for a typo, or create a new one in your account.
KEY_DISABLED401Re-enable it in your account, or use another key.
KEY_EXPIRED401Create a new key in your account.
PLAN_REQUIRED403Upgrade the plan. A new key on the same plan will get the same answer.
ACCOUNT_SUSPENDED403Contact support. Rotating the key will not help.
PAGINATION_DEPTH_EXCEEDED403Narrow the list with filters, or upgrade for deeper paging.
INVALID_PARAMETER400Correct the named fields and send the request again.
RESOURCE_NOT_FOUND404Check the id. The list and search endpoints return valid ones.

401 is about the credential, 403 about what it is allowed to do. Reissuing a key fixes the first and changes nothing about the second. Authentication draws the line in full.

Three rules: only retryable reasons, the server’s own Retry-After when it sends one, and jitter so a fleet that failed together does not retry together.

call.ts
javascript
// Retry the failures that can succeed later, and only those.const RETRYABLE = new Set(['RATE_LIMITED', 'PAGINATION_BURST', 'DAILY_CAP_EXCEEDED', 'DEPENDENCY_UNAVAILABLE', 'MAINTENANCE', 'INTERNAL_ERROR']) async function call(request, attempt = 0) {  const res = await fetch(request)  if (res.ok) return res   const body = await res.json().catch(() => null)  const reason = body?.error?.reason   // A reason the API says is permanent. Sending it again is a  // request spent to receive the same sentence.  if (!RETRYABLE.has(reason) || attempt >= 4) throw new GunSpecError(res.status, body)   // Honour the server's own number when it sends one: it knows when  // the window resets and a guess does not.  const retryAfter = Number(res.headers.get('Retry-After')) || 0  const backoff = retryAfter * 1000 || 2 ** attempt * 500   // Jitter, or every client that failed together retries together.  await sleep(backoff + Math.random() * 250)  return call(request, attempt + 1)}

The official SDKs do this already: maxRetries defaults to 2, Retry-After is honoured, and every error carries reason and requestId. Tune it with retry on the client rather than wrapping it. See the SDK reference.

A page that renders without our data is better than a page that does not render. Decide this once, at the boundary, rather than in every component.

firearm-for-page.ts
javascript
// What the reader sees while this is going wrong.try {  return await gunspec.firearms.get(id)} catch (err) {  // A copy from ten minutes ago is a better page than an error, and  // the catalog changes on the order of days.  const stale = await cache.get(id)  if (stale) return { ...stale, stale: true }   // Nothing cached. Say what failed, quote the request id, and let  // the page render the rest of itself.  logger.warn({ requestId: err.requestId, reason: err.reason }, 'gunspec unavailable')  return null}
  • Serve a stale copy before you serve an error. The catalog moves on the order of days, so ten-minute-old specs are not a lie.
  • Fail one section, not the page: sellers being unavailable should not take the specification table with it.
  • Put a timeout on every call. A request that never returns is worse than one that fails, because nothing downstream ever gets to decide.
  • Never retry a write because the response was slow unless you have an idempotency strategy. A duplicate is a real cost; a delay is not.

Every response carries X-Request-Id, and every SDK error carries it as requestId. Log it. With that id we can find the exact request; without it, a report is a description of a page. Support and feedback is where it goes.