Skip to content

fix: replace the generated client on refresh instead of mutating its token - #36

Merged
mad4ms merged 1 commit into
mainfrom
fix/token-refresh-rebuilds-client
Sep 12, 2026
Merged

mad4ms merged 1 commit into
mainfrom
fix/token-refresh-rebuilds-client

Conversation

@mad4ms

@mad4ms mad4ms commented Sep 12, 2026

Copy link
Copy Markdown
Owner

The bug

A refreshed token never reaches the wire.

AuthenticatedClient writes the Authorization header into an httpx.Client the first time it is used, and caches that client:

def get_httpx_client(self) -> httpx.Client:
    if self._client is None:                       # first use only
        self._headers[self.auth_header_name] = f"{self.prefix} {self.token}"
        self._client = httpx.Client(headers=self._headers, ...)
    return self._client

_authenticate() and _ensure_client() then assigned client.token, which updates the attribute and nothing else. Every request after the first token expiry carried the expired token.

The failure mode is severe and quiet: the API answers 403 with an explicit-deny message, a caller's retry mints another token and is rejected identically, and only a process restart recovers.

This was found the hard way. A downstream live runtime lost sixteen hours of fixture discovery to it — 1442 rejected calls in one day — while a one-off script using the very same credentials on the same host worked fine. The journal shows the mechanism exactly:

20:45:55  Refreshing access token      → requests still OK (the old token is still valid)
20:48:57  GET → 403 → refresh → GET → 403      ← the original token expired; permanent from here

The change

Token changes now discard the generated client so the next call rebuilds it with the new header, and the superseded transport is closed instead of leaked. close() releases it too, which it previously never did — only session was closed.

_ensure_client() loses its else: self.client.token = token branch: with every refresh going through _discard_client(), a surviving client always carries the current token.

Tests

test/test_token_refresh.py asserts the invariant without touching HTTP, per the repo rule against mocking the transport: the constructor performs no network I/O, and a pre-set expiry keeps _ensure_client() from authenticating, so a token swap can be driven directly. The first test fails on main with an AttributeError where it expects a rebuilt client.

  • pytest: 12 passed, 7 skipped (the live-API tests, no credentials present)
  • ruff check, ruff format --check, mypy --strict: clean

Note for the release

Downstream pins >=0.2.0 and currently resolves 0.2.0, which carries the same bug. A release would let consumers drop their local workarounds.

🤖 Generated with Claude Code

…token

A refreshed token never reached the wire. `AuthenticatedClient` writes the
Authorization header into an `httpx.Client` the first time it is used and
caches that client; `_authenticate()` and `_ensure_client()` then assigned
`client.token`, which updates the attribute and nothing else. Every request
after the first token expiry carried the expired token.

The symptom is severe and silent: the API answers 403 with an explicit-deny
message, a caller's retry mints another token and is rejected the same way,
and only a process restart recovers. A downstream live runtime lost sixteen
hours of fixture discovery to this — 1442 rejected calls in a day — while a
one-off script with the same credentials worked.

Token changes now discard the client so the next call rebuilds it, and the
superseded transport is closed rather than leaked. `close()` releases it too,
which it previously never did.

The new tests assert the invariant without touching HTTP: the constructor
performs no network I/O, and a pre-set expiry keeps `_ensure_client()` from
authenticating, so a token swap can be driven directly.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
@mad4ms
mad4ms merged commit 3e2239a into main Sep 12, 2026
5 checks passed
mad4ms added a commit that referenced this pull request Sep 12, 2026
Patch release carrying the token-refresh fix from #36. Consumers on 0.3.0 and
0.2.0 lose every DataCore call with a 403 once their first token expires, and
recover only by restarting the process, so this wants to reach PyPI promptly.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant