Rate limits and retries
How a refused request looks, what a well-behaved client does about it, and which requests are safe to repeat.
On this page
Identity limits how fast any one caller can use its endpoints. This protects every other client from a loop, a bug or an attack. The limits are not published and they change, so nothing here depends on a number. What you can rely on is how a refusal looks and what to do about it.
What a refusal looks like#
A request that is refused for sending too many is answered with status 429 and a Retry-After header. The token, pushed authorization, revocation, introspection and userinfo endpoints all answer this way.
HTTP/1.1 429 Too Many RequestsRetry-After: 12Cache-Control: no-storeContent-Type: application/json{"error":"temporarily_unavailable","error_description":"too many requests"}Retry-Afteris a whole number of seconds, at least 1. It is the earliest time worth trying again.- The body is the same whatever caused the refusal. Which limit you hit is not revealed, so do not try to infer it.
- On the token, pushed authorization, revocation and introspection endpoints the answer also carries a fresh
DPoP-Nonceheader, so a retry does not cost a nonce round trip. - The sign-in pages people use in the browser are limited separately. The page handles that, not your code.
What to do#
- Stop and wait for
Retry-After. Do not send anything to that endpoint before it passes. - Add jitter. If many of your workers were refused together, waiting exactly the same time sends them back together.
- Cap the number of retries and the total wait. After a few attempts, fail the operation and tell somebody. A client that retries forever turns a brief limit into a long one.
- Slow down at the source. A 429 on a routine call usually means a cache is missing. Cache discovery, the key set and your service tokens. See Client credentials.
- Never loop on a failing request. A request that is refused for another reason, such as a bad assertion, will be refused again, and sending it faster only makes you limited sooner.
The SDK's relying party and service client already retry a 429 for you, and only a 429. They wait for Retry-After (plus a little jitter, or an exponential back-off when there is no header), make at most three attempts by default, never wait longer than five seconds for one retry, and stop after twenty seconds in total. A Retry-After longer than the cap is not waited out: the call fails at once and the error carries the value. Every attempt builds a new client assertion and a new DPoP proof. Tune it with the retry option, from one attempt (no retry) up to five, or see Retrying a 429 for every member.
When the attempts run out, the SDK throws OAuthEndpointError with reason rate_limited, retryAfterSeconds and attempts. It never retries a 5xx, a timeout or an OAuth error: it throws OAuthEndpointError with reason server_error, transport or oauth_error, and what to do next is your decision, using the table below. The other retry it makes is the DPoP nonce challenge. If you also want to retry server errors, or you call other endpoints yourself, write a policy of your own. Create the SDK client with retry: { maxAttempts: 1 } when you wrap it, so the two do not multiply:
import { OAuthEndpointError } from '@tdacorp/identity-sdk/rp'const MAX_ATTEMPTS = 3const MAX_WAIT_SECONDS = 30const sleep = (milliseconds: number): Promise<void> => new Promise((resolve) => setTimeout(resolve, milliseconds))/** * Retries a call that is safe to repeat, and only for a rate limit or a server error. * Any other failure goes straight back to the caller. The SDK retries a rate limit on its own, a bounded number of times; * create the client with `retry: { maxAttempts: 1 }` when you wrap it in this, so the attempts do not multiply. */export async function withRetry<T>(call: () => Promise<T>): Promise<T> { for (let attempt = 1; ; attempt += 1) { try { return await call() } catch (error) { if (!(error instanceof OAuthEndpointError)) throw error if (error.reason !== 'rate_limited' && error.reason !== 'server_error') throw error if (attempt >= MAX_ATTEMPTS) throw error // Honour the server's Retry-After when it sent one, back off exponentially when it did not, and add a little jitter. const seconds = Math.min(error.retryAfterSeconds ?? 2 ** attempt, MAX_WAIT_SECONDS) await sleep(seconds * 1000 * (1 + Math.random() * 0.2)) } }}Which requests are safe to repeat#
A retry is only correct when repeating the request cannot do harm.
| Request | Safe to retry after a 429 or a 5xx? |
|---|---|
| Pushed authorization request | Yes. A new one gives a new request_uri, and the old one simply expires. |
client_credentials token request | Yes. |
| Token exchange | Yes. |
/userinfo, /revoke, introspection | Yes. Revoking twice is harmless. |
| Authorization code redemption | After a 429, yes: the request was refused before it was processed. After a 5xx or a timeout, no. The code may have been spent without you seeing the answer. Start a new sign-in. |
| Refresh | After a 429, yes. After a 5xx or a timeout, retry once right away, within the short grace window (about ten seconds at most; identity may shorten it): identity returns the same new refresh token to a repeat inside that short window. Later than that, a repeat counts as reuse and ends the session. Do not run two refreshes of one token at once. See Refresh tokens. |
Every retry builds a new client assertion and a new DPoP proof. Both are single use, and a copy of the refused request will be refused for that reason alone.
Be a good neighbour#
- Reuse access tokens until shortly before they expire instead of asking for one per request.
- Do not poll. You should not need to: sessions end through back-channel logout, and access tokens are checked by their signature.
- Spread scheduled work. Many jobs that all start on the hour all ask for tokens on the hour.
If you are limited and should not be#
A limit that blocks ordinary traffic is worth reporting. Send TDACorp the time, the endpoint, your client id and the Retry-After you saw. Do not send tokens or keys.