Summary
AuthManager.ensure_logged_in() tests self._is_logged_in and calls login() without holding a lock. When several service calls run concurrently on a client that has not logged in yet, every one of them observes _is_logged_in == False and issues its own POST /services/frontend-service/login.
Where
rohlik_api/auth.py — AuthManager.ensure_logged_in
- reached from every service method, which all begin with
await self._ensure_logged_in() (rohlik_api/services/base.py)
Reproduction
RohlikAPI.get_data() is sequential so it never hits this, but any concurrent fan-out does:
async with RohlikAPI(username, password, auto_login=False) as client:
await asyncio.gather(
client.delivery.get_info(),
client.orders.get_next(),
client.cart.get_content(),
client.account.get_premium_profile(),
)
On a cold client that sends four logins instead of one. The window is exactly one login round-trip wide, so it reproduces reliably rather than occasionally.
Why it matters
For a long-lived single-account client this happens at most once per process, so it is nearly invisible. It gets worse the more clients you create:
- A server that builds a client per user pays this on every new user's first fan-out, and a fan-out is a natural opening call ("what's in my cart, when's my delivery?").
- Those logins all leave from one egress IP, which is the shape of traffic that attracts rate limiting.
The 401 path already gets this right
HttpClient._request guards its re-auth with _reauth_lock plus a _reauth_generation counter, so a burst of concurrent 401s produces exactly one re-login and the rest just retry. The cold-start path simply lacks the equivalent.
Suggested fix
A lock on AuthManager, held across the await:
def __init__(self, http_client, username, password):
...
self._login_lock = asyncio.Lock()
async def ensure_logged_in(self) -> None:
if self._is_logged_in:
return
async with self._login_lock:
# Re-check: the holder of the lock may have logged in while we queued.
if not self._is_logged_in:
await self.login()
login() already short-circuits on _is_logged_in, so the inner check is belt-and-braces — the part that matters is that the lock spans the await rather than just the flag test.
Found via
Working around it in dvejsada/rohlik-mcp#6, an MCP server that creates one RohlikAPI per user. It now performs the first login itself under a per-account lock rather than relying on ensure_logged_in, because tools that gather 8–10 concurrent service calls were firing that many logins per new account.
Summary
AuthManager.ensure_logged_in()testsself._is_logged_inand callslogin()without holding a lock. When several service calls run concurrently on a client that has not logged in yet, every one of them observes_is_logged_in == Falseand issues its ownPOST /services/frontend-service/login.Where
rohlik_api/auth.py—AuthManager.ensure_logged_inawait self._ensure_logged_in()(rohlik_api/services/base.py)Reproduction
RohlikAPI.get_data()is sequential so it never hits this, but any concurrent fan-out does:On a cold client that sends four logins instead of one. The window is exactly one login round-trip wide, so it reproduces reliably rather than occasionally.
Why it matters
For a long-lived single-account client this happens at most once per process, so it is nearly invisible. It gets worse the more clients you create:
The 401 path already gets this right
HttpClient._requestguards its re-auth with_reauth_lockplus a_reauth_generationcounter, so a burst of concurrent 401s produces exactly one re-login and the rest just retry. The cold-start path simply lacks the equivalent.Suggested fix
A lock on
AuthManager, held across the await:login()already short-circuits on_is_logged_in, so the inner check is belt-and-braces — the part that matters is that the lock spans the await rather than just the flag test.Found via
Working around it in dvejsada/rohlik-mcp#6, an MCP server that creates one
RohlikAPIper user. It now performs the first login itself under a per-account lock rather than relying onensure_logged_in, because tools that gather 8–10 concurrent service calls were firing that many logins per new account.