Ошибки сервера, 429 и Retry-After — Versola Docs
/docs
0.1.0versola.ioGitHub

Ошибки сервера, 429 и Retry-After

Что делает Versola OAuth SDK, когда провайдер отвечает 500, 503 или 429, как он учитывает Retry-After и какой код нужен приложению на Android, iOS и Flutter.

Когда провайдер OpenID перегружен или недоступен, Versola OAuth SDK повторяет запросы, которые безопасно повторять, ждет столько, сколько провайдер просит в Retry-After, и только потом отдает приложению одну типизированную ошибку. На этой странице показано, что SDK делает на каждом шаге и какой код нужен приложению. Страница относится к Android, iOS и Flutter, поведение на всех трех одинаковое.

Что приложению делать с ошибкой сервера?

Сбой провайдера приходит в приложение как ошибка Recoverable с причиной Server. Recoverable вы уже обрабатываете, как описано на странице Состояния сессии и ошибки. Для Server хватает трех правил:

  1. Прочитайте retryAfter. Если он задан, провайдер сказал, когда снова примет запросы. Назначьте повтор на это время и не крутите цикл.
  2. Если retryAfter пуст, SDK сделал все, что мог: бюджет повторов исчерпан, либо статус или операция такие, что повтор не поможет. Покажите кнопку «Повторить» или повторяйте по собственному расписанию.
  3. Не разлогинивайте пользователя. Ошибка сервера оставляет сессию как была, сохраненный grant не затрагивается.

429 от 5xx отличает статус. Обе приходят в одной категории.

Как обработать ошибку сервера

Server несет четыре значения. Названия на платформах немного различаются:

ЗначениеAndroid (Cause.Server)iOS (.server)Flutter (RecoverableError)
HTTP-статус последнего ответаstatusstatusstatus
Ожидание, которое назвал провайдер; пусто, если читаемого Retry-After не былоretryAfterMillisretryAfterMillisretryAfter (Duration)
Неудавшиеся запросы вызова, который поднял ошибкуattemptsattemptsattempts
Сколько времени потратила операция, с задержками и без времени пользователя в браузереelapsedMilliselapsedMilliselapsed (Duration)

attempts считает неудавшиеся запросы того вызова, который поднял ошибку, а не всей операции. Если у login() discovery не удался один раз, а потом не удается pushed authorization request, ошибка покажет только неудачи этого push. Во Flutter все четыре поля допускают null, потому что RecoverableError служит для всех причин.

useToken, scheduleRetry, showRetryButton, showCountdown и showOffline в примерах — функции вашего приложения, в 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()
        }
    }
}

На Android нагрузка Recoverable называется reason, потому что cause занят в 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()
}

Вариант называется .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();
  }
}

Как показать пользователю, сколько ждать?

Пока идет пауза после прежнего Retry-After, SDK ничего не отправляет и на запрос, который отправил бы, отвечает той же ошибкой Server: attempts равен 0, а retryAfter — остаток паузы (о паузе рассказывают следующие разделы). Остаток и нужно показать. Проверка одинакова на iOS и во Flutter (attempts == 0):

is Cause.Server -> {
    val seconds = (reason.retryAfterMillis ?: 0) / 1000
    if (reason.attempts == 0) showCountdown(seconds)   // nothing was sent
    else showRetryButton()
}

Как SDK обращается с ответами 500, 503 и 429?

SDK повторяет неудавшийся запрос, только когда «да» говорят оба условия: ответ и операция, которая отправила запрос. Повторы всегда укладываются в бюджет одного видимого приложению вызова: login(), logout(), обновления токена или вызова вроде userInfo() вместе со всеми запросами, которые он порождает. По умолчанию этот бюджет — 3 неудавшихся запроса к одному источнику, 4 ко всем источникам вместе и 20 s суммарно. Параметры и их максимумы — на странице Состояния сессии и ошибки.

