Skip to content

feat: auto-detect backend from environment variables#88

Open
27Bslash6 wants to merge 1 commit intomainfrom
feat/auto-detect-backend-from-env
Open

feat: auto-detect backend from environment variables#88
27Bslash6 wants to merge 1 commit intomainfrom
feat/auto-detect-backend-from-env

Conversation

@27Bslash6
Copy link
Copy Markdown
Contributor

@27Bslash6 27Bslash6 commented Apr 6, 2026

Summary

Closes #87

  • DefaultBackendProvider now auto-detects the cache backend from CACHEKIT_-prefixed environment variables instead of always defaulting to Redis
  • Raises ConfigurationError when multiple CACHEKIT_-prefixed backend vars are set concurrently (ambiguous config)
  • Non-prefixed REDIS_URL never conflicts — it's a 12-factor fallback that yields to any explicit CACHEKIT_ var

Priority chain: CACHEKIT_API_KEYCACHEKIT_REDIS_URLCACHEKIT_MEMCACHED_SERVERSCACHEKIT_FILE_CACHE_DIRREDIS_URL → None (L1-only)

Unchanged: explicit backend= parameter and set_default_backend() always take precedence over env-var detection.

Test plan

  • 15 new unit tests covering all detection paths, conflict errors, fallback, caching, and error messages
  • 1341 existing unit tests still pass
  • Ruff lint + format clean
  • basedpyright clean
  • Pre-commit hooks (including detect-secrets) pass

Summary by CodeRabbit

  • New Features

    • Environment-driven cache backend auto-detection (Redis, Memcached, File, CachekitIO) with lazy one-time resolution and cached result.
    • Async operations degrade to L1-only when no L2 backend is available.
  • Bug Fixes

    • Clear error reporting in sync mode when no backend is configured.
    • Detects and errors on ambiguous/multiple backend configurations.
  • Tests

    • Expanded tests for auto-detection, env-var mappings, fallbacks, reuse, and conflict errors.

@coderabbitai
Copy link
Copy Markdown

coderabbitai bot commented Apr 6, 2026

No actionable comments were generated in the recent review. 🎉

ℹ️ Recent review info
⚙️ Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Pro

Run ID: c981f44f-7c1b-4013-b5e6-492998d3342a

📥 Commits

Reviewing files that changed from the base of the PR and between b603be2 and 31fc1c1.

📒 Files selected for processing (4)
  • .secrets.baseline
  • src/cachekit/backends/provider.py
  • src/cachekit/decorators/wrapper.py
  • tests/unit/backends/test_provider.py
✅ Files skipped from review due to trivial changes (1)
  • .secrets.baseline
🚧 Files skipped from review as they are similar to previous changes (2)
  • src/cachekit/decorators/wrapper.py
  • tests/unit/backends/test_provider.py

📝 Walkthrough

Walkthrough

DefaultBackendProvider now auto-detects the L2 backend from environment variables (CACHEKIT_* or REDIS_URL), can return None for L1-only mode, raises ConfigurationError on ambiguous configs, and changes wrapper behavior to fail-fast for sync but degrade gracefully for async when no backend is available. .secrets.baseline updated.

Changes

Cohort / File(s) Summary
Secrets Baseline
\.secrets\.baseline
Added detect_secrets.filters.common.is_baseline_file filter for the baseline filename and a new unverified Secret Keyword baseline entry for src/cachekit/backends/provider.py:139. Updated generated_at timestamp.
Backend Provider Auto-Detection
src/cachekit/backends/provider.py
Refactored DefaultBackendProvider to perform lazy env-driven backend resolution via _resolve_provider(). Supports CachekitIO/Redis/Memcached/File selection, treats REDIS_URL as Redis fallback, caches resolution, raises ConfigurationError on multi-backend ambiguity, and adds _StaticBackendProvider plus per-backend creation helpers. get_backend() may return None (L1-only).
Wrapper Control Flow
src/cachekit/decorators/wrapper.py
Sync wrapper now raises BackendError(..., PERMANENT) when L1+L2 mode has no resolved backend. Async wrapper now returns early (executes original function) when backend is None, skipping L2.
Provider Tests
tests/unit/backends/test_provider.py
Reworked tests to assert lazy resolution state and added TestDefaultBackendProviderAutoDetect covering single-env detection (CachekitIO/Redis/Memcached/File), REDIS_URL fallback, L1-only (None) behavior, resolution caching, and conflict cases raising ConfigurationError. Added necessary imports and assertions.

