When an OpenID provider is overloaded or down, the Versola OAuth SDK repeats the requests that are safe to repeat, waits as long as the provider asks through Retry-After, and then hands your app one typed error. This page shows what the SDK does at each step and the code your app needs. It applies to Android, iOS and Flutter, and the behaviour is the same on all three.
What does your app do with a server error?
A provider failure reaches your app as a Recoverable error with the cause Server. You already handle Recoverable as described on Session states and errors. Three rules cover Server:
- Read
retryAfter. If it’s set, the provider said when it will accept requests again. Schedule the retry for then, and don’t loop. - If
retryAfteris empty, the SDK has done what it can: it spent its retry budget, or the status or the operation is one a repeat can’t help. Show a retry button, or back off on your own schedule. - Don’t sign the user out. A server error leaves the session as it was, and the stored grant is untouched.
The status tells a 429 from a 5xx. Both arrive in the same category.
Handle a server error
Server carries four values. The names differ a little per platform:
| Value | Android (Cause.Server) | iOS (.server) | Flutter (RecoverableError) |
|---|---|---|---|
| HTTP status of the last answer | status | status | status |
The provider’s wait, empty without a usable Retry-After | retryAfterMillis | retryAfterMillis | retryAfter (a Duration) |
| Failed requests of the call that raised the error | attempts | attempts | attempts |
| Time the operation spent, delays included and the user’s time in the browser left out | elapsedMillis | elapsedMillis | elapsed (a Duration) |
attempts counts the failed requests of the call that raised the error, not of the whole operation. A login() whose discovery failed once and whose pushed authorization request then fails reports the failures of the push alone. On Flutter the four fields are nullable, because RecoverableError serves every cause.
useToken, scheduleRetry, showRetryButton, showCountdown and showOffline in the snippets are your app’s own functions, not part of the SDK.
Android
import versola.oauth.OAuthClient
import versola.oauth.core.VersolaError
import versola.oauth.core.VersolaError.Recoverable.Cause
suspend fun loadToken(oauth: OAuthClient) {
try {
val token = oauth.accessToken()
useToken(token.value)
} catch (e: VersolaError.Recoverable) {
when (val reason = e.reason) {
is Cause.Server -> {
val waitMillis = reason.retryAfterMillis
if (waitMillis != null) {
scheduleRetry(afterMillis = waitMillis) // the provider said when
} else {
showRetryButton() // the SDK did what it could
}
}
is Cause.Network -> showOffline()
else -> showRetryButton()
}
}
}
On Android the payload of Recoverable is named reason, because cause is taken by Throwable.
iOS
do {
let token = try await oauth.accessToken()
useToken(token.value)
} catch OAuthError.recoverable(.server(let status, let retryAfterMillis, _, _)) {
if let retryAfterMillis {
scheduleRetry(after: .milliseconds(retryAfterMillis)) // the provider said when
} else {
showRetryButton(status: status) // the SDK did what it could
}
} catch OAuthError.recoverable(.network) {
showOffline()
}
The case is .server(status:retryAfterMillis:attempts:elapsedMillis:).
Flutter
try {
final token = await client.accessToken();
useToken(token.value);
} on RecoverableError catch (error) {
switch (error.cause) {
case RecoverableCause.server:
final wait = error.retryAfter;
if (wait != null) {
scheduleRetry(after: wait); // the provider said when
} else {
showRetryButton(); // the SDK did what it could
}
case RecoverableCause.network:
showOffline();
default:
showRetryButton();
}
}
How do you show the user how long to wait?
While a pause from an earlier Retry-After is running, the SDK sends nothing and answers a request it would have sent with the same Server error: attempts is 0 and retryAfter is what is left of the pause (the sections below explain the pause). That remaining time is the number to show. The check is the same on iOS and Flutter (attempts == 0):
is Cause.Server -> {
val seconds = (reason.retryAfterMillis ?: 0) / 1000
if (reason.attempts == 0) showCountdown(seconds) // nothing was sent
else showRetryButton()
}
How does the SDK treat a 500, 503 or 429?
The SDK repeats a failed request only when two things both say yes: the response, and the operation that sent the request. A repeat always runs inside the budget of one application-visible call: a login(), a logout(), a refresh or a call such as userInfo(), together with every request it causes. By default that budget is 3 failed requests to one origin, 4 across all origins and 20 s in total. The limits and their maxima are on Session states and errors.
| Response | What the SDK does |
|---|---|
| 408, 429, 500, 502, 503, 504 | Repeats the request when the operation allows it (next table), with its own backoff or with Retry-After |
| Any other 5xx (501, 505, and so on), and a status the operation has no use for and no more precise error for (a 404 on the key set, a 1xx) | Returns Server after one request, with no delay |
| 3xx from discovery, the key set, the token endpoint, UserInfo or the pushed authorization request | A Configuration error, never repeated. The SDK doesn’t follow redirects, and a redirect from these endpoints is a deployment fault |
| Another 4xx | Maps to the category of its OAuth error, or to Configuration, as described on Session states and errors. Never repeated |
| A DPoP nonce or proof challenge | Answered by one fixed retry that no setting changes, as described on Session states and errors |
| No answer: DNS, connection, TLS, timeout | A Network error, repeated when the operation allows it |
| Operation | Repeated? | Why |
|---|---|---|
| Token refresh | Only when the request provably wasn’t processed (see the next section) | A repeat can replay a token the provider already rotated |
Revocation on logout() | Yes | Revoking a revoked token succeeds |
| UserInfo | Yes | A read |
| Discovery and the key set | Yes | Reads |
| Pushed authorization request | Yes | A second push creates a second request_uri, and the first expires unused |
| Authorization-code exchange | Never | The code works once. A second attempt is a replay the provider may answer by revoking what it issued, so the user signs in again |
| Your own requests through the authenticated client | Never on a status | The SDK can’t know whether your call is safe to repeat |
Only requests that failed count against the budget. A login() that discovers, pushes and validates makes several requests to one origin, and the successful ones don’t use up the attempts of a later failing one.
A login() is one budget from discovery to ID-token validation. The time the user spends in the browser doesn’t count against the 20 s, and the allowances carry across it. The key set that the ID token is checked against is fetched after the code exchange when it isn’t cached yet. That fetch is repeated like any other read, so one dropped response doesn’t lose a login the provider already completed.
The budget limits how long the SDK keeps retrying. It doesn’t cut off a request in flight, whose timeout belongs to your HTTP client.
Two more details:
- A revocation endpoint on another host than the token endpoint has its own allowance and its own pause.
- A repeated UserInfo request on a DPoP-bound token carries a fresh proof on every attempt, because a proof is valid once.
Why is a token refresh repeated only sometimes?
Providers that rotate refresh tokens treat a second use of an old token as theft and can revoke the whole family. The SDK sends no Idempotency-Key with a refresh, because no standard defines one. After some failures it can’t tell whether the provider rotated the token before it failed. It therefore repeats a refresh only when the request provably wasn’t processed:
| Failure | Refresh repeated? |
|---|---|
| DNS failure, connection refused, no route, TLS handshake or certificate error | Yes. Nothing was written |
| 408, 429, 503 | Yes. The provider says it didn’t take the request up |
| Timeout after connecting, a reset connection | No |
| 500, 502, 504 | No. The provider may have acted before failing |
For the cases marked “No”, the SDK returns the error after one request, with no delay. The stored refresh token and the session state stay as they were. The next refresh() or accessToken() your app calls sends the same stored token, because the SDK can’t know whether the provider rotated it. If the provider did, the answer is invalid_grant, which arrives as Reauthenticate.
How does the SDK read Retry-After?
The SDK reads Retry-After on 429 and 503 only. On any other status it ignores the header.
- Forms. Whole seconds (
Retry-After: 120) or an HTTP date (Retry-After: Fri, 30 Oct 2026 12:00:04 GMT). Leading zeros are part of the number:0030is 30 seconds. A date is measured against the response’s ownDateheader, so a wrong clock on the device doesn’t change the wait. Without a usableDate, it is measured against the device clock. - In place of the backoff. With a value, the SDK waits that long, plus random jitter of up to a tenth of it (at most 1 s). The jitter never exceeds what the budget has left after the wait, so it can’t turn a
Retry-Afterthat fits into one that doesn’t.maxBackoffdoesn’t cap the wait. - Never shortened. If the wait doesn’t fit what is left of the budget (20 s by default), the SDK doesn’t sleep and doesn’t retry early. It stops and returns the value to your app. The jitter isn’t part of this test. Retrying before the provider said it would accept the request only earns another 429.
- Still an attempt. A request sent after the wait counts against the attempt limits like any other, so
Retry-After: 0repeated forever still ends after 3 requests. - Malformed means absent. A negative number, a fraction, text, an empty value, two headers or a date in the past is ignored, and the ordinary backoff applies. A bad header never fails a request.
- Capped at a day. The value your app sees saturates at 86400 seconds.
| Response | What the SDK does |
|---|---|
429, Retry-After: 2 | Waits about 2 s, repeats the request, and returns its result |
503, Retry-After: 30 | 30 s doesn’t fit the budget. No wait and no second request: Server(status: 503, retryAfter: 30 s), and a pause of 30 s starts |
503, Retry-After: Fri, 30 Oct 2026 12:00:04 GMT | Waits until 4 s after the response’s Date (if that Date is 12:00:00, it waits 4 s) |
429, Retry-After: soon | Ignored. Repeats with the ordinary backoff |
500, Retry-After: 60 | Ignored, because only 429 and 503 carry it |
What happens after a Retry-After?
When an operation ends because of a Retry-After, the SDK remembers per origin when the provider will accept requests again. That interval is the pause. Two endings start it: the wait doesn’t fit the budget, or the attempts ran out while the provider kept asking. A Retry-After that fits, is waited out and ends in a success starts no pause, so a concurrent call isn’t refused on its account.
While the pause runs, every request the SDK itself would send to that origin fails at once with the same Server error. Nothing goes over the network, and attempts is 0. This protects a provider that asked for quiet from an app that calls accessToken() in a loop, or from fifty screens that each retry.
login()checks the pause of the token endpoint’s origin at its start, before the browser opens, where a refusal costs the user nothing. The code exchange after the browser is never refused by a pause: the user has just signed in, and the exchange is sent once. If it ends with a 429 or 503 and aRetry-After, that starts a pause like any other ending.- The pause lives in memory. A new process starts without it, and the provider still enforces its own limit.
- It starts only from what the provider said. After 5xx errors without
Retry-After, the SDK starts no pause, and the retry loop above it is your app’s. - The pause and the budget keep counting while the device sleeps, because
Retry-Afteris wall time. - Your own requests through the authenticated client are never blocked by it.
logout()still wipes the local session during a pause. If the revocation endpoint is on the paused origin, the revocation is refused andtokenRevokedisfalse.
What about requests through the authenticated client?
The SDK doesn’t repeat your own API calls on a status. A 429 or 5xx from your API comes back as the response it is, after one request, with its Retry-After header intact. The SDK can’t know whether the call was safe to repeat, so your app decides.
On Android, oauth.call returns the OkHttp response. The example assumes https://api.example.com is one of your protectedOrigins.
import okhttp3.Request
import versola.oauth.OAuthClient
suspend fun loadOrders(oauth: OAuthClient) {
val request = Request.Builder().url("https://api.example.com/orders").build()
oauth.call(request).use { response ->
if (response.code == 429 || response.code == 503) {
val seconds = response.header("Retry-After")?.toLongOrNull() // seconds form only
scheduleRetry(afterMillis = (seconds ?: 5) * 1000)
}
}
}
On iOS, AuthenticatedSession.data(for:) returns the data and an HTTPURLResponse:
let session = try oauth.authenticatedSession()
let (data, response) = try await session.data(for: request)
if response.statusCode == 429 || response.statusCode == 503 {
let seconds = response.value(forHTTPHeaderField: "Retry-After").flatMap(Int.init) // seconds form only
scheduleRetry(after: .seconds(seconds ?? 5))
}
The header can also be an HTTP date. Parse it yourself if your API sends one. Flutter has no authenticated client: send the request with dio or http, using the token from accessToken(), and read the header the same way.
How do you change the retry policy?
The defaults suit most apps. You can make the SDK more or less patient with retryPolicy in the configuration, up to the maxima on Session states and errors. A value above a maximum, or below a minimum (an attempt count under 1, a multiplier under 1.0), is refused with a Configuration error, not silently clamped. On Android it is thrown by the RetryPolicy(...) constructor, on iOS by RetryPolicy.init, and on Flutter by OAuthClient.create. maxAttemptsPerOrigin can’t exceed maxTotalAttempts.
import kotlin.time.Duration.Companion.seconds
import versola.oauth.OAuthConfiguration
import versola.oauth.core.RetryPolicy
OAuthConfiguration(
issuer = "https://id.example.com",
clientId = "acme-app",
redirectUri = "https://app.example.com/oauth",
retryPolicy = RetryPolicy(
maxAttemptsPerOrigin = 2,
maxBackoff = 4.seconds,
totalBudget = 10.seconds,
),
)
let configuration = try OAuthConfiguration(
issuer: "https://id.example.com",
clientId: "acme-app",
redirectUri: "https://app.example.com/oauth",
retryPolicy: try RetryPolicy(maxAttemptsPerOrigin: 2, maxBackoff: 4, totalBudget: 10)
)
const configuration = OAuthConfiguration(
issuer: 'https://id.example.com',
clientId: 'acme-app',
redirectUri: 'https://app.example.com/oauth',
retryPolicy: RetryPolicy(
maxAttemptsPerOrigin: 2,
maxBackoff: Duration(seconds: 4),
totalBudget: Duration(seconds: 10),
),
);
On iOS the durations are seconds. Android also has two ready-made presets: RetryPolicy.Conservative (2 attempts per origin and in total, a 250 ms first delay, a 2 s ceiling, a 5 s budget) and RetryPolicy.Patient (5 attempts per origin, 8 in total, a 30 s ceiling, a 60 s budget). iOS and Flutter have no presets: pass the numbers. The delay jitter can’t be turned off, and no setting lets the SDK retry without a limit.
Next steps
- Session states and errors: the four error categories and the retry budget.
- Provider guides: which providers send
Retry-Afterand which only send their own rate-limit headers. - Android, iOS and Flutter: the platform setup these snippets assume.