Server errors, 429 and Retry-After — Versola Docs
/docs
0.1.0versola.ioGitHub

Server errors, 429 and Retry-After

What the Versola OAuth SDK does when a provider answers 500, 503 or 429, how it honours Retry-After, and the code your app needs for each case on Android, iOS and Flutter.

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:

  1. Read retryAfter. If it’s set, the provider said when it will accept requests again. Schedule the retry for then, and don’t loop.
  2. If retryAfter is 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.
  3. 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:

ValueAndroid (Cause.Server)iOS (.server)Flutter (RecoverableError)
HTTP status of the last answerstatusstatusstatus
The provider’s wait, empty without a usable Retry-AfterretryAfterMillisretryAfterMillisretryAfter (a Duration)
Failed requests of the call that raised the errorattemptsattemptsattempts
Time the operation spent, delays included and the user’s time in the browser left outelapsedMilliselapsedMilliselapsed (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.

ResponseWhat the SDK does
408, 429, 500, 502, 503, 504Repeats 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 requestA Configuration error, never repeated. The SDK doesn’t follow redirects, and a redirect from these endpoints is a deployment fault
Another 4xxMaps to the category of its OAuth error, or to Configuration, as described on Session states and errors. Never repeated
A DPoP nonce or proof challengeAnswered by one fixed retry that no setting changes, as described on Session states and errors
No answer: DNS, connection, TLS, timeoutA Network error, repeated when the operation allows it
OperationRepeated?Why
Token refreshOnly when the request provably wasn’t processed (see the next section)A repeat can replay a token the provider already rotated
Revocation on logout()YesRevoking a revoked token succeeds
UserInfoYesA read
Discovery and the key setYesReads
Pushed authorization requestYesA second push creates a second request_uri, and the first expires unused
Authorization-code exchangeNeverThe 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 clientNever on a statusThe 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:

FailureRefresh repeated?
DNS failure, connection refused, no route, TLS handshake or certificate errorYes. Nothing was written
408, 429, 503Yes. The provider says it didn’t take the request up
Timeout after connecting, a reset connectionNo
500, 502, 504No. 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: 0030 is 30 seconds. A date is measured against the response’s own Date header, so a wrong clock on the device doesn’t change the wait. Without a usable Date, 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-After that fits into one that doesn’t. maxBackoff doesn’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: 0 repeated 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.
ResponseWhat the SDK does
429, Retry-After: 2Waits about 2 s, repeats the request, and returns its result
503, Retry-After: 3030 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 GMTWaits until 4 s after the response’s Date (if that Date is 12:00:00, it waits 4 s)
429, Retry-After: soonIgnored. Repeats with the ordinary backoff
500, Retry-After: 60Ignored, 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 a Retry-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-After is 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 and tokenRevoked is false.

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