Sequence Diagram(s)

sequenceDiagram
    participant Client as Client Code
    participant Provider as DefaultBackendProvider
    participant Env as Env Vars
    participant Resolver as _resolve_provider()
    participant Factory as Backend Factories

    Client->>Provider: get_backend()
    alt first call (_resolved = False)
        Provider->>Resolver: _resolve_provider()
        Resolver->>Env: check CACHEKIT_API_KEY
        alt CACHEKIT_API_KEY set
            Resolver->>Factory: create CachekitIOBackend
            Factory-->>Resolver: CachekitIO instance
        else check CACHEKIT_REDIS_URL / REDIS_URL
            Resolver->>Env: check CACHEKIT_REDIS_URL / REDIS_URL
            alt redis var set
                Resolver->>Factory: create RedisBackend
                Factory-->>Resolver: Redis instance
            else check CACHEKIT_MEMCACHED_SERVERS
                Resolver->>Env: check CACHEKIT_MEMCACHED_SERVERS
                alt memcached set
                    Resolver->>Factory: create MemcachedBackend
                    Factory-->>Resolver: Memcached instance
                else check CACHEKIT_FILE_CACHE_DIR
                    Resolver->>Env: check CACHEKIT_FILE_CACHE_DIR
                    alt file dir set
                        Resolver->>Factory: create FileBackend
                        Factory-->>Resolver: File instance
                    else no backend vars
                        Resolver-->>Provider: None
                    end
                end
            end
        end
        alt multiple conflicting vars
            Resolver-->>Provider: raise ConfigurationError
        end
        Provider->>Provider: cache result, set _resolved = True
    else cached (_resolved = True)
        Provider->>Provider: return cached backend or None
    end
    Provider-->>Client: backend instance or None
Loading
sequenceDiagram
    participant User as User Code
    participant Sync as sync_wrapper
    participant Async as async_wrapper
    participant L1 as L1 Cache
    participant L2 as L2 Backend / Provider

    User->>Sync: call decorated function
    Sync->>L2: resolve backend (get_backend)
    alt backend is None (L1-only)
        Sync->>Sync: raise BackendError(PERMANENT)
        Sync-->>User: error
    else backend resolved
        Sync->>L1: check L1
        alt L1 hit
            L1-->>User: return value
        else L1 miss
            Sync->>L2: query L2
            L2-->>Sync: value
            Sync->>L1: store
            Sync-->>User: return value
        end
    end

    User->>Async: call decorated coroutine
    Async->>L2: resolve backend (get_backend)
    alt backend is None (L1-only)
        Async->>L1: check L1
        alt L1 hit
            L1-->>User: return value
        else L1 miss
            Async->>User: execute original coroutine (skip L2)
            User-->>Async: result
            Async->>L1: store
            Async-->>User: return value
        end
    else backend resolved
        Async->>L1: check L1
        alt L1 hit
            L1-->>User: return value
        else L1 miss
            Async->>L2: query L2
            L2-->>Async: value
            Async->>L1: store
            Async-->>User: return value
        end
    end
Loading

Estimated code review effort

🎯 4 (Complex) | ⏱️ ~45 minutes

Poem

🐰 I sniffed the env vars, one by one,
CACHEKIT whispers, Redis hums a drum,
If rules collide I thump my paw,
Async skips, sync raises awe—hop hurrah! 🌿

🚥 Pre-merge checks | ✅ 5
✅ Passed checks (5 passed)
Check name Status Explanation
Title check ✅ Passed The title accurately and concisely summarizes the main change: auto-detecting backend from environment variables.
Description check ✅ Passed The description provides a clear summary, motivation, test plan coverage, and addresses the linked issue #87 objectives comprehensively.
Linked Issues check ✅ Passed All objectives from issue #87 are met: auto-detection priority chain implemented, ConfigurationError on ambiguous configs, explicit parameters take precedence, and comprehensive test coverage provided.
Out of Scope Changes check ✅ Passed All changes are scoped to the auto-detection feature: provider.py backend resolution logic, wrapper.py null-backend handling, baseline security file, and comprehensive tests.
Docstring Coverage ✅ Passed Docstring coverage is 82.76% which is sufficient. The required threshold is 80.00%.

