Repository navigation
Two different 429s from one Express middleware, and how a client should tell them apart #7579
supertj
started this conversation in
Show and tell
Replies: 0 comments
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Uh oh!
There was an error while loading. Please reload this page.
If your API has paid plans, a 429 can mean two things. Either the caller is sending too fast, or they've used everything their plan includes for this billing period. A generic "retry on 429" helper treats both the same, and gets one of them badly wrong.
Here's the server side. The UsageRail middleware checks the caller's API key, then that customer's per-second and per-minute limits, then their quota, all before your route runs:
When a customer goes over their rate limit:
When they've used up the quota on a plan that stops at the limit:
Same status code, but here
Retry-Afteris the number of seconds left in the billing period, 12 days. A retry helper that sleeps onRetry-Afterparks the job for 12 days. One that ignores it and backs off exponentially keeps asking for something that won't come back until the period resets.Checking the
errorfield alone isn't quite enough either.quota_exceededcan also come back withRetry-After: 1for a moment while the SDK is topping up its share of that customer's quota, and that one is worth retrying. What worked for me is to retry short waits and surface long ones:A few Express-specific things I ran into:
app.use(usageRail(...))only covers routes registered after it. That's how/healthabove stays open, and it's also how a router mounted in an earlier file ends up silently unmetered./tenants/:tenantId/reports. Express doesn't keep a router's mount path as you wrote it, so the SDK records mount paths as routers are mounted. Import@usagerail/node/expressbefore the modules that define your routers. Routers mounted earlier get grouped by the concrete path (/tenants/42/reports), so every tenant becomes its own row.onReject, copyrejection.headersonto the response:res.status(rejection.status).set(rejection.headers).json(...). Without that,Retry-Afterand theRateLimitfields disappear and clients go back to guessing.rate_limitedrefusal also doesn't take a token from the bucket.Disclosure: I work on UsageRail. The Express setup, options and the full list of rejection codes are at https://usagerail.com/docs/express
All reactions