Payments are experiencing issues due to temporary restrictions in Russia. If your payment does not go through, please submit a support request.Our support team is available 24/7 — we are always here to help with hosting and server issues.We are now accepting requests for dedicated server rental and colocation services in our data center.Reminder: we recommend enabling backups for additional data protection.A new VPS/VDS lineup with NVMe storage and improved performance is now available.Maintenance work on some servers has been completed. All services are operating normally.
Article8 min readViews0

OAuth Token Expired for Eight Requests: How to Perform a Single Update

We examine single-flight for OAuth: one request updates the access and refresh tokens, others wait for the result, and a token endpoint error does not corrupt the saved pair.

Comments 0

Metal tokens pass through a single gateway that releases the updated token
In this article

If eight parallel requests simultaneously encounter an expired OAuth access token, only one request should go to the token endpoint for renewal. The others wait for its result, then recheck the shared token pair and use the new access token. For a single process, this creates a sequence of fast check → lock → recheck → update state after successful response.

Below is a safe educational model for a single process on CPython 3.12.14. It does not access the network, does not write secrets to files, and does not represent the contract of a specific provider or 1C-Bitrix module. In a production integration, request parameters, lifetime, storage method, and rotation rules must be taken from the authorization server documentation.

Where the Update Storm Comes From

Imagine eight tasks that almost simultaneously send requests to an external API. All read the same A1/R1 pair, notice that A1 has expired, and decide to update the token. Without coordination, eight tasks will send eight requests with the same R1.

In OAuth, a client uses a refresh token to obtain a new access token. The authorization server may issue a new refresh token along with the new access token; in that case, the client must replace the old refresh token with the new one. This behavior is described in section 6 of RFC 6749.

Why Refresh Token Rotation Increases the Cost of a Race

With rotation, the previous refresh token is invalidated after a successful update. RFC 9700 recommends either sender-bound refresh tokens or rotation with reuse detection for public clients. Two parallel refreshes are no longer just extra load: one request may replace R1 with R2, while the next triggers a reuse error and provokes revocation of the active chain.

Component

Training model rule

What it prevents

Validity buffer

Update 5 seconds before expires_at

Edge requests with old A1

Blocking

One task updates at a time

Parallel use of R1

Re-validation

After acquiring the mutex, the task reads expires_at again

Sequential queue of unnecessary refreshes

State replacement

Save A2/R2 only after a complete successful response

A pair of A2/R1 or a lost working R1

Error

Leave the current pair unchanged

Partial write on token endpoint failure

Single-flight: mutex and double-check

There is a catch: using a mutex alone does not guarantee a single refresh. If every waiting task updates the token immediately without a double-check, eight parallel refreshes will become eight sequential ones. Therefore, the expiration condition is checked twice: before waiting and inside the critical section.

Single-flight diagram: eight requests pass through one lock, one update is performed, and eight tasks receive token A2
Toy model: eight tasks use a single result A2; only one task calls the token endpoint.

The example uses asyncio.Lock. Python documentation describes it as a mechanism for exclusive access in asyncio tasks and separately warns: this object is not intended for synchronizing OS threads. It certainly does not coordinate different processes or servers.

Reproducible model

The command below creates the entire environment in memory and runs three checks. The initial state is an expired A1 and refresh token R1. Eight tasks simultaneously request a token; then a token endpoint failure is simulated followed by a retry after failure. All values are synthetic.

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

Check

Expected result

Observation

Eight parallel tasks

One refresh; all A2

one refresh, all received A2

Token endpoint failure

Keep R2

state kept R2

Retry after failure

Get a new pair

received A4 with R4

The scenario completed with code 0. It confirms only the coordination of eight tasks in a single process, the double-check under the lock, and the absence of partial writes during a synthetic error. The real token endpoint, network timeouts, multiple processes, database, and specific provider were not tested.

What happens when the token endpoint fails

The refresh() method first constructs a new Tokens object and only then assigns it to the shared state. If the request terminates with an exception, the old object remains unchanged. This is critical for rotation: one must not succeed in writing A2 while leaving R1, nor overwrite R2 with an empty value from an incomplete response.

  • Do not write a new pair until the mandatory response fields are verified.

  • Do not clear the working refresh token due to a network timeout: the server may have accepted the request, but the response was lost.

  • Do not launch infinite retries; after an ambiguous result, act according to the provider contract.

  • Do not output the access token and refresh token to the log; the result, time, connection identifier, and a safe error code are sufficient.

How to embed the mechanism in an API client

  1. Store the access token, refresh token, and expires_at as a single versioned record.

  2. First, read the record without locking; if the access token is still valid with a margin, return it immediately.

  3. If an update is needed, enter the critical section for that specific connection.

  4. Inside the section, read the current record again: another task may have already performed the refresh.

  5. Send a single request to the token endpoint and atomically replace the entire pair only after validating the response.

  6. After exiting the section, provide the waiting tasks with the new access token; pass errors according to a controlled retry policy.

A 401 response from a resource API should not be converted into an unconditional refresh for every request. First, verify whether the access token has actually expired and whether the contract permits safely retrying the original operation. For order modifications, a retry can be more dangerous than a brief authorization error.

Boundary of a single process

asyncio.Lock solves the problem only within a single event loop. If the integration runs across multiple processes or servers, a shared coordinator is required: for example, locking by connection ID plus comparing the record version before replacement. The specific technology is chosen together with the storage; a local lock cannot be presented as a distributed guarantee.

For a distributed variant, it is useful to store the version number. The process reads version 12, receives a new pair, and attempts to replace the record only if the version is still 12. If version 13 has already been written, the response is stale and must not overwrite a newer state. This is a design guideline, not a tested setup in this article.

Readiness criteria

  • When a token expires simultaneously, the token endpoint receives one refresh request for one connection.

  • All waiting tasks receive the same new pair.

  • After a successful rotation, the old refresh token is no longer used.

  • A failure before a successful response does not change the saved pair.

  • Several processes are coordinated by a shared mechanism, not a local asyncio.Lock.

  • Logs contain the result and identifiers, but not token values.

A single successfully updated token validates only the happy path. Acceptance testing begins when eight requests arrive simultaneously, the token endpoint fails once, and the saved pair remains consistent.

Discussion 0

Share your experience and ask questions. Comments without links appear after editorial review.

No comments yet. Start the discussion.