Skip to content

TokenCache SPI: add a first-class delete and an AsyncTokenCache twin #57

@OmarAlJarrah

Description

@OmarAlJarrah

Current design

TokenCache (packages/dexpace-sdk-core/src/dexpace/sdk/core/http/auth/token_cache.py:21) is a sync-only Protocol exposing exactly three operations:

class TokenCache(Protocol):
    def get(self, scopes, audience=None) -> AccessTokenInfo | None: ...
    def set(self, scopes, token, audience=None) -> None: ...
    def clear(self) -> None: ...

There is no way to remove a single entry. The only granular write is set, and the only removal is clear (everything).

Because there is no per-entry removal, the bearer policy invalidates a rejected token by overwriting its key with a poisoned value. In both BearerTokenPolicy.send and AsyncBearerTokenPolicy.send (policies.py:171 and policies.py:342), a 401 triggers:

self._cache.set(self._scopes, _expired_token(), self._audience)

where _expired_token() returns AccessTokenInfo(token="", expires_on=0) (policies.py:482). Invalidation then works only indirectly: the next _authorize call reads the sentinel back and needs_refresh happens to treat expires_on=0 as expired (access_token.py:84). The on_challenge docstrings (policies.py:234, policies.py:401) have to spend a paragraph each explaining this sentinel to anyone who subclasses and inspects the cache.

Separately, AsyncBearerTokenPolicy accepts and stores a plain sync TokenCache (policies.py:314, policies.py:323) and calls its get/set synchronously from inside the async _authorize (policies.py:430, policies.py:438). There is no AsyncTokenCache, even though the codebase deliberately maintains a TokenCredential / AsyncTokenCredential split for the credential right next to it (credentials.py:15, credentials.py:33).

Trade-off / concern

Two issues fall out of the same SPI shape:

  1. Invalidation is expressed as a write of a fake value. A cache contract that can only set and clear forces "remove this entry" to be modelled as "overwrite this entry with something that looks expired." That is indirect, it leaks into the public on_challenge contract (subclassers now have to know the sentinel exists), and it makes invalidation behave like a normal set to any custom backend — which is the underlying reason a mis-keyed set can clobber a still-valid entry rather than being a clearly scoped delete.

  2. The async policy can only drive a sync cache. The TokenCache docstring (token_cache.py:24) explicitly invites out-of-process implementations: "Implementations may persist outside the process (file-backed, Redis, etc.)." But an out-of-process cache driven by AsyncBearerTokenPolicy has nowhere to do async I/O — get/set are sync, so a Redis or file backend must either block the event loop or run sync I/O under the hood. That undercuts the very extensibility the seam advertises, and it is inconsistent with the credential seam right beside it, which does ship an async twin.

Proposed direction

  • Add delete(scopes, audience=None) -> None to the TokenCache Protocol so invalidation is a first-class operation. The bearer policies call self._cache.delete(...) after a 401 instead of writing _expired_token(), and _expired_token() / the sentinel paragraphs in the on_challenge docstrings go away. InMemoryTokenCache gains a trivial lock-guarded del self._entries[...] (tolerating a missing key).
  • Introduce an AsyncTokenCache Protocol (async get / async set / async delete / async clear) mirroring the existing TokenCredential / AsyncTokenCredential split, and have AsyncBearerTokenPolicy accept it so an out-of-process cache can await its I/O. The in-memory default can satisfy both, or ship an InMemoryAsyncTokenCache wrapper.

Trade-offs of the proposal: a second Protocol and an extra method widen the auth SPI, and any existing custom TokenCache implementation must add delete. Pre-1.0 that is an acceptable break, and delete is a one-liner for the in-memory default. The async twin is additive — the sync policy keeps using TokenCache unchanged.

Acknowledging the current rationale

The sync-only single-Protocol shape keeps the auth surface minimal, which is consistent with the project's "Public API is narrow" convention in CLAUDE.md, and InMemoryTokenCache (the only shipped implementation) genuinely needs neither delete nor an async variant — clear plus the sentinel covers its in-process use. So for the default the current shape is adequate. It is still worth revisiting because the Protocol's own docstring positions it as a pluggable, possibly out-of-process seam, and the async credential split shows the codebase already accepts the cost of sync/async twins for exactly this kind of I/O-bearing SPI. A delete-less, sync-only cache is the one place in the auth package where the seam's advertised extensibility and the rest of the package's conventions diverge.

Metadata

Metadata

Assignees

No one assigned

    Labels

    enhancementNew feature or request

    Type

    No type
    No fields configured for issues without a type.

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions