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:
-
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.
-
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.
Current design
TokenCache(packages/dexpace-sdk-core/src/dexpace/sdk/core/http/auth/token_cache.py:21) is a sync-onlyProtocolexposing exactly three operations:There is no way to remove a single entry. The only granular write is
set, and the only removal isclear(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.sendandAsyncBearerTokenPolicy.send(policies.py:171andpolicies.py:342), a 401 triggers:where
_expired_token()returnsAccessTokenInfo(token="", expires_on=0)(policies.py:482). Invalidation then works only indirectly: the next_authorizecall reads the sentinel back andneeds_refreshhappens to treatexpires_on=0as expired (access_token.py:84). Theon_challengedocstrings (policies.py:234,policies.py:401) have to spend a paragraph each explaining this sentinel to anyone who subclasses and inspects the cache.Separately,
AsyncBearerTokenPolicyaccepts and stores a plain syncTokenCache(policies.py:314,policies.py:323) and calls itsget/setsynchronously from inside the async_authorize(policies.py:430,policies.py:438). There is noAsyncTokenCache, even though the codebase deliberately maintains aTokenCredential/AsyncTokenCredentialsplit 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:
Invalidation is expressed as a write of a fake value. A cache contract that can only
setandclearforces "remove this entry" to be modelled as "overwrite this entry with something that looks expired." That is indirect, it leaks into the publicon_challengecontract (subclassers now have to know the sentinel exists), and it makes invalidation behave like a normalsetto any custom backend — which is the underlying reason a mis-keyedsetcan clobber a still-valid entry rather than being a clearly scoped delete.The async policy can only drive a sync cache. The
TokenCachedocstring (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 byAsyncBearerTokenPolicyhas nowhere to do async I/O —get/setare 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
delete(scopes, audience=None) -> Noneto theTokenCacheProtocol so invalidation is a first-class operation. The bearer policies callself._cache.delete(...)after a 401 instead of writing_expired_token(), and_expired_token()/ the sentinel paragraphs in theon_challengedocstrings go away.InMemoryTokenCachegains a trivial lock-guardeddel self._entries[...](tolerating a missing key).AsyncTokenCacheProtocol (async get/async set/async delete/async clear) mirroring the existingTokenCredential/AsyncTokenCredentialsplit, and haveAsyncBearerTokenPolicyaccept it so an out-of-process cache canawaitits I/O. The in-memory default can satisfy both, or ship anInMemoryAsyncTokenCachewrapper.Trade-offs of the proposal: a second Protocol and an extra method widen the auth SPI, and any existing custom
TokenCacheimplementation must adddelete. Pre-1.0 that is an acceptable break, anddeleteis a one-liner for the in-memory default. The async twin is additive — the sync policy keeps usingTokenCacheunchanged.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, andInMemoryTokenCache(the only shipped implementation) genuinely needs neitherdeletenor an async variant —clearplus 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.