terp-cap-redis 0.1.0__tar.gz
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- terp_cap_redis-0.1.0/.gitignore +47 -0
- terp_cap_redis-0.1.0/PKG-INFO +15 -0
- terp_cap_redis-0.1.0/escape-hatch-budget.json +3 -0
- terp_cap_redis-0.1.0/pyproject.toml +37 -0
- terp_cap_redis-0.1.0/src/terp/capabilities/redis/__init__.py +94 -0
- terp_cap_redis-0.1.0/src/terp/capabilities/redis/oidc.py +113 -0
- terp_cap_redis-0.1.0/src/terp/capabilities/redis/py.typed +0 -0
- terp_cap_redis-0.1.0/src/terp/capabilities/redis/realtime.py +91 -0
- terp_cap_redis-0.1.0/src/terp/capabilities/redis/stores.py +321 -0
|
@@ -0,0 +1,47 @@
|
|
|
1
|
+
# Python
|
|
2
|
+
__pycache__/
|
|
3
|
+
*.py[cod]
|
|
4
|
+
*.egg-info/
|
|
5
|
+
.eggs/
|
|
6
|
+
build/
|
|
7
|
+
dist/
|
|
8
|
+
.venv/
|
|
9
|
+
.venv-*/
|
|
10
|
+
venv/
|
|
11
|
+
.pytest_cache/
|
|
12
|
+
.mypy_cache/
|
|
13
|
+
.ruff_cache/
|
|
14
|
+
.coverage
|
|
15
|
+
htmlcov/
|
|
16
|
+
|
|
17
|
+
# uv
|
|
18
|
+
uv.lock
|
|
19
|
+
|
|
20
|
+
# Node
|
|
21
|
+
node_modules/
|
|
22
|
+
.pnpm-store/
|
|
23
|
+
*.tsbuildinfo
|
|
24
|
+
|
|
25
|
+
# Playwright (conformance e2e) artifacts
|
|
26
|
+
test-results/
|
|
27
|
+
playwright-report/
|
|
28
|
+
blob-report/
|
|
29
|
+
playwright/.cache/
|
|
30
|
+
.last-run.json
|
|
31
|
+
|
|
32
|
+
# Local frontend template render checks
|
|
33
|
+
apps/example/_frontend_tpl_check/
|
|
34
|
+
|
|
35
|
+
# Editor / OS
|
|
36
|
+
.DS_Store
|
|
37
|
+
.idea/
|
|
38
|
+
*.local
|
|
39
|
+
|
|
40
|
+
# Local environment overrides — never commit (a real .env may hold SECRET_KEY).
|
|
41
|
+
# The tracked template is `.env.example`.
|
|
42
|
+
.env
|
|
43
|
+
.env.*
|
|
44
|
+
!.env.example
|
|
45
|
+
!.env.example.jinja
|
|
46
|
+
# Rendered app-declared variables (environment.schema.json) — may hold secrets.
|
|
47
|
+
.app.env
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: terp-cap-redis
|
|
3
|
+
Version: 0.1.0
|
|
4
|
+
Summary: Terp Redis store adapters — shared idempotency, throttling, and cache state for multi-replica deployments.
|
|
5
|
+
License-Expression: Apache-2.0
|
|
6
|
+
Requires-Python: >=3.13
|
|
7
|
+
Requires-Dist: redis>=5
|
|
8
|
+
Requires-Dist: terp-core==0.1.0
|
|
9
|
+
Provides-Extra: all
|
|
10
|
+
Requires-Dist: terp-cap-oidc==0.1.0; extra == 'all'
|
|
11
|
+
Requires-Dist: terp-cap-realtime==0.1.0; extra == 'all'
|
|
12
|
+
Provides-Extra: oidc
|
|
13
|
+
Requires-Dist: terp-cap-oidc==0.1.0; extra == 'oidc'
|
|
14
|
+
Provides-Extra: realtime
|
|
15
|
+
Requires-Dist: terp-cap-realtime==0.1.0; extra == 'realtime'
|
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
[build-system]
|
|
2
|
+
requires = ["hatchling"]
|
|
3
|
+
build-backend = "hatchling.build"
|
|
4
|
+
|
|
5
|
+
[project]
|
|
6
|
+
name = "terp-cap-redis"
|
|
7
|
+
version = "0.1.0"
|
|
8
|
+
description = "Terp Redis store adapters — shared idempotency, throttling, and cache state for multi-replica deployments."
|
|
9
|
+
requires-python = ">=3.13"
|
|
10
|
+
license = "Apache-2.0"
|
|
11
|
+
dependencies = [
|
|
12
|
+
"terp-core==0.1.0",
|
|
13
|
+
"redis>=5",
|
|
14
|
+
]
|
|
15
|
+
|
|
16
|
+
# Capability-facing adapters are OPT-IN extras, so the base distribution never installs
|
|
17
|
+
# another capability: shared throttling/idempotency/cache must not drag in a
|
|
18
|
+
# self-registering transport (realtime) or an SSO stack (oidc).
|
|
19
|
+
[project.optional-dependencies]
|
|
20
|
+
realtime = ["terp-cap-realtime==0.1.0"]
|
|
21
|
+
oidc = ["terp-cap-oidc==0.1.0"]
|
|
22
|
+
all = [
|
|
23
|
+
"terp-cap-realtime==0.1.0",
|
|
24
|
+
"terp-cap-oidc==0.1.0",
|
|
25
|
+
]
|
|
26
|
+
|
|
27
|
+
# A LIBRARY (store-adapter) capability: it provides Redis-backed implementations of
|
|
28
|
+
# Terp's per-process store seams — IdempotencyStore, ThrottleStore, and CacheStore —
|
|
29
|
+
# and declares NO `terp.capabilities` ModuleSpec entry point (nothing is auto-mounted)
|
|
30
|
+
# and NO `terp.migrations` entry point (Redis owns no SQL tables). A composition root
|
|
31
|
+
# opts in by wiring create_app(..., idempotency_store=..., throttle_store=...,
|
|
32
|
+
# cache_store=..., require_shared_*=settings.is_production).
|
|
33
|
+
|
|
34
|
+
# PEP 420 namespace package: this distribution owns only `terp.capabilities.redis`.
|
|
35
|
+
[tool.hatch.build.targets.wheel]
|
|
36
|
+
sources = ["src"]
|
|
37
|
+
only-include = ["src/terp/capabilities/redis"]
|
|
@@ -0,0 +1,94 @@
|
|
|
1
|
+
"""terp.capabilities.redis — shared Redis-backed stores for horizontal deployments.
|
|
2
|
+
|
|
3
|
+
Terp's kernel deliberately keeps the idempotency, throttling, and cache ports engine-free:
|
|
4
|
+
``terp.core`` ships safe per-process defaults and boot guards, while a deployment that runs
|
|
5
|
+
more than one replica wires a shared backend explicitly. This capability is that opt-in
|
|
6
|
+
backend for Redis. It has no routes, models, or migrations; it is a library adapter a
|
|
7
|
+
composition root constructs from either an existing redis-py client or a Redis URL and then
|
|
8
|
+
passes to ``create_app``::
|
|
9
|
+
|
|
10
|
+
stores = RedisStoreBundle.from_url(settings.REDIS_URL)
|
|
11
|
+
create_app(
|
|
12
|
+
...,
|
|
13
|
+
idempotency_store=stores.idempotency,
|
|
14
|
+
throttle_store=stores.throttle,
|
|
15
|
+
cache_store=stores.cache,
|
|
16
|
+
require_shared_idempotency_store=settings.is_production,
|
|
17
|
+
require_shared_throttle_store=settings.is_production,
|
|
18
|
+
require_shared_cache_store=settings.is_production,
|
|
19
|
+
)
|
|
20
|
+
|
|
21
|
+
The adapters stamp themselves with the public ``mark_shared_*`` markers so the kernel's
|
|
22
|
+
multi-instance boot promises are checked fail-closed. Redis operations are intentionally
|
|
23
|
+
small and TTL-bound: idempotency claims and throttle counters use Lua scripts for atomicity;
|
|
24
|
+
cache values use Redis' native string TTLs.
|
|
25
|
+
|
|
26
|
+
Two capability-facing adapters live behind optional extras, so the base distribution
|
|
27
|
+
depends only on ``terp-core`` (shared throttling/idempotency never installs another
|
|
28
|
+
capability):
|
|
29
|
+
|
|
30
|
+
* ``terp-cap-redis[realtime]`` — :class:`RedisConnectionTicketStore`
|
|
31
|
+
(:mod:`terp.capabilities.redis.realtime`), shared one-use realtime connection tickets.
|
|
32
|
+
* ``terp-cap-redis[oidc]`` — :class:`RedisOIDCStateStore`
|
|
33
|
+
(:mod:`terp.capabilities.redis.oidc`), shared single-use OIDC authorization state for
|
|
34
|
+
multi-replica SSO.
|
|
35
|
+
* ``terp-cap-redis[all]`` — both capability-facing adapters.
|
|
36
|
+
|
|
37
|
+
Both re-export lazily from the package root: importing them without the matching extra
|
|
38
|
+
raises a directive ``ModuleNotFoundError`` naming the extra to install. They are omitted
|
|
39
|
+
from ``__all__`` so a wildcard import remains valid for a base-only installation.
|
|
40
|
+
"""
|
|
41
|
+
|
|
42
|
+
from __future__ import annotations
|
|
43
|
+
|
|
44
|
+
from typing import Any
|
|
45
|
+
|
|
46
|
+
from terp.capabilities.redis.stores import (
|
|
47
|
+
RedisCacheStore,
|
|
48
|
+
RedisIdempotencyStore,
|
|
49
|
+
RedisStoreBundle,
|
|
50
|
+
RedisThrottleStore,
|
|
51
|
+
)
|
|
52
|
+
|
|
53
|
+
# The extras' adapters import their capability at module import time, so they resolve
|
|
54
|
+
# lazily here: the base install (no extras) can `import terp.capabilities.redis` freely.
|
|
55
|
+
_EXTRA_EXPORTS = {
|
|
56
|
+
"RedisConnectionTicketStore": (
|
|
57
|
+
"terp.capabilities.redis.realtime",
|
|
58
|
+
"terp.capabilities.realtime",
|
|
59
|
+
"realtime",
|
|
60
|
+
),
|
|
61
|
+
"RedisOIDCStateStore": (
|
|
62
|
+
"terp.capabilities.redis.oidc",
|
|
63
|
+
"terp.capabilities.oidc",
|
|
64
|
+
"oidc",
|
|
65
|
+
),
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
|
|
69
|
+
def __getattr__(name: str) -> Any:
|
|
70
|
+
extra = _EXTRA_EXPORTS.get(name)
|
|
71
|
+
if extra is None:
|
|
72
|
+
raise AttributeError(f"module {__name__!r} has no attribute {name!r}")
|
|
73
|
+
import importlib
|
|
74
|
+
|
|
75
|
+
module_name, required_module, extra_name = extra
|
|
76
|
+
try:
|
|
77
|
+
module = importlib.import_module(module_name)
|
|
78
|
+
except ModuleNotFoundError as exc:
|
|
79
|
+
if exc.name != required_module:
|
|
80
|
+
raise
|
|
81
|
+
raise ModuleNotFoundError(
|
|
82
|
+
f"{name} requires the optional `{extra_name}` adapter; "
|
|
83
|
+
f"install `terp-cap-redis[{extra_name}]` (or `terp-cap-redis[all]`).",
|
|
84
|
+
name=required_module,
|
|
85
|
+
) from exc
|
|
86
|
+
return getattr(module, name)
|
|
87
|
+
|
|
88
|
+
|
|
89
|
+
__all__ = [
|
|
90
|
+
"RedisCacheStore",
|
|
91
|
+
"RedisIdempotencyStore",
|
|
92
|
+
"RedisStoreBundle",
|
|
93
|
+
"RedisThrottleStore",
|
|
94
|
+
]
|
|
@@ -0,0 +1,113 @@
|
|
|
1
|
+
"""Redis-backed OIDC authorization state (the ``terp-cap-redis[oidc]`` extra).
|
|
2
|
+
|
|
3
|
+
The OIDC capability's default :class:`~terp.capabilities.oidc.InMemoryStateStore` is
|
|
4
|
+
per-process, so out of the box SSO needs one API replica or sticky routing. This store
|
|
5
|
+
implements the same :class:`~terp.capabilities.oidc.OIDCStateStore` port over Redis:
|
|
6
|
+
the ``/authorize`` and ``/callback`` halves of a flow may then land on different
|
|
7
|
+
replicas. ``consume`` keeps the port's guarantees — strictly single-use (an atomic
|
|
8
|
+
GET+DEL), expiring (a native Redis TTL bounds every pending flow), and
|
|
9
|
+
provider-matched (a state issued for one provider cannot finish another's callback).
|
|
10
|
+
|
|
11
|
+
Importing this submodule requires the ``oidc`` extra (``terp-cap-redis[oidc]``); the
|
|
12
|
+
shared kernel stores in :mod:`terp.capabilities.redis.stores` stay decoupled from it.
|
|
13
|
+
"""
|
|
14
|
+
|
|
15
|
+
from __future__ import annotations
|
|
16
|
+
|
|
17
|
+
import datetime
|
|
18
|
+
import json
|
|
19
|
+
import secrets
|
|
20
|
+
from typing import Any
|
|
21
|
+
|
|
22
|
+
from terp.capabilities.oidc import (
|
|
23
|
+
DEFAULT_STATE_TTL,
|
|
24
|
+
PendingAuthorization,
|
|
25
|
+
generate_code_verifier,
|
|
26
|
+
)
|
|
27
|
+
|
|
28
|
+
from terp.capabilities.redis.stores import _client_from_url, _text
|
|
29
|
+
|
|
30
|
+
_CONSUME_STATE_SCRIPT = """
|
|
31
|
+
local value = redis.call('GET', KEYS[1])
|
|
32
|
+
if not value then
|
|
33
|
+
return nil
|
|
34
|
+
end
|
|
35
|
+
redis.call('DEL', KEYS[1])
|
|
36
|
+
return value
|
|
37
|
+
"""
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
class RedisOIDCStateStore:
|
|
41
|
+
"""A shared, single-use OIDC state store for multi-replica deployments."""
|
|
42
|
+
|
|
43
|
+
def __init__(
|
|
44
|
+
self,
|
|
45
|
+
client: Any,
|
|
46
|
+
*,
|
|
47
|
+
namespace: str = "terp",
|
|
48
|
+
ttl: datetime.timedelta = DEFAULT_STATE_TTL,
|
|
49
|
+
) -> None:
|
|
50
|
+
ttl_seconds = int(ttl.total_seconds())
|
|
51
|
+
if ttl_seconds <= 0:
|
|
52
|
+
raise ValueError("RedisOIDCStateStore requires a positive ttl")
|
|
53
|
+
self._client = client
|
|
54
|
+
self._prefix = f"{namespace}:oidc-state:"
|
|
55
|
+
self._ttl = datetime.timedelta(seconds=ttl_seconds)
|
|
56
|
+
|
|
57
|
+
@classmethod
|
|
58
|
+
def from_url(
|
|
59
|
+
cls,
|
|
60
|
+
url: str,
|
|
61
|
+
*,
|
|
62
|
+
namespace: str = "terp",
|
|
63
|
+
ttl: datetime.timedelta = DEFAULT_STATE_TTL,
|
|
64
|
+
) -> RedisOIDCStateStore:
|
|
65
|
+
"""Create the store from a Redis URL using redis-py's ``Redis.from_url``."""
|
|
66
|
+
return cls(_client_from_url(url), namespace=namespace, ttl=ttl)
|
|
67
|
+
|
|
68
|
+
def issue(self, provider: str) -> tuple[str, PendingAuthorization]:
|
|
69
|
+
"""Open a new flow for *provider*: returns ``(state, pending)``."""
|
|
70
|
+
state = secrets.token_urlsafe(32)
|
|
71
|
+
pending = PendingAuthorization(
|
|
72
|
+
provider=provider,
|
|
73
|
+
nonce=secrets.token_urlsafe(32),
|
|
74
|
+
code_verifier=generate_code_verifier(),
|
|
75
|
+
expires_at=datetime.datetime.now(datetime.UTC) + self._ttl,
|
|
76
|
+
)
|
|
77
|
+
value = json.dumps(
|
|
78
|
+
{
|
|
79
|
+
"provider": pending.provider,
|
|
80
|
+
"nonce": pending.nonce,
|
|
81
|
+
"code_verifier": pending.code_verifier,
|
|
82
|
+
"expires_at": pending.expires_at.isoformat(),
|
|
83
|
+
},
|
|
84
|
+
separators=(",", ":"),
|
|
85
|
+
)
|
|
86
|
+
# The Redis TTL is the expiry: an abandoned flow ages out server-side, so the
|
|
87
|
+
# store cannot grow without bound and a replica never sees a stale state.
|
|
88
|
+
self._client.set(self._key(state), value, ex=int(self._ttl.total_seconds()))
|
|
89
|
+
return state, pending
|
|
90
|
+
|
|
91
|
+
def consume(self, state: str, provider: str) -> PendingAuthorization | None:
|
|
92
|
+
"""Redeem *state* exactly once, or ``None`` (unknown / expired / wrong provider)."""
|
|
93
|
+
raw = self._client.eval(_CONSUME_STATE_SCRIPT, 1, self._key(state))
|
|
94
|
+
if raw is None:
|
|
95
|
+
return None
|
|
96
|
+
payload = json.loads(_text(raw))
|
|
97
|
+
pending = PendingAuthorization(
|
|
98
|
+
provider=payload["provider"],
|
|
99
|
+
nonce=payload["nonce"],
|
|
100
|
+
code_verifier=payload["code_verifier"],
|
|
101
|
+
expires_at=datetime.datetime.fromisoformat(payload["expires_at"]),
|
|
102
|
+
)
|
|
103
|
+
if pending.provider != provider:
|
|
104
|
+
return None
|
|
105
|
+
if pending.expires_at <= datetime.datetime.now(datetime.UTC):
|
|
106
|
+
return None
|
|
107
|
+
return pending
|
|
108
|
+
|
|
109
|
+
def _key(self, state: str) -> str:
|
|
110
|
+
return f"{self._prefix}{state}"
|
|
111
|
+
|
|
112
|
+
|
|
113
|
+
__all__ = ["RedisOIDCStateStore"]
|
|
File without changes
|
|
@@ -0,0 +1,91 @@
|
|
|
1
|
+
"""Redis-backed realtime connection tickets (the ``terp-cap-redis[realtime]`` extra).
|
|
2
|
+
|
|
3
|
+
The realtime capability's one-use connection tickets are per-process by default; a
|
|
4
|
+
multi-replica deployment shares them here so the replica that serves the WebSocket
|
|
5
|
+
upgrade can consume a ticket another replica issued. This submodule is the only place
|
|
6
|
+
terp-cap-redis touches terp-cap-realtime: importing it requires the ``realtime`` extra
|
|
7
|
+
(``terp-cap-redis[realtime]``), so the shared kernel stores in
|
|
8
|
+
:mod:`terp.capabilities.redis.stores` never drag a self-registering transport
|
|
9
|
+
capability onto the path.
|
|
10
|
+
"""
|
|
11
|
+
|
|
12
|
+
from __future__ import annotations
|
|
13
|
+
|
|
14
|
+
import json
|
|
15
|
+
import uuid
|
|
16
|
+
from typing import Any
|
|
17
|
+
|
|
18
|
+
from terp.capabilities.realtime import ConnectionTicket, ConnectionTicketStore
|
|
19
|
+
from terp.core import Principal, Role
|
|
20
|
+
|
|
21
|
+
from terp.capabilities.redis.stores import _client_from_url, _text
|
|
22
|
+
|
|
23
|
+
_CONSUME_TICKET_SCRIPT = """
|
|
24
|
+
local value = redis.call('GET', KEYS[1])
|
|
25
|
+
if not value then
|
|
26
|
+
return nil
|
|
27
|
+
end
|
|
28
|
+
redis.call('DEL', KEYS[1])
|
|
29
|
+
return value
|
|
30
|
+
"""
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
class RedisConnectionTicketStore(ConnectionTicketStore):
|
|
34
|
+
"""Shared one-use realtime tickets with atomic GET+DEL consumption."""
|
|
35
|
+
|
|
36
|
+
def __init__(self, client: Any, *, namespace: str = "terp") -> None:
|
|
37
|
+
self._client = client
|
|
38
|
+
self._prefix = f"{namespace}:realtime-ticket:"
|
|
39
|
+
|
|
40
|
+
@classmethod
|
|
41
|
+
def from_url(
|
|
42
|
+
cls, url: str, *, namespace: str = "terp"
|
|
43
|
+
) -> RedisConnectionTicketStore:
|
|
44
|
+
return cls(_client_from_url(url), namespace=namespace)
|
|
45
|
+
|
|
46
|
+
def issue(self, ticket: ConnectionTicket, *, ttl_seconds: int) -> str:
|
|
47
|
+
if ttl_seconds <= 0:
|
|
48
|
+
raise ValueError(
|
|
49
|
+
"RedisConnectionTicketStore.issue requires a positive ttl_seconds"
|
|
50
|
+
)
|
|
51
|
+
token = uuid.uuid4().hex + uuid.uuid4().hex
|
|
52
|
+
value = json.dumps(
|
|
53
|
+
{
|
|
54
|
+
"principal_id": str(ticket.principal.id),
|
|
55
|
+
"role_name": ticket.principal.role.name,
|
|
56
|
+
"role_rank": ticket.principal.role.rank,
|
|
57
|
+
"channel": ticket.channel,
|
|
58
|
+
"transport": ticket.transport,
|
|
59
|
+
"credential": ticket.credential,
|
|
60
|
+
"audience": ticket.audience,
|
|
61
|
+
},
|
|
62
|
+
separators=(",", ":"),
|
|
63
|
+
)
|
|
64
|
+
self._client.set(self._key(token), value, ex=int(ttl_seconds))
|
|
65
|
+
return token
|
|
66
|
+
|
|
67
|
+
def consume(
|
|
68
|
+
self, token: str, *, channel: str, transport: str
|
|
69
|
+
) -> ConnectionTicket | None:
|
|
70
|
+
raw = self._client.eval(_CONSUME_TICKET_SCRIPT, 1, self._key(token))
|
|
71
|
+
if raw is None:
|
|
72
|
+
return None
|
|
73
|
+
payload = json.loads(_text(raw))
|
|
74
|
+
if payload["channel"] != channel or payload["transport"] != transport:
|
|
75
|
+
return None
|
|
76
|
+
return ConnectionTicket(
|
|
77
|
+
principal=Principal(
|
|
78
|
+
id=uuid.UUID(payload["principal_id"]),
|
|
79
|
+
role=Role(payload["role_name"], int(payload["role_rank"])),
|
|
80
|
+
),
|
|
81
|
+
channel=payload["channel"],
|
|
82
|
+
transport=payload["transport"],
|
|
83
|
+
credential=payload.get("credential", ""),
|
|
84
|
+
audience=payload.get("audience", ""),
|
|
85
|
+
)
|
|
86
|
+
|
|
87
|
+
def _key(self, token: str) -> str:
|
|
88
|
+
return f"{self._prefix}{token}"
|
|
89
|
+
|
|
90
|
+
|
|
91
|
+
__all__ = ["RedisConnectionTicketStore"]
|
|
@@ -0,0 +1,321 @@
|
|
|
1
|
+
"""Redis implementations of Terp's kernel store ports (idempotency, throttle, cache).
|
|
2
|
+
|
|
3
|
+
The three ports have deliberately narrow contracts, so the adapter keeps Redis usage narrow
|
|
4
|
+
as well: one namespaced key per logical entry, explicit TTLs on every value, and Lua only
|
|
5
|
+
where a multi-command transition must be indivisible. That gives horizontally scaled Terp
|
|
6
|
+
apps honest global behaviour without teaching the kernel about Redis or weakening the
|
|
7
|
+
single-process defaults.
|
|
8
|
+
"""
|
|
9
|
+
|
|
10
|
+
from __future__ import annotations
|
|
11
|
+
|
|
12
|
+
import base64
|
|
13
|
+
import importlib
|
|
14
|
+
import json
|
|
15
|
+
import uuid
|
|
16
|
+
from dataclasses import dataclass
|
|
17
|
+
from typing import Any
|
|
18
|
+
|
|
19
|
+
from terp.core import (
|
|
20
|
+
BeginOutcome,
|
|
21
|
+
CacheStore,
|
|
22
|
+
IdempotencyStore,
|
|
23
|
+
StoredResponse,
|
|
24
|
+
ThrottleStore,
|
|
25
|
+
mark_shared_cache_store,
|
|
26
|
+
mark_shared_idempotency_store,
|
|
27
|
+
mark_shared_throttle_store,
|
|
28
|
+
)
|
|
29
|
+
|
|
30
|
+
_BEGIN_SCRIPT = """
|
|
31
|
+
local fingerprint = redis.call('HGET', KEYS[1], 'fingerprint')
|
|
32
|
+
if not fingerprint then
|
|
33
|
+
redis.call('HSET', KEYS[1], 'fingerprint', ARGV[1], 'lease', ARGV[2], 'done', '0')
|
|
34
|
+
redis.call('EXPIRE', KEYS[1], tonumber(ARGV[3]))
|
|
35
|
+
return {'started', ARGV[2]}
|
|
36
|
+
end
|
|
37
|
+
if fingerprint ~= ARGV[1] then
|
|
38
|
+
return {'mismatch'}
|
|
39
|
+
end
|
|
40
|
+
if redis.call('HGET', KEYS[1], 'done') ~= '1' then
|
|
41
|
+
return {'in_flight'}
|
|
42
|
+
end
|
|
43
|
+
return {
|
|
44
|
+
'replay',
|
|
45
|
+
redis.call('HGET', KEYS[1], 'status'),
|
|
46
|
+
redis.call('HGET', KEYS[1], 'headers'),
|
|
47
|
+
redis.call('HGET', KEYS[1], 'body')
|
|
48
|
+
}
|
|
49
|
+
"""
|
|
50
|
+
|
|
51
|
+
_COMPLETE_SCRIPT = """
|
|
52
|
+
if redis.call('HGET', KEYS[1], 'lease') ~= ARGV[1] then
|
|
53
|
+
return 0
|
|
54
|
+
end
|
|
55
|
+
redis.call(
|
|
56
|
+
'HSET', KEYS[1],
|
|
57
|
+
'done', '1',
|
|
58
|
+
'status', ARGV[2],
|
|
59
|
+
'headers', ARGV[3],
|
|
60
|
+
'body', ARGV[4]
|
|
61
|
+
)
|
|
62
|
+
redis.call('EXPIRE', KEYS[1], tonumber(ARGV[5]))
|
|
63
|
+
return 1
|
|
64
|
+
"""
|
|
65
|
+
|
|
66
|
+
_RELEASE_SCRIPT = """
|
|
67
|
+
if redis.call('HGET', KEYS[1], 'lease') == ARGV[1] then
|
|
68
|
+
return redis.call('DEL', KEYS[1])
|
|
69
|
+
end
|
|
70
|
+
return 0
|
|
71
|
+
"""
|
|
72
|
+
|
|
73
|
+
_HIT_SCRIPT = """
|
|
74
|
+
local count = redis.call('INCR', KEYS[1])
|
|
75
|
+
if count == 1 then
|
|
76
|
+
redis.call('EXPIRE', KEYS[1], tonumber(ARGV[1]))
|
|
77
|
+
end
|
|
78
|
+
local ttl = redis.call('TTL', KEYS[1])
|
|
79
|
+
if ttl < 0 then
|
|
80
|
+
redis.call('EXPIRE', KEYS[1], tonumber(ARGV[1]))
|
|
81
|
+
ttl = tonumber(ARGV[1])
|
|
82
|
+
end
|
|
83
|
+
return {count, ttl}
|
|
84
|
+
"""
|
|
85
|
+
|
|
86
|
+
def _client_from_url(url: str) -> Any:
|
|
87
|
+
"""Construct a redis-py client lazily so importing the adapter stays lightweight."""
|
|
88
|
+
from redis import Redis # arch-allow-no-adhoc-background-runtime: this capability IS the Redis adapter for shared store seams — the one governed place the engine is imported
|
|
89
|
+
|
|
90
|
+
return Redis.from_url(url)
|
|
91
|
+
|
|
92
|
+
|
|
93
|
+
def _text(value: object) -> str:
|
|
94
|
+
if isinstance(value, bytes):
|
|
95
|
+
return value.decode("utf-8")
|
|
96
|
+
return str(value)
|
|
97
|
+
|
|
98
|
+
|
|
99
|
+
def _bytes(value: object) -> bytes:
|
|
100
|
+
if isinstance(value, bytes):
|
|
101
|
+
return value
|
|
102
|
+
return str(value).encode("utf-8")
|
|
103
|
+
|
|
104
|
+
|
|
105
|
+
class RedisIdempotencyStore(IdempotencyStore):
|
|
106
|
+
"""A Redis-backed :class:`~terp.core.IdempotencyStore` with atomic claims.
|
|
107
|
+
|
|
108
|
+
``begin`` is a single Lua transition: the first caller creates an in-flight hash with a
|
|
109
|
+
random lease and TTL; concurrent callers see ``in_flight``; completed hashes replay only
|
|
110
|
+
when the fingerprint matches; reuse under a different fingerprint is always a
|
|
111
|
+
``mismatch``. ``complete`` and ``release`` are also Lua lease checks, so a slow request
|
|
112
|
+
whose claim expired cannot overwrite or delete a newer claim.
|
|
113
|
+
"""
|
|
114
|
+
|
|
115
|
+
def __init__(self, client: Any, *, namespace: str = "terp") -> None:
|
|
116
|
+
self._client = client
|
|
117
|
+
self._prefix = f"{namespace}:idempotency:"
|
|
118
|
+
mark_shared_idempotency_store(self)
|
|
119
|
+
|
|
120
|
+
@classmethod
|
|
121
|
+
def from_url(cls, url: str, *, namespace: str = "terp") -> RedisIdempotencyStore:
|
|
122
|
+
"""Create the store from a Redis URL using redis-py's ``Redis.from_url``."""
|
|
123
|
+
return cls(_client_from_url(url), namespace=namespace)
|
|
124
|
+
|
|
125
|
+
def begin(self, key: str, fingerprint: str, *, ttl_seconds: int) -> BeginOutcome:
|
|
126
|
+
if ttl_seconds <= 0:
|
|
127
|
+
raise ValueError(
|
|
128
|
+
f"RedisIdempotencyStore.begin requires a positive ttl_seconds, got {ttl_seconds!r}"
|
|
129
|
+
)
|
|
130
|
+
lease = uuid.uuid4().hex
|
|
131
|
+
result = self._client.eval(
|
|
132
|
+
_BEGIN_SCRIPT,
|
|
133
|
+
1,
|
|
134
|
+
self._key(key),
|
|
135
|
+
fingerprint,
|
|
136
|
+
lease,
|
|
137
|
+
int(ttl_seconds),
|
|
138
|
+
)
|
|
139
|
+
parts = list(result)
|
|
140
|
+
state = _text(parts[0])
|
|
141
|
+
if state == "started":
|
|
142
|
+
return BeginOutcome(state="started", lease=_text(parts[1]))
|
|
143
|
+
if state == "in_flight":
|
|
144
|
+
return BeginOutcome(state="in_flight")
|
|
145
|
+
if state == "mismatch":
|
|
146
|
+
return BeginOutcome(state="mismatch")
|
|
147
|
+
headers = tuple((str(name), str(value)) for name, value in json.loads(_text(parts[2])))
|
|
148
|
+
body = base64.b64decode(_bytes(parts[3]))
|
|
149
|
+
response = StoredResponse(status_code=int(_text(parts[1])), headers=headers, body=body)
|
|
150
|
+
return BeginOutcome(state="replay", response=response)
|
|
151
|
+
|
|
152
|
+
def complete(
|
|
153
|
+
self, key: str, lease: str, response: StoredResponse, *, ttl_seconds: int
|
|
154
|
+
) -> None:
|
|
155
|
+
if ttl_seconds <= 0:
|
|
156
|
+
raise ValueError(
|
|
157
|
+
f"RedisIdempotencyStore.complete requires a positive ttl_seconds, got {ttl_seconds!r}"
|
|
158
|
+
)
|
|
159
|
+
headers = json.dumps(list(response.headers), separators=(",", ":"))
|
|
160
|
+
self._client.eval(
|
|
161
|
+
_COMPLETE_SCRIPT,
|
|
162
|
+
1,
|
|
163
|
+
self._key(key),
|
|
164
|
+
lease,
|
|
165
|
+
str(response.status_code),
|
|
166
|
+
headers,
|
|
167
|
+
base64.b64encode(response.body).decode("ascii"),
|
|
168
|
+
int(ttl_seconds),
|
|
169
|
+
)
|
|
170
|
+
|
|
171
|
+
def release(self, key: str, lease: str) -> None:
|
|
172
|
+
self._client.eval(_RELEASE_SCRIPT, 1, self._key(key), lease)
|
|
173
|
+
|
|
174
|
+
def _key(self, key: str) -> str:
|
|
175
|
+
return f"{self._prefix}{key}"
|
|
176
|
+
|
|
177
|
+
|
|
178
|
+
class RedisThrottleStore(ThrottleStore):
|
|
179
|
+
"""A Redis fixed-window counter and lockout store.
|
|
180
|
+
|
|
181
|
+
Hit counters are namespaced strings whose first increment sets the window TTL in the
|
|
182
|
+
same Lua script. Locks are separate TTL strings, so clearing a key removes both the
|
|
183
|
+
counter and the lock while callers retain the port's simple ``hit`` / ``lock`` /
|
|
184
|
+
``locked`` contract.
|
|
185
|
+
"""
|
|
186
|
+
|
|
187
|
+
def __init__(self, client: Any, *, namespace: str = "terp") -> None:
|
|
188
|
+
self._client = client
|
|
189
|
+
self._hits_prefix = f"{namespace}:throttle:h:"
|
|
190
|
+
self._locks_prefix = f"{namespace}:throttle:l:"
|
|
191
|
+
mark_shared_throttle_store(self)
|
|
192
|
+
|
|
193
|
+
@classmethod
|
|
194
|
+
def from_url(cls, url: str, *, namespace: str = "terp") -> RedisThrottleStore:
|
|
195
|
+
"""Create the store from a Redis URL using redis-py's ``Redis.from_url``."""
|
|
196
|
+
return cls(_client_from_url(url), namespace=namespace)
|
|
197
|
+
|
|
198
|
+
def hit(self, key: str, window_seconds: int) -> tuple[int, int]:
|
|
199
|
+
result = self._client.eval(_HIT_SCRIPT, 1, self._hit_key(key), int(window_seconds))
|
|
200
|
+
count, reset = list(result)
|
|
201
|
+
return int(count), max(1, int(reset))
|
|
202
|
+
|
|
203
|
+
def lock(self, key: str, seconds: int) -> None:
|
|
204
|
+
self._client.set(self._lock_key(key), "1", ex=int(seconds))
|
|
205
|
+
|
|
206
|
+
def locked(self, key: str) -> int:
|
|
207
|
+
ttl = int(self._client.ttl(self._lock_key(key)))
|
|
208
|
+
if ttl <= 0:
|
|
209
|
+
return 0
|
|
210
|
+
return ttl
|
|
211
|
+
|
|
212
|
+
def clear(self, key: str) -> None:
|
|
213
|
+
self._client.delete(self._hit_key(key), self._lock_key(key))
|
|
214
|
+
|
|
215
|
+
def _hit_key(self, key: str) -> str:
|
|
216
|
+
return f"{self._hits_prefix}{key}"
|
|
217
|
+
|
|
218
|
+
def _lock_key(self, key: str) -> str:
|
|
219
|
+
return f"{self._locks_prefix}{key}"
|
|
220
|
+
|
|
221
|
+
|
|
222
|
+
class RedisCacheStore(CacheStore):
|
|
223
|
+
"""A Redis string cache with one TTL-bound key per Terp cache entry."""
|
|
224
|
+
|
|
225
|
+
def __init__(self, client: Any, *, namespace: str = "terp") -> None:
|
|
226
|
+
self._client = client
|
|
227
|
+
self._prefix = f"{namespace}:cache:"
|
|
228
|
+
mark_shared_cache_store(self)
|
|
229
|
+
|
|
230
|
+
@classmethod
|
|
231
|
+
def from_url(cls, url: str, *, namespace: str = "terp") -> RedisCacheStore:
|
|
232
|
+
"""Create the store from a Redis URL using redis-py's ``Redis.from_url``."""
|
|
233
|
+
return cls(_client_from_url(url), namespace=namespace)
|
|
234
|
+
|
|
235
|
+
def get(self, key: str) -> str | None:
|
|
236
|
+
value = self._client.get(self._key(key))
|
|
237
|
+
if value is None:
|
|
238
|
+
return None
|
|
239
|
+
return _text(value)
|
|
240
|
+
|
|
241
|
+
def set(self, key: str, value: str, *, ttl_seconds: int) -> None:
|
|
242
|
+
if ttl_seconds <= 0:
|
|
243
|
+
raise ValueError(
|
|
244
|
+
f"RedisCacheStore.set requires a positive ttl_seconds, got {ttl_seconds!r}"
|
|
245
|
+
)
|
|
246
|
+
self._client.set(self._key(key), value, ex=int(ttl_seconds))
|
|
247
|
+
|
|
248
|
+
def delete(self, key: str) -> None:
|
|
249
|
+
self._client.delete(self._key(key))
|
|
250
|
+
|
|
251
|
+
def _key(self, key: str) -> str:
|
|
252
|
+
return f"{self._prefix}{key}"
|
|
253
|
+
|
|
254
|
+
|
|
255
|
+
@dataclass(frozen=True)
|
|
256
|
+
class RedisStoreBundle:
|
|
257
|
+
"""Convenience holder for Redis-backed platform store adapters.
|
|
258
|
+
|
|
259
|
+
Use this when one Redis deployment backs all three Terp store seams. Separate classes
|
|
260
|
+
remain available for deployments that split cache and control-state Redis clusters.
|
|
261
|
+
Capability-facing adapters (realtime connection tickets, OIDC authorization state)
|
|
262
|
+
live in their own submodules behind optional extras. For compatibility, the bundle
|
|
263
|
+
includes ``realtime_tickets`` when the ``realtime`` extra is installed; it is ``None``
|
|
264
|
+
in a base-only installation.
|
|
265
|
+
"""
|
|
266
|
+
|
|
267
|
+
idempotency: RedisIdempotencyStore
|
|
268
|
+
throttle: RedisThrottleStore
|
|
269
|
+
cache: RedisCacheStore
|
|
270
|
+
realtime_tickets: Any | None = None
|
|
271
|
+
|
|
272
|
+
@classmethod
|
|
273
|
+
def from_client(cls, client: Any, *, namespace: str = "terp") -> RedisStoreBundle:
|
|
274
|
+
"""Build all three adapters over an already-configured redis-py compatible client."""
|
|
275
|
+
return cls(
|
|
276
|
+
idempotency=RedisIdempotencyStore(client, namespace=namespace),
|
|
277
|
+
throttle=RedisThrottleStore(client, namespace=namespace),
|
|
278
|
+
cache=RedisCacheStore(client, namespace=namespace),
|
|
279
|
+
realtime_tickets=_optional_realtime_store(client, namespace=namespace),
|
|
280
|
+
)
|
|
281
|
+
|
|
282
|
+
@classmethod
|
|
283
|
+
def from_url(cls, url: str, *, namespace: str = "terp") -> RedisStoreBundle:
|
|
284
|
+
"""Build all three adapters over one Redis client constructed from *url*."""
|
|
285
|
+
return cls.from_client(_client_from_url(url), namespace=namespace)
|
|
286
|
+
|
|
287
|
+
|
|
288
|
+
def _optional_realtime_store(client: Any, *, namespace: str) -> Any | None:
|
|
289
|
+
"""Build the compatibility bundle member only when the realtime extra is installed."""
|
|
290
|
+
try:
|
|
291
|
+
module = importlib.import_module("terp.capabilities.redis.realtime")
|
|
292
|
+
except ModuleNotFoundError as exc:
|
|
293
|
+
if exc.name == "terp.capabilities.realtime":
|
|
294
|
+
return None
|
|
295
|
+
raise
|
|
296
|
+
return module.RedisConnectionTicketStore(client, namespace=namespace)
|
|
297
|
+
|
|
298
|
+
|
|
299
|
+
def __getattr__(name: str) -> Any:
|
|
300
|
+
"""Preserve the former direct import path when the realtime extra is installed."""
|
|
301
|
+
if name != "RedisConnectionTicketStore":
|
|
302
|
+
raise AttributeError(f"module {__name__!r} has no attribute {name!r}")
|
|
303
|
+
try:
|
|
304
|
+
module = importlib.import_module("terp.capabilities.redis.realtime")
|
|
305
|
+
except ModuleNotFoundError as exc:
|
|
306
|
+
if exc.name != "terp.capabilities.realtime":
|
|
307
|
+
raise
|
|
308
|
+
raise ModuleNotFoundError(
|
|
309
|
+
"RedisConnectionTicketStore requires the optional `realtime` adapter; "
|
|
310
|
+
"install `terp-cap-redis[realtime]` (or `terp-cap-redis[all]`).",
|
|
311
|
+
name="terp.capabilities.realtime",
|
|
312
|
+
) from exc
|
|
313
|
+
return module.RedisConnectionTicketStore
|
|
314
|
+
|
|
315
|
+
|
|
316
|
+
__all__ = [
|
|
317
|
+
"RedisCacheStore",
|
|
318
|
+
"RedisIdempotencyStore",
|
|
319
|
+
"RedisStoreBundle",
|
|
320
|
+
"RedisThrottleStore",
|
|
321
|
+
]
|