ОтветЧто делает SDK
408, 429, 500, 502, 503, 504Повторяет запрос, если операция это допускает (следующая таблица), со своим backoff или с Retry-After
Любой другой 5xx (501, 505 и так далее) и статус, который операции не нужен и для которого нет более точной ошибки (404 на набор ключей, 1xx)Возвращает Server после одного запроса, без задержки
3xx от discovery, набора ключей, эндпоинта токена, UserInfo или pushed authorization requestОшибка Configuration, не повторяется никогда. SDK не следует за редиректами, а редирект от этих эндпоинтов — ошибка развертывания
Другой 4xxОтносит к категории своей ошибки OAuth или к Configuration, как описано на странице Состояния сессии и ошибки. Не повторяется никогда
Требование DPoP nonce или proofЗакрывается одним фиксированным повтором, который никакая настройка не меняет, как описано на странице Состояния сессии и ошибки
Ответа нет: DNS, соединение, TLS, таймаутОшибка Network, повторяется, если операция это допускает
ОперацияПовторяется?Почему
Обновление токенаТолько если запрос заведомо не был обработан (см. следующий раздел)Повтор может воспроизвести токен, который провайдер уже ротировал
Отзыв токена при logout()ДаОтзыв уже отозванного токена проходит успешно
UserInfoДаЧтение
Discovery и набор ключейДаЧтение
Pushed authorization requestДаВторой push создает второй request_uri, а первый истекает неиспользованным
Обмен authorization codeНикогдаКод работает один раз. Вторая попытка — повтор, на который провайдер может ответить отзывом выданного, поэтому пользователь входит заново
Ваши собственные запросы через аутентифицированный клиентНикогда по статусуSDK не знает, безопасно ли повторять ваш вызов

Бюджет расходуют только неудавшиеся запросы. login(), который делает discovery, push и проверку, отправляет несколько запросов к одному источнику, и успешные не съедают попытки у неудавшегося запроса позже.

login() — один бюджет от discovery до проверки ID-токена. Время, которое пользователь проводит в браузере, в эти 20 s не входит, а счет неудавшихся запросов через браузер не обнуляется. Набор ключей, по которому проверяется ID-токен, загружается после обмена кода, если его еще нет в кеше. Эта загрузка повторяется, как любое другое чтение, так что один потерянный ответ не губит вход, который провайдер уже завершил.

Бюджет ограничивает, как долго SDK продолжает повторять. Запрос, который уже в полете, он не обрывает: таймаут у него задает ваш HTTP-клиент.

Еще две детали:

  • У эндпоинта отзыва на другом хосте, чем эндпоинт токена, свои лимиты попыток и своя пауза.
  • Повторный запрос UserInfo с токеном, привязанным по DPoP, несет на каждой попытке свежий proof, потому что proof действителен один раз.

Почему обновление токена повторяется не всегда?

Провайдеры, которые ротируют refresh-токены, считают второе использование старого токена кражей и могут отозвать все семейство. SDK не отправляет с обновлением заголовок Idempotency-Key, потому что ни один стандарт его не определяет. После части сбоев SDK не может понять, успел ли провайдер ротировать токен до сбоя. Поэтому обновление повторяется, только если запрос заведомо не был обработан:

СбойОбновление повторяется?
Ошибка DNS, отказ в соединении, нет маршрута, ошибка TLS-рукопожатия или сертификатаДа. Ничего не было записано
408, 429, 503Да. Провайдер сообщает, что запрос не принял
Таймаут после соединения, сброшенное соединениеНет
500, 502, 504Нет. Провайдер мог сработать до сбоя

В случаях «Нет» SDK возвращает ошибку после одного запроса, без задержки. Сохраненный refresh-токен и состояние сессии остаются как были. Следующий refresh() или accessToken() из вашего приложения отправит тот же сохраненный токен, потому что SDK не знает, ротировал ли его провайдер. Если ротировал, ответом будет invalid_grant, и он придет как Reauthenticate.

Как SDK читает Retry-After?

SDK читает Retry-After только на 429 и 503. На любом другом статусе заголовок игнорируется.

  • Форматы. Целые секунды (Retry-After: 120) или дата HTTP (Retry-After: Fri, 30 Oct 2026 12:00:04 GMT). Ведущие нули входят в число: 0030 — это 30 секунд. Дату SDK отсчитывает от заголовка Date самого ответа, поэтому неверные часы на устройстве ожидание не меняют. Если пригодного Date нет, отсчет идет от часов устройства.
  • Вместо backoff. Если значение есть, SDK ждет ровно столько, плюс случайный jitter до одной десятой этого времени (не больше 1 s). Jitter никогда не превышает того, что остается в бюджете после ожидания, поэтому он не может превратить Retry-After, который помещается, в тот, который не помещается. maxBackoff это ожидание не ограничивает.
  • Никогда не сокращается. Если ожидание не помещается в остаток бюджета (по умолчанию 20 s), SDK не спит и не повторяет раньше срока. Он останавливается и возвращает значение приложению. Jitter в эту проверку не входит. Повтор до того, как провайдер сказал, что примет запрос, принесет только новый 429.
  • Это все равно попытка. Запрос после ожидания считается в лимиты попыток, как любой другой, так что Retry-After: 0 без конца заканчивается после 3 запросов.
  • Некорректное значение считается отсутствующим. Отрицательное число, дробь, текст, пустое значение, два заголовка или дата в прошлом игнорируются, и действует обычный backoff. Плохой заголовок запрос не роняет.
  • Потолок — сутки. Значение, которое видит приложение, обрезается на 86400 секундах.