✏️ Tip: You can configure your own custom pre-merge checks in the settings.

✨ Finishing Touches
📝 Generate docstrings
  • Create stacked PR
  • Commit on current branch
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Commit unit tests in branch feat/auto-detect-backend-from-env

Comment @coderabbitai help to get the list of available commands and usage tips.

@codecov
Copy link
Copy Markdown

codecov bot commented Apr 6, 2026

❌ 1 Tests Failed:

Tests completed Failed Passed Skipped
1341 1 1340 8
View the full list of 1 ❄️ flaky test(s)
tests/unit/test_l1_only_mode.py::TestL1OnlyModeBug::test_intent_presets_with_backend_none

Flake rate in main: 70.10% (Passed 29 times, Failed 68 times)

Stack Traces | 0.002s run time
self = <tests.unit.test_l1_only_mode.TestL1OnlyModeBug object at 0x7346b05dbb90>

    def test_intent_presets_with_backend_none(self):
        """
        Intent-based presets (@cache.minimal, @cache.production, etc.) should respect backend=None.
    
        This tests the edge case where backend=None is passed to intent presets like:
        - @cache.minimal(backend=None)
        - @cache.production(backend=None)
        - @cache.secure(master_key="...", backend=None)
        """
        from cachekit.decorators import cache
    
        with patch("cachekit.decorators.wrapper.get_backend_provider") as mock_provider:
            mock_provider.return_value.get_backend.side_effect = RuntimeError("Should not be called!")
    
            # Test @cache.minimal(backend=None)
            minimal_call_count = 0
    
            @cache.minimal(backend=None)
            def minimal_func() -> str:
                nonlocal minimal_call_count
                minimal_call_count += 1
                return "minimal"
    
            assert minimal_func() == "minimal"
            assert minimal_func() == "minimal"
            assert minimal_call_count == 1, f"@cache.minimal L1 miss - called {minimal_call_count} times"
    
            # Test @cache.production(backend=None)
            production_call_count = 0
    
            @cache.production(backend=None)
            def production_func() -> str:
                nonlocal production_call_count
                production_call_count += 1
                return "production"
    
            assert production_func() == "production"
            assert production_func() == "production"
            assert production_call_count == 1, f"@cache.production L1 miss - called {production_call_count} times"
    
            # Test @cache.secure(master_key="...", backend=None)
            secure_call_count = 0
    
>           @cache.secure(master_key="a" * 64, backend=None)
             ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^

tests/unit/test_l1_only_mode.py:234: 
_ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ 
.../cachekit/decorators/intent.py:180: in decorator
    return _apply_cache_logic(f, resolved_config, _l1_only_mode=_explicit_l1_only)  # type: ignore[return-value]
           ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
.../cachekit/decorators/intent.py:33: in _apply_cache_logic
    return create_cache_wrapper(func, config=decorator_config, _l1_only_mode=_l1_only_mode)
           ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
.../cachekit/decorators/wrapper.py:448: in create_cache_wrapper
    validate_encryption_config(encryption)
_ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ 

encryption = True

    def validate_encryption_config(encryption: bool = False) -> None:
        """Validate encryption configuration when encryption is enabled.
    
        Checks that CACHEKIT_MASTER_KEY is set via pydantic-settings when encryption=True.
    
        Args:
            encryption: Whether encryption is enabled. If False, no validation.
    
        Raises:
            ConfigurationError: If encryption config is invalid
    
        Security Warning:
            Environment variables are NOT secure key storage for production.
            Use secrets management systems (HashiCorp Vault, AWS Secrets Manager, etc.)
            for production deployments.
    
        Examples:
            No-op when encryption is disabled:
    
            >>> validate_encryption_config(encryption=False)  # Returns None, no error
    
            Validation requires CACHEKIT_MASTER_KEY when enabled (requires env var):
    
            >>> validate_encryption_config(encryption=True)  # doctest: +SKIP
        """
        # Only validate if encryption is explicitly enabled
        if not encryption:
            return
    
        # Get master key from pydantic-settings (handles env vars properly)
        from cachekit.config.singleton import get_settings
    
        settings = get_settings()
        master_key = settings.master_key.get_secret_value() if settings.master_key else None
    
        # Check if master_key is set
        if not master_key:
