Из-за временных ограничений на территории РФ наблюдаются проблемы с оплатой. Если платёж не проходит, оставьте запрос в службу поддержки.Служба поддержки работает 24/7 — мы всегда на связи по вопросам хостинга и серверов.Открыт прием заявок на аренду выделенных серверов и размещение оборудования в дата-центре.Напоминаем: рекомендуем включить резервное копирование для дополнительной защиты данных.Доступна новая линейка VPS/VDS с NVMe-дисками и увеличенной производительностью.Технические работы на части серверов завершены. Все сервисы работают в штатном режиме.
Статья8 мин чтенияПросмотры0

OAuth-токен истёк у восьми запросов: как выполнить одно обновление

Разбираем single-flight для OAuth: один запрос обновляет access token и refresh token, остальные ждут результат, а ошибка token endpoint не портит сохранённую пару.

Комментарии 0

Металлические жетоны проходят через один шлюз, который выпускает обновлённый токен
В этой статье

Если восемь параллельных запросов одновременно увидели истёкший OAuth access token, к token endpoint должен пойти один запрос обновления. Остальные ждут его результат, затем повторно проверяют общую пару токенов и используют уже новый access token. Для одного процесса это даёт связка «быстрая проверка → блокировка → повторная проверка → обновление состояния после успешного ответа».

Ниже — безопасная учебная модель для одного процесса на CPython 3.12.14. Она не обращается к сети, не пишет секреты в файлы и не изображает контракт конкретного провайдера или модуля 1С-Битрикс. В рабочей интеграции параметры запроса, срок жизни, способ хранения и правила ротации нужно брать из документации авторизационного сервера.

Откуда берётся шторм обновления

Представим восемь задач, которые почти одновременно отправляют запросы к внешнему API. Все читают одну и ту же пару A1/R1, замечают истёкший A1 и решают обновить токен. Без координации восемь задач отправят восемь запросов с одним R1.

В OAuth refresh token используется клиентом для получения нового access token. Авторизационный сервер может выдать вместе с новым access token и новый refresh token; тогда клиент должен заменить старый refresh token новым. Это поведение описано в разделе 6 RFC 6749.

Почему ротация refresh token повышает цену гонки

При ротации предыдущий refresh token инвалидируется после успешного обновления. RFC 9700 рекомендует для публичных клиентов либо привязанные к отправителю refresh token, либо ротацию с обнаружением повторного использования. Два параллельных refresh — уже не просто лишняя нагрузка: один запрос может успеть заменить R1 на R2, а следующий получить ошибку повторного использования и спровоцировать отзыв активной цепочки.

Компонент

Правило учебной модели

Что предотвращает

Запас по сроку

Обновлять за 5 секунд до expires_at

Пограничные запросы со старым A1

Блокировка

В один момент обновляет одна задача

Параллельное использование R1

Повторная проверка

После получения mutex задача снова читает expires_at

Последовательную очередь лишних refresh

Замена состояния

Сохранять A2/R2 только после полного успешного ответа

Пару A2/R1 или потерю рабочего R1

Ошибка

Оставлять текущую пару без изменений

Частичную запись при сбое token endpoint

Single-flight: mutex и повторная проверка

Тут есть подвох: использование mutex само по себе ещё не гарантирует один refresh. Если каждая ожидавшая задача без повторной проверки сразу обновит токен, восемь параллельных refresh превратятся в восемь последовательных. Поэтому условие срока действия проверяется дважды: до ожидания и внутри критической секции.

Схема single-flight: восемь запросов проходят через одну блокировку, выполняется одно обновление и восемь задач получают токен A2
Учебная модель: восемь задач используют один результат A2; только одна задача обращается к token endpoint.

В примере используется asyncio.Lock. Документация Python описывает его как средство исключительного доступа для задач asyncio и отдельно предупреждает: этот объект не предназначен для синхронизации потоков ОС. Тем более он не координирует разные процессы или серверы.

Воспроизводимая модель

Команда ниже создаёт весь стенд в памяти и запускает три проверки. Начальное состояние — истёкший A1 и refresh token R1. Восемь задач одновременно запрашивают токен; затем имитируется отказ token endpoint и повтор после отказа. Все значения синтетические.