ОтветЧто делает SDK
429, Retry-After: 2Ждет около 2 s, повторяет запрос и возвращает его результат
503, Retry-After: 3030 s в бюджет не помещаются. Ни ожидания, ни второго запроса: Server(status: 503, retryAfter: 30 s), и начинается пауза на 30 s
503, Retry-After: Fri, 30 Oct 2026 12:00:04 GMTЖдет до момента, на 4 s позже Date ответа (если Date — 12:00:00, ждет 4 s)
429, Retry-After: soonИгнорируется. Повтор с обычным backoff
500, Retry-After: 60Игнорируется: заголовок учитывается только на 429 и 503

Что происходит после Retry-After?

Когда операция заканчивается из-за Retry-After, SDK запоминает для каждого источника, когда провайдер снова примет запросы. Этот промежуток — пауза. Ее запускают два исхода: ожидание не помещается в бюджет или попытки кончились, пока провайдер продолжал просить подождать. Если Retry-After помещается в бюджет, SDK выдерживает ожидание и повтор удается, пауза не начинается, поэтому параллельный вызов из-за него не отклоняется.

Пока пауза идет, любой запрос, который SDK отправил бы этому источнику сам, сразу падает с той же ошибкой Server. В сеть ничего не уходит, attempts равен 0. Так провайдер, попросивший тишины, защищен от приложения, которое вызывает accessToken() в цикле, и от пятидесяти экранов, каждый из которых повторяет запрос сам.

  • login() проверяет паузу источника эндпоинта токена в самом начале, до открытия браузера: там отказ ничего не стоит пользователю. Обмен кода после браузера пауза никогда не отклоняет: пользователь только что вошел, а обмен отправляется один раз. Если он закончился ответом 429 или 503 с Retry-After, это запускает паузу, как любой другой исход.
  • Пауза живет в памяти. Новый процесс стартует без нее, а лимит провайдер все равно соблюдает сам.
  • Пауза начинается только по словам провайдера. После ошибок 5xx без Retry-After SDK паузу не вводит, и цикл повторов выше него — дело вашего приложения.
  • Пауза и бюджет продолжают отсчет, пока устройство спит, потому что Retry-After измеряется настенным временем.
  • Ваши собственные запросы через аутентифицированный клиент пауза не блокирует.
  • logout() и во время паузы стирает локальную сессию. Если эндпоинт отзыва находится на источнике под паузой, отзыв отклоняется, и tokenRevoked равен false.

А запросы через аутентифицированный клиент?

Ваши собственные вызовы API SDK по статусу не повторяет. 429 или 5xx от вашего API возвращается как есть, после одного запроса, с нетронутым заголовком Retry-After. SDK не знает, безопасно ли повторять вызов, поэтому решает приложение.

На Android oauth.call возвращает ответ OkHttp. Пример предполагает, что https://api.example.com входит в ваши 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)
        }
    }
}

На iOS AuthenticatedSession.data(for:) возвращает данные и 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))
}

Заголовок может быть и датой HTTP. Если ваш API присылает дату, разберите ее сами. Во Flutter аутентифицированного клиента нет: отправляйте запрос через dio или http с токеном из accessToken() и читайте заголовок так же.

Как изменить политику повторов?

Значений по умолчанию большинству приложений хватает. Сделать SDK терпеливее или нетерпеливее можно через retryPolicy в конфигурации, в пределах максимумов со страницы Состояния сессии и ошибки. Значение выше максимума или ниже минимума (число попыток меньше 1, множитель меньше 1.0) отклоняется ошибкой Configuration, а не обрезается молча. На Android ее бросает конструктор RetryPolicy(...), на iOS — RetryPolicy.init, во Flutter — OAuthClient.create. maxAttemptsPerOrigin не может быть больше 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),
  ),
);

На iOS длительности задаются в секундах. На Android есть еще два готовых пресета: RetryPolicy.Conservative (2 попытки на источник и всего, первая задержка 250 ms, потолок 2 s, бюджет 5 s) и RetryPolicy.Patient (5 попыток на источник, 8 всего, потолок 30 s, бюджет 60 s). На iOS и во Flutter пресетов нет: передавайте числа. Jitter отключить нельзя, и ни одна настройка не заставит SDK повторять без предела.

Что дальше