>           raise ConfigurationError(
                "CACHEKIT_MASTER_KEY environment variable required when encryption=True. "
                "Generate with: python -c 'import secrets; print(secrets.token_hex(32))'"
            )
E           cachekit.config.validation.ConfigurationError: CACHEKIT_MASTER_KEY environment variable required when encryption=True. Generate with: python -c 'import secrets; print(secrets.token_hex(32))'

.../cachekit/config/validation.py:70: ConfigurationError

To view more test analytics, go to the Test Analytics Dashboard
📋 Got 3 mins? Take this short survey to help us improve Test Analytics.

Copy link
Copy Markdown

@coderabbitai coderabbitai bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🧹 Nitpick comments (1)
src/cachekit/backends/provider.py (1)

149-164: Minor thread-safety consideration for concurrent initialization.

The lazy initialization pattern (if not self._resolved) is not thread-safe. Concurrent threads could race and both enter _resolve_provider(). Since _resolve_provider() is idempotent (reads env vars, creates provider), this is benign—at worst, duplicate providers are created momentarily.

If strict single-initialization is required in the future, consider adding a lock:

♻️ Optional: thread-safe initialization
+import threading
+
 class DefaultBackendProvider(BackendProviderInterface):
+    _init_lock = threading.Lock()
+
     def get_backend(self):
-        if not self._resolved:
-            self._provider = self._resolve_provider()
-            self._resolved = True
+        if not self._resolved:
+            with self._init_lock:
+                if not self._resolved:  # double-check
+                    self._provider = self._resolve_provider()
+                    self._resolved = True
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@src/cachekit/backends/provider.py` around lines 149 - 164, The lazy init in
get_backend() can race since it checks self._resolved and calls
_resolve_provider() without synchronization; to make initialization thread-safe,
add a lock (e.g., self._init_lock = threading.Lock()) and wrap the resolve
sequence in get_backend() with the lock so only one thread runs
self._resolve_provider() and sets self._provider/self._resolved, leaving other
threads to read the already-set self._provider; update any constructor (or class
init) to create the lock and ensure get_backend() uses it around the if not
self._resolved / self._resolve_provider() block.
🤖 Prompt for all review comments with AI agents
Verify each finding against the current code and only fix it if needed.

Nitpick comments:
In `@src/cachekit/backends/provider.py`:
- Around line 149-164: The lazy init in get_backend() can race since it checks
self._resolved and calls _resolve_provider() without synchronization; to make
initialization thread-safe, add a lock (e.g., self._init_lock =
threading.Lock()) and wrap the resolve sequence in get_backend() with the lock
so only one thread runs self._resolve_provider() and sets
self._provider/self._resolved, leaving other threads to read the already-set
self._provider; update any constructor (or class init) to create the lock and
ensure get_backend() uses it around the if not self._resolved /
self._resolve_provider() block.

ℹ️ Review info
⚙️ Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Pro

Run ID: 7641c536-c1e2-402b-a7a5-6a7313b97b12

📥 Commits

Reviewing files that changed from the base of the PR and between 575c047 and b603be2.

📒 Files selected for processing (4)
  • .secrets.baseline
  • src/cachekit/backends/provider.py
  • src/cachekit/decorators/wrapper.py
  • tests/unit/backends/test_provider.py

@27Bslash6 27Bslash6 force-pushed the feat/auto-detect-backend-from-env branch from b603be2 to 5dd48df Compare April 6, 2026 22:49
DefaultBackendProvider now resolves the cache backend from CACHEKIT_-prefixed
environment variables instead of always defaulting to Redis. Raises
ConfigurationError when multiple CACHEKIT_ backend vars are set concurrently.

Priority: CACHEKIT_API_KEY > CACHEKIT_REDIS_URL > CACHEKIT_MEMCACHED_SERVERS >
CACHEKIT_FILE_CACHE_DIR > REDIS_URL (12-factor fallback) > None (L1-only).

Non-prefixed REDIS_URL never conflicts with CACHEKIT_ vars.
@27Bslash6 27Bslash6 force-pushed the feat/auto-detect-backend-from-env branch from 5dd48df to 31fc1c1 Compare April 8, 2026 10:49
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.

feat: auto-detect backend from environment variables in DefaultBackendProvider

1 participant