python3 - <<'PY' import asyncio from dataclasses import dataclass @dataclass class Tokens: access_token: str refresh_token: str expires_at: int class FakeIssuer: def __init__(self): self.calls = 0 self.used_refresh_tokens = set() self.fail_next = False async def refresh(self, refresh_token: str, now: int) -> Tokens: await asyncio.sleep(0) self.calls += 1 if self.fail_next: self.fail_next = False raise RuntimeError("token_endpoint_unavailable") if refresh_token in self.used_refresh_tokens: raise RuntimeError("refresh_token_reused") self.used_refresh_tokens.add(refresh_token) n = self.calls + 1 return Tokens(f"A{n}", f"R{n}", now + 60) class TokenManager: def __init__(self, tokens: Tokens, issuer: FakeIssuer): self.tokens = tokens self.issuer = issuer self.lock = asyncio.Lock() async def get_access_token(self, now: int) -> str: if self.tokens.expires_at > now + 5: return self.tokens.access_token async with self.lock: if self.tokens.expires_at > now + 5: return self.tokens.access_token updated = await self.issuer.refresh(self.tokens.refresh_token, now) self.tokens = updated return updated.access_token async def main(): issuer = FakeIssuer() manager = TokenManager(Tokens("A1", "R1", 0), issuer) tokens = await asyncio.gather( *(manager.get_access_token(now=100) for _ in range(8)) ) assert tokens == ["A2"] * 8 assert issuer.calls == 1 assert manager.tokens == Tokens("A2", "R2", 160) print("parallel_8: one refresh, all received A2") manager.tokens.expires_at = 100 issuer.fail_next = True try: await manager.get_access_token(now=100) except RuntimeError as error: assert str(error) == "token_endpoint_unavailable" else: raise AssertionError("failure was not propagated") assert manager.tokens.refresh_token == "R2" print("failed_refresh: state kept R2") token = await manager.get_access_token(now=100) assert token == "A4" assert manager.tokens == Tokens("A4", "R4", 160) print("retry_after_failure: received A4 with R4") asyncio.run(main()) PY

parallel_8: one refresh, all received A2 failed_refresh: state kept R2 retry_after_failure: received A4 with R4

Проверка

Ожидаемый результат

Наблюдение

Восемь параллельных задач

Один refresh; всем A2

one refresh, all received A2

Сбой token endpoint

Сохранить R2

state kept R2

Повтор после сбоя

Получить новую пару

received A4 with R4

Сценарий завершился с кодом 0. Он подтверждает только координацию восьми задач в одном процессе, повторную проверку под блокировкой и отсутствие частичной записи при синтетической ошибке. Реальный token endpoint, сетевые тайм-ауты, несколько процессов, база данных и конкретный провайдер не тестировались.

Что происходит при сбое token endpoint

Метод refresh() сначала формирует новый объект Tokens и только затем присваивает его общему состоянию. Если запрос завершается исключением, старый объект остаётся без изменений. Это важно для ротации: нельзя успеть записать A2, но оставить R1, или затереть R2 пустым значением из неполного ответа.

  • Не записывать новую пару до проверки обязательных полей ответа.

  • Не очищать рабочий refresh token из-за сетевого тайм-аута: сервер мог принять запрос, но ответ потерялся.

  • Не запускать бесконечные повторы; после неоднозначного результата действовать по контракту провайдера.

  • Не выводить access token и refresh token в журнал; достаточно результата, времени, идентификатора подключения и безопасного кода ошибки.

Как встроить механизм в клиент API

  1. Хранить access token, refresh token и expires_at как одну версионируемую запись.

  2. Сначала читать запись без блокировки; если access token ещё годен с запасом, сразу возвращать его.

  3. Если пора обновлять, входить в критическую секцию для конкретного подключения.

  4. Внутри секции снова читать актуальную запись: другая задача уже могла выполнить refresh.

  5. Отправлять один запрос к token endpoint и атомарно заменять всю пару только после валидации ответа.

  6. После выхода из секции отдавать ожидавшим задачам новый access token; ошибку передавать по контролируемой политике повторов.

Ответ 401 от ресурсного API не стоит превращать в безусловный refresh для каждого запроса. Сначала проверьте, действительно ли access token истёк и разрешает ли контракт безопасный повтор исходной операции. Для изменения заказа повтор может быть опаснее, чем краткая ошибка авторизации.

Граница одного процесса

asyncio.Lock решает задачу только внутри одного event loop. Если интеграция работает в нескольких процессах или на нескольких серверах, нужен общий координатор: например, блокировка по идентификатору подключения плюс сравнение версии записи перед заменой. Конкретную технологию выбирают вместе с хранилищем; локальный lock нельзя выдавать за распределённую гарантию.

Для распределённого варианта полезно хранить номер версии. Процесс читает версию 12, получает новую пару и пытается заменить запись только при условии, что версия всё ещё 12. Если уже записана версия 13, ответ устарел и не должен перезаписывать более новое состояние. Это проектный ориентир, а не испытанный в этой статье стенд.

Критерий готовности

  • При одновременном истечении токена token endpoint получает один refresh для одного подключения.

  • Все ожидавшие задачи получают одну и ту же новую пару.

  • После успешной ротации старый refresh token больше не используется.

  • Сбой до успешного ответа не меняет сохранённую пару.

  • Несколько процессов координируются общим механизмом, а не локальным asyncio.Lock.

  • Журналы содержат результат и идентификаторы, но не значения токенов.

Один успешно обновлённый токен проверяет только счастливый путь. Приёмка начинается там, где одновременно приходят восемь запросов, token endpoint один раз падает, а сохранённая пара остаётся согласованной.

Обсуждение 0

Делись опытом и задавай вопросы. Комментарии без ссылок появляются после проверки редактором.

Пока никто не написал. Начни обсуждение.