feat(proxy): one locked get_or_create for lazy defaults; CacheBackend falls back - #274
Merged
Merged
Conversation
… falls back get_backend_or_fallback() and get_app_cache() each had their own double-checked lock, and get_state_manager() had none, so concurrent first requests could each build and register a StateManager. Add ProxyBase.get_or_create(factory) with a per-class threading.Lock and build all three on it. The lock is per class so the default CacheManager can create the fallback backend inside its own creation. get_cache_backend (CacheBackend) now uses get_backend_or_fallback(), so it no longer answers 500 until some @cache route has installed the fallback. get_state_manager still has no memory fallback. Closes #112
This was referenced Sep 27, 2026
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Summary
Closes #112.
Three dependencies each had their own "use the registered instance, else create a default" logic:
get_backend_or_fallback()(proxy.py)get_app_cache()(dependencies.py)get_state_manager()(state/dependencies.py)Without a lock, concurrent first requests could each build and register a
StateManager, and the lastset()won. Separately,CacheBackend(get_cache_backend) had no fallback at all. It answered 500 (BackendNotFoundError) until some@cacheroute had run and installed theMemoryBackendfallback.Changes
ProxyBase.get_or_create(factory):factoryunder the class's lock and registers the result. The factory runs at most once, even with concurrent first callers.threading.Lock, like the two it replaces, because these are sync FastAPI dependencies that run in worker threads.__init_subclass__. The defaultCacheManageris built insideCacheManagerProxy's lock and creates the backend fallback viaBackendProxy.get_or_create. A single shared non-reentrant lock would deadlock there.get_or_create:get_backend_or_fallback(),get_app_cache()andget_state_manager(). The two module-level locks are gone.get_cache_backend/CacheBackendnow returnget_backend_or_fallback(), the same backend@cacheandAppCacheuse.get_state_manager()still has no memory fallback. OAuth states must be readable by whichever worker handles the callback. With no backend it still raisesBackendNotFoundErrorand registers nothing, as STATE.md already documents.CacheBackendnow falls back;BackendProxy.get()still raises.Not breaking: a registered instance is returned exactly as before.
Tests
Scope:
uv run pytest -qwithout live servers (806 passed, 190 live-server tests skipped). Also pre-commit on all files and both strict docs builds.New and changed tests:
tests/test_proxybackend.py, run on a private test proxy:get_or_createreturns the registered instance without calling the factory;get_backend_or_fallback()finishes. It runs in a thread with a 5 s join, so a shared lock fails the test instead of hanging.tests/state/test_proxy.py:get_state_manager()calls share one instance, using a slowStateManagerto widen the race window;tests/test_dependencies.py: the old "raisesBackendNotFoundError" test became "CacheBackendfalls back to a registeredMemoryBackendshared withAppCache".Mutation checks (each fails only the tests listed):
get_or_createwithout the locktest_get_app_cache_concurrent_first_calls_share_one_backendget_cache_backendback toBackendProxy.get()CacheBackendfallback testget_state_managerback to the unlocked versionCHANGELOG
Changed
CacheBackendfalls back to aMemoryBackendlike@cacheandAppCache. It used to answer 500 (BackendNotFoundError) until some@cacheroute had registered the fallback. The three lazy defaults(
get_backend_or_fallback,get_app_cache,get_state_manager) now shareProxyBase.get_or_create(factory), which runs the factory at most onceunder a per-class lock;
get_state_managercould previously register twomanagers under concurrent first requests. States still have no memory
fallback. (#112)