Когда провайдер OpenID перегружен или недоступен, Versola OAuth SDK повторяет запросы, которые безопасно повторять, ждет столько, сколько провайдер просит в Retry-After, и только потом отдает приложению одну типизированную ошибку. На этой странице показано, что SDK делает на каждом шаге и какой код нужен приложению. Страница относится к Android, iOS и Flutter, поведение на всех трех одинаковое.
Что приложению делать с ошибкой сервера?
Сбой провайдера приходит в приложение как ошибка Recoverable с причиной Server. Recoverable вы уже обрабатываете, как описано на странице Состояния сессии и ошибки. Для Server хватает трех правил:
- Прочитайте
retryAfter. Если он задан, провайдер сказал, когда снова примет запросы. Назначьте повтор на это время и не крутите цикл. - Если
retryAfterпуст, SDK сделал все, что мог: бюджет повторов исчерпан, либо статус или операция такие, что повтор не поможет. Покажите кнопку «Повторить» или повторяйте по собственному расписанию. - Не разлогинивайте пользователя. Ошибка сервера оставляет сессию как была, сохраненный grant не затрагивается.
429 от 5xx отличает статус. Обе приходят в одной категории.
Как обработать ошибку сервера
Server несет четыре значения. Названия на платформах немного различаются:
| Значение | Android (Cause.Server) | iOS (.server) | Flutter (RecoverableError) |
|---|---|---|---|
| HTTP-статус последнего ответа | status | status | status |
Ожидание, которое назвал провайдер; пусто, если читаемого Retry-After не было | retryAfterMillis | retryAfterMillis | retryAfter (Duration) |
| Неудавшиеся запросы вызова, который поднял ошибку | attempts | attempts | attempts |
| Сколько времени потратила операция, с задержками и без времени пользователя в браузере | elapsedMillis | elapsedMillis | elapsed (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: 30 | 30 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-AfterSDK паузу не вводит, и цикл повторов выше него — дело вашего приложения. - Пауза и бюджет продолжают отсчет, пока устройство спит, потому что
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 повторять без предела.
Что дальше
- Состояния сессии и ошибки: четыре категории ошибок и бюджет повторов.
- Руководства по провайдерам: какие провайдеры присылают
Retry-After, а какие только свои заголовки лимитов. - Android, iOS и Flutter: настройка платформ, которую предполагают эти примеры.