Skip to content

AuthManager.ensure_logged_in has no lock: concurrent first calls each perform a login #7

Description

@dvejsada

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.

Activity

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

Labels

bugSomething isn't working

Projects

No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions