Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 6 additions & 0 deletions .changeset/server-side-caching.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
---
"@cloudflare/flagship": minor
"@cloudflare/flagship-python": minor
---

Add opt-in server-side response caching to the server providers. Set `cacheTtl` (TypeScript) or `cache_ttl` (Python) to enable a TTL + LRU cache keyed by flag key, type, and evaluation context. Cache hits resolve with reason `CACHED`; disabled flags and errors are never cached. Caching is off by default.
2 changes: 1 addition & 1 deletion .github/workflows/bonk.yml
Original file line number Diff line number Diff line change
Expand Up @@ -31,5 +31,5 @@ jobs:
CLOUDFLARE_API_TOKEN: ${{ secrets.CF_AI_GATEWAY_TOKEN }}
with:
model: 'cloudflare-ai-gateway/anthropic/claude-opus-4-6'
opencode_version: "1.17.7"
opencode_version: '1.17.7'
mentions: '/bonk,@ask-bonk'
16 changes: 8 additions & 8 deletions .github/workflows/pull-request.yml
Original file line number Diff line number Diff line change
Expand Up @@ -25,7 +25,7 @@ jobs:
fetch-depth: 0
- uses: pnpm/action-setup@v6
with:
version: "11"
version: '11'
- uses: actions/setup-node@v6
with:
node-version: 24
Expand All @@ -42,7 +42,7 @@ jobs:
- uses: actions/checkout@v6
- uses: pnpm/action-setup@v6
with:
version: "11"
version: '11'
- uses: actions/setup-node@v6
with:
node-version: 24
Expand All @@ -57,12 +57,12 @@ jobs:
- uses: actions/checkout@v6
- uses: pnpm/action-setup@v6
with:
version: "11"
version: '11'
- uses: actions/setup-node@v6
with:
node-version: 24
- run: pnpm install --frozen-lockfile
- run: pnpm dlx sherif
- run: pnpm run sherif
- run: pnpm run lint

typescript-typecheck:
Expand All @@ -73,7 +73,7 @@ jobs:
- uses: actions/checkout@v6
- uses: pnpm/action-setup@v6
with:
version: "11"
version: '11'
- uses: actions/setup-node@v6
with:
node-version: 24
Expand All @@ -88,7 +88,7 @@ jobs:
- uses: actions/checkout@v6
- uses: pnpm/action-setup@v6
with:
version: "11"
version: '11'
- uses: actions/setup-node@v6
with:
node-version: 24
Expand All @@ -103,7 +103,7 @@ jobs:
- uses: actions/checkout@v6
- uses: pnpm/action-setup@v6
with:
version: "11"
version: '11'
- uses: actions/setup-node@v6
with:
node-version: 24
Expand All @@ -119,7 +119,7 @@ jobs:
- uses: actions/checkout@v6
- uses: pnpm/action-setup@v6
with:
version: "11"
version: '11'
- uses: actions/setup-node@v6
with:
node-version: 24
Expand Down
2 changes: 1 addition & 1 deletion .github/workflows/release.yml
Original file line number Diff line number Diff line change
Expand Up @@ -41,7 +41,7 @@ jobs:

- uses: pnpm/action-setup@v6
with:
version: "11"
version: '11'

- uses: actions/setup-node@v6
with:
Expand Down
4 changes: 3 additions & 1 deletion package.json
Original file line number Diff line number Diff line change
Expand Up @@ -15,10 +15,12 @@
"build": "pnpm -r run build",
"changeset:validate": "tsx .github/changeset-version.ts validate",
"format": "oxfmt --write .",
"format:write": "oxfmt --write .",
"format:check": "oxfmt --check .",
"sherif": "sherif -r root-package-manager-field",
"lint": "oxlint .",
"typecheck": "tsc --noEmit && pnpm -r run typecheck",
"check": "sherif && oxfmt --check . && oxlint . && pnpm run typecheck",
"check": "sherif -r root-package-manager-field && oxfmt --check . && oxlint . && pnpm run typecheck",
"test": "pnpm -r run test",
"prepare": "husky"
},
Expand Down
10 changes: 10 additions & 0 deletions pnpm-lock.yaml

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

23 changes: 23 additions & 0 deletions sdks/python/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -93,6 +93,10 @@ FlagshipServerProvider(
retries=1, # retry attempts on transient errors, capped at 10 (default: 1)
retry_delay=1.0, # seconds between retries, capped at 30.0 (default: 1.0)
logging=False, # set True to enable SDK debug output (default: False)

# Response caching — opt-in, off by default (see "Caching")
# cache_ttl=30.0, # seconds; enables caching when set
# cache_max_size=1000, # max cached entries, LRU-evicted (default: 1000)
)
```

Expand All @@ -108,6 +112,25 @@ FlagshipServerProvider(
| `retries` | `int` | `1` | Retry attempts on transient errors; capped at `10` |
| `retry_delay` | `float` | `1.0` | Delay between retries in seconds; capped at `30.0` |
| `logging` | `bool` | `False` | Enable SDK-level debug output via the `flagship` logger |
| `cache_ttl` | `float` | — | Cache TTL in seconds; enables caching when set |
| `cache_max_size` | `int` | `1000` | Maximum cached entries; least-recently-used is evicted |

## Caching

The provider can cache evaluations to avoid a network round-trip for repeated flag/context pairs. Caching is **off by default** and enabled by setting `cache_ttl` (seconds):

```python
FlagshipServerProvider(
app_id="your-app-id",
account_id="your-account-id",
cache_ttl=30.0, # cached values may be up to 30s stale
cache_max_size=1000, # LRU eviction beyond this many entries
)
```

Each entry is keyed by flag key, type, and the **full evaluation context**, so distinct contexts never share a value. Cache hits resolve with `reason == Reason.CACHED`. Disabled flags and errors are never cached. Because freshness is TTL-based, a flag change in Flagship takes effect after the entry expires.

The cache is shared by the sync and async APIs and guarded by a lock for thread-safe sync use.

## Evaluation context

Expand Down
1 change: 1 addition & 0 deletions sdks/python/examples/async_server.py
Original file line number Diff line number Diff line change
Expand Up @@ -25,6 +25,7 @@ async def main() -> None:
auth_token="your-token",
timeout=5.0,
retries=1,
cache_ttl=30.0, # cache evaluations per context for 30s (off by default)
)
)

Expand Down
1 change: 1 addition & 0 deletions sdks/python/examples/server.py
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,7 @@ def main() -> None:
timeout=5.0,
retries=1,
logging=True,
cache_ttl=30.0, # cache evaluations per context for 30s (off by default)
)
)

Expand Down
2 changes: 1 addition & 1 deletion sdks/python/pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -10,7 +10,7 @@ readme = "README.md"
license = "Apache-2.0"
license-files = ["LICENSE"]
requires-python = ">=3.10"
dependencies = ["httpx>=0.28.1", "openfeature-sdk>=0.9.0"]
dependencies = ["cachetools>=5.0.0", "httpx>=0.28.1", "openfeature-sdk>=0.9.0"]
classifiers = [
"Development Status :: 4 - Beta",
"Intended Audience :: Developers",
Expand Down
61 changes: 60 additions & 1 deletion sdks/python/src/flagship/provider.py
Original file line number Diff line number Diff line change
@@ -1,7 +1,10 @@
import logging
from collections.abc import Callable, Mapping, Sequence
import threading
from collections.abc import Callable, Hashable, Mapping, Sequence
from dataclasses import replace
from typing import Any

from cachetools import TTLCache
from openfeature.evaluation_context import EvaluationContext
from openfeature.exception import (
GeneralError,
Expand All @@ -17,6 +20,7 @@
from openfeature.provider import AbstractProvider, Metadata

from .client import FLAGSHIP_DEFAULT_BASE_URL, FlagshipClient
from .context import context_to_query_params

__all__ = ["FlagshipServerProvider"]

Expand Down Expand Up @@ -47,6 +51,10 @@ class FlagshipServerProvider(AbstractProvider):

Set ``logging=True`` to enable SDK-level debug output. When ``False``
(the default) the SDK produces no log output of its own.

Set ``cache_ttl`` (seconds) to enable an opt-in TTL + LRU response cache,
keyed by flag key, type, and evaluation context. Caching is disabled by
default; cached values may be up to ``cache_ttl`` stale.
"""

def __init__(
Expand All @@ -62,6 +70,8 @@ def __init__(
retries: int = 1,
retry_delay: float = 1.0,
logging: bool = False,
cache_ttl: float | None = None,
cache_max_size: int = 1000,
) -> None:
self._client = FlagshipClient(
app_id=app_id,
Expand All @@ -75,6 +85,10 @@ def __init__(
retry_delay=retry_delay,
)
self._logging = logging
self._cache: TTLCache[Hashable, FlagResolutionDetails[Any]] | None = (
Comment thread
akshitsinha marked this conversation as resolved.
TTLCache(maxsize=cache_max_size, ttl=cache_ttl) if cache_ttl is not None and cache_ttl > 0 else None
)
self._cache_lock = threading.Lock()

def get_metadata(self) -> Metadata:
return Metadata(name="Flagship Server Provider")
Expand All @@ -83,9 +97,11 @@ def get_provider_hooks(self) -> list[Hook]:
return []

def shutdown(self) -> None:
self._clear_cache()
self._client.close()

async def shutdown_async(self) -> None:
self._clear_cache()
await self._client.aclose()

def resolve_boolean_details(
Expand Down Expand Up @@ -176,6 +192,11 @@ def _resolve(
evaluation_context: EvaluationContext | None,
) -> FlagResolutionDetails[Any]:
self._log_debug("[Flagship] Evaluating flag %r", flag_key)
key = self._cache_key(flag_type, flag_key, evaluation_context)
if key is not None:
cached = self._cache_get(key)
if cached is not None:
return cached
result = self._client.evaluate(flag_key, evaluation_context)
details = _build_details(flag_type, default_value, result)
self._log_debug(
Expand All @@ -185,6 +206,8 @@ def _resolve(
details.reason,
details.variant,
)
if key is not None and result.reason != "DISABLED":
self._cache_store(key, details)
return details

async def _resolve_async(
Expand All @@ -195,6 +218,11 @@ async def _resolve_async(
evaluation_context: EvaluationContext | None,
) -> FlagResolutionDetails[Any]:
self._log_debug("[Flagship] Evaluating flag %r", flag_key)
key = self._cache_key(flag_type, flag_key, evaluation_context)
if key is not None:
cached = self._cache_get(key)
if cached is not None:
return cached
result = await self._client.evaluate_async(flag_key, evaluation_context)
details = _build_details(flag_type, default_value, result)
self._log_debug(
Expand All @@ -204,8 +232,39 @@ async def _resolve_async(
details.reason,
details.variant,
)
if key is not None and result.reason != "DISABLED":
self._cache_store(key, details)
return details

def _cache_key(
self,
flag_type: FlagType,
flag_key: str,
evaluation_context: EvaluationContext | None,
) -> Hashable | None:
if self._cache is None:
return None
params = context_to_query_params(evaluation_context)
return (flag_key, flag_type, frozenset(params.items()))

def _cache_get(self, key: Hashable) -> FlagResolutionDetails[Any] | None:
assert self._cache is not None
with self._cache_lock:
cached = self._cache.get(key)
if cached is None:
return None
return replace(cached, reason=Reason.CACHED)

def _cache_store(self, key: Hashable, details: FlagResolutionDetails[Any]) -> None:
assert self._cache is not None
with self._cache_lock:
self._cache[key] = details

def _clear_cache(self) -> None:
if self._cache is not None:
with self._cache_lock:
self._cache.clear()

def _log_debug(self, msg: str, *args: Any) -> None:
if self._logging:
_logger.debug(msg, *args)
Expand Down
Loading
Loading