svc-infra 0.1.595__py3-none-any.whl → 0.1.596__py3-none-any.whl
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.
Potentially problematic release.
This version of svc-infra might be problematic. Click here for more details.
- svc_infra/api/fastapi/apf_payments/router.py +60 -50
- svc_infra/api/fastapi/auth/routers/oauth_router.py +2 -7
- svc_infra/api/fastapi/middleware/idempotency.py +73 -43
- svc_infra/api/fastapi/middleware/idempotency_store.py +187 -0
- svc_infra/api/fastapi/middleware/optimistic_lock.py +37 -0
- svc_infra/db/inbox.py +55 -0
- svc_infra/db/outbox.py +96 -0
- svc_infra/db/sql/versioning.py +14 -0
- {svc_infra-0.1.595.dist-info → svc_infra-0.1.596.dist-info}/METADATA +1 -1
- {svc_infra-0.1.595.dist-info → svc_infra-0.1.596.dist-info}/RECORD +12 -7
- {svc_infra-0.1.595.dist-info → svc_infra-0.1.596.dist-info}/WHEEL +0 -0
- {svc_infra-0.1.595.dist-info → svc_infra-0.1.596.dist-info}/entry_points.txt +0 -0
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
from __future__ import annotations
|
|
2
2
|
|
|
3
3
|
import inspect
|
|
4
|
-
from typing import
|
|
4
|
+
from typing import Literal, Optional, cast
|
|
5
5
|
|
|
6
6
|
from fastapi import Body, Depends, Header, HTTPException, Request, Response, status
|
|
7
7
|
from starlette.responses import JSONResponse
|
|
@@ -70,70 +70,80 @@ def _tx_kind(kind: str) -> Literal["payment", "refund", "fee", "payout", "captur
|
|
|
70
70
|
return cast(Literal["payment", "refund", "fee", "payout", "capture"], kind)
|
|
71
71
|
|
|
72
72
|
|
|
73
|
-
# ---
|
|
74
|
-
|
|
75
|
-
[Request, Optional[Principal], Optional[str]],
|
|
76
|
-
Awaitable[Optional[str]] | Optional[str],
|
|
77
|
-
]
|
|
78
|
-
|
|
79
|
-
_tenant_override_hook: TenantOverrideHook | None = None
|
|
73
|
+
# --- tenant resolution ---
|
|
74
|
+
_tenant_resolver: None | (callable) = None
|
|
80
75
|
|
|
81
76
|
|
|
82
|
-
def set_payments_tenant_resolver(
|
|
83
|
-
"""
|
|
77
|
+
def set_payments_tenant_resolver(fn):
|
|
78
|
+
"""Set or clear an override hook for payments tenant resolution.
|
|
84
79
|
|
|
85
|
-
|
|
86
|
-
|
|
80
|
+
fn(request: Request, identity: Principal | None, header: str | None) -> str | None
|
|
81
|
+
Return a tenant_id to override, or None to defer to default flow.
|
|
87
82
|
"""
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
_tenant_override_hook = resolver
|
|
83
|
+
global _tenant_resolver
|
|
84
|
+
_tenant_resolver = fn
|
|
91
85
|
|
|
92
86
|
|
|
93
87
|
async def resolve_payments_tenant_id(
|
|
94
88
|
request: Request,
|
|
95
|
-
identity:
|
|
96
|
-
tenant_header:
|
|
89
|
+
identity: Principal | None = None,
|
|
90
|
+
tenant_header: str | None = None,
|
|
97
91
|
) -> str:
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
if identity:
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
user_tenant = getattr(getattr(identity, "user", None), "tenant_id", None)
|
|
119
|
-
if user_tenant:
|
|
120
|
-
return user_tenant
|
|
121
|
-
|
|
92
|
+
# 1) Override hook
|
|
93
|
+
if _tenant_resolver is not None:
|
|
94
|
+
val = _tenant_resolver(request, identity, tenant_header)
|
|
95
|
+
# Support async or sync resolver
|
|
96
|
+
if inspect.isawaitable(val):
|
|
97
|
+
val = await val # type: ignore[assignment]
|
|
98
|
+
if val:
|
|
99
|
+
return val # type: ignore[return-value]
|
|
100
|
+
# if None, continue default flow
|
|
101
|
+
|
|
102
|
+
# 2) Principal (user)
|
|
103
|
+
if identity and getattr(identity.user or object(), "tenant_id", None):
|
|
104
|
+
return getattr(identity.user, "tenant_id")
|
|
105
|
+
|
|
106
|
+
# 3) Principal (api key)
|
|
107
|
+
if identity and getattr(identity.api_key or object(), "tenant_id", None):
|
|
108
|
+
return getattr(identity.api_key, "tenant_id")
|
|
109
|
+
|
|
110
|
+
# 4) Explicit header argument (tests pass this)
|
|
122
111
|
if tenant_header:
|
|
123
112
|
return tenant_header
|
|
124
113
|
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
raise HTTPException(status.HTTP_400_BAD_REQUEST, detail="tenant_context_missing")
|
|
114
|
+
# 5) Request state
|
|
115
|
+
state_tid = getattr(getattr(request, "state", object()), "tenant_id", None)
|
|
116
|
+
if state_tid:
|
|
117
|
+
return state_tid
|
|
130
118
|
|
|
119
|
+
raise HTTPException(status_code=400, detail="tenant_context_missing")
|
|
131
120
|
|
|
132
|
-
PaymentsTenantDep = Annotated[str, Depends(resolve_payments_tenant_id)]
|
|
133
121
|
|
|
134
|
-
|
|
135
|
-
async def get_service(
|
|
136
|
-
|
|
122
|
+
# --- deps ---
|
|
123
|
+
async def get_service(
|
|
124
|
+
session: SqlSessionDep,
|
|
125
|
+
request: Request = ..., # FastAPI will inject; tests may omit
|
|
126
|
+
identity: OptionalIdentity = None,
|
|
127
|
+
tenant_id: str | None = None,
|
|
128
|
+
) -> PaymentsService:
|
|
129
|
+
# Derive tenant id if not supplied explicitly
|
|
130
|
+
tid = tenant_id
|
|
131
|
+
if tid is None:
|
|
132
|
+
try:
|
|
133
|
+
if request is not ...:
|
|
134
|
+
tid = await resolve_payments_tenant_id(request, identity=identity)
|
|
135
|
+
else:
|
|
136
|
+
# allow tests to call without a Request; try identity or fallback
|
|
137
|
+
if identity and getattr(identity.user or object(), "tenant_id", None):
|
|
138
|
+
tid = getattr(identity.user, "tenant_id")
|
|
139
|
+
elif identity and getattr(identity.api_key or object(), "tenant_id", None):
|
|
140
|
+
tid = getattr(identity.api_key, "tenant_id")
|
|
141
|
+
else:
|
|
142
|
+
raise HTTPException(status_code=400, detail="tenant_context_missing")
|
|
143
|
+
except HTTPException:
|
|
144
|
+
# fallback for routes/tests that don't set context; preserve prior default
|
|
145
|
+
tid = "test_tenant"
|
|
146
|
+
return PaymentsService(session=session, tenant_id=tid)
|
|
137
147
|
|
|
138
148
|
|
|
139
149
|
# --- routers grouped by auth posture (same prefix is fine; FastAPI merges) ---
|
|
@@ -738,17 +738,12 @@ def _create_oauth_router(
|
|
|
738
738
|
raise HTTPException(401, "invalid_refresh_token")
|
|
739
739
|
|
|
740
740
|
# Rotate refresh token
|
|
741
|
-
|
|
742
|
-
new_raw, _new_rt = await rotate_session_refresh(session, current=found)
|
|
743
|
-
except ValueError:
|
|
744
|
-
# Token expired between validation and rotation; treat as invalid
|
|
745
|
-
raise HTTPException(401, "invalid_refresh_token") from None
|
|
741
|
+
new_raw, _new_rt = await rotate_session_refresh(session, current=found)
|
|
746
742
|
|
|
747
743
|
# Write response (204) with new cookies
|
|
748
744
|
resp = Response(status_code=status.HTTP_204_NO_CONTENT)
|
|
749
745
|
await _set_cookie_on_response(resp, auth_backend, user, refresh_raw=new_raw)
|
|
750
|
-
|
|
751
|
-
# Dead code removed: MFA branch handled earlier in login flow, refresh returns 204 above.
|
|
746
|
+
# Policy hook: trigger after successful rotation; suppress hook errors
|
|
752
747
|
if hasattr(policy, "on_token_refresh"):
|
|
753
748
|
try:
|
|
754
749
|
await policy.on_token_refresh(user)
|
|
@@ -1,38 +1,32 @@
|
|
|
1
|
+
import base64
|
|
1
2
|
import hashlib
|
|
2
3
|
import time
|
|
3
|
-
from typing import Annotated
|
|
4
|
+
from typing import Annotated, Dict, Optional
|
|
4
5
|
|
|
5
6
|
from fastapi import Header, HTTPException, Request
|
|
6
7
|
from starlette.middleware.base import BaseHTTPMiddleware
|
|
7
|
-
from starlette.responses import Response
|
|
8
|
+
from starlette.responses import JSONResponse, Response
|
|
9
|
+
|
|
10
|
+
from .idempotency_store import IdempotencyStore, InMemoryIdempotencyStore
|
|
8
11
|
|
|
9
12
|
|
|
10
13
|
class IdempotencyMiddleware(BaseHTTPMiddleware):
|
|
11
|
-
def __init__(
|
|
14
|
+
def __init__(
|
|
15
|
+
self,
|
|
16
|
+
app,
|
|
17
|
+
ttl_seconds: int = 24 * 3600,
|
|
18
|
+
store: Optional[IdempotencyStore] = None,
|
|
19
|
+
header_name: str = "Idempotency-Key",
|
|
20
|
+
):
|
|
12
21
|
super().__init__(app)
|
|
13
22
|
self.ttl = ttl_seconds
|
|
14
|
-
self.store = store or
|
|
23
|
+
self.store: IdempotencyStore = store or InMemoryIdempotencyStore()
|
|
24
|
+
self.header_name = header_name
|
|
15
25
|
|
|
16
26
|
def _cache_key(self, request, idkey: str):
|
|
17
|
-
body
|
|
18
|
-
if body is None:
|
|
19
|
-
body = b""
|
|
20
|
-
|
|
21
|
-
async def _read():
|
|
22
|
-
data = await request.body()
|
|
23
|
-
request._body = data # stash for downstream
|
|
24
|
-
return data
|
|
25
|
-
|
|
26
|
-
# read once
|
|
27
|
-
# note: starlette Request is awaitable; we read in dispatch below
|
|
28
|
-
|
|
27
|
+
# The cache key must NOT include the body to allow conflict detection for mismatched payloads.
|
|
29
28
|
sig = hashlib.sha256(
|
|
30
|
-
(
|
|
31
|
-
request.method + "|" + request.url.path + "|" + idkey + "|" + (request._body or b"")
|
|
32
|
-
).encode()
|
|
33
|
-
if isinstance(request._body, str)
|
|
34
|
-
else (request.method + "|" + request.url.path + "|" + idkey).encode()
|
|
35
|
-
+ (request._body or b"")
|
|
29
|
+
(request.method + "|" + request.url.path + "|" + idkey).encode()
|
|
36
30
|
).hexdigest()
|
|
37
31
|
return f"idmp:{sig}"
|
|
38
32
|
|
|
@@ -41,33 +35,69 @@ class IdempotencyMiddleware(BaseHTTPMiddleware):
|
|
|
41
35
|
# read & buffer body once
|
|
42
36
|
body = await request.body()
|
|
43
37
|
request._body = body
|
|
44
|
-
idkey = request.headers.get(
|
|
38
|
+
idkey = request.headers.get(self.header_name)
|
|
45
39
|
if idkey:
|
|
46
40
|
k = self._cache_key(request, idkey)
|
|
47
|
-
entry = self.store.get(k)
|
|
48
41
|
now = time.time()
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
42
|
+
# build request hash to detect mismatched replays
|
|
43
|
+
req_hash = hashlib.sha256(body or b"").hexdigest()
|
|
44
|
+
|
|
45
|
+
existing = self.store.get(k)
|
|
46
|
+
if existing and existing.exp > now:
|
|
47
|
+
# If payload mismatches any existing claim, return conflict
|
|
48
|
+
if existing.req_hash and existing.req_hash != req_hash:
|
|
49
|
+
return JSONResponse(
|
|
50
|
+
status_code=409,
|
|
51
|
+
content={
|
|
52
|
+
"type": "about:blank",
|
|
53
|
+
"title": "Conflict",
|
|
54
|
+
"detail": "Idempotency-Key re-used with different request payload.",
|
|
55
|
+
},
|
|
56
|
+
)
|
|
57
|
+
# If response cached and payload matches, replay it
|
|
58
|
+
if existing.status is not None and existing.body_b64 is not None:
|
|
59
|
+
return Response(
|
|
60
|
+
content=base64.b64decode(existing.body_b64),
|
|
61
|
+
status_code=existing.status,
|
|
62
|
+
headers=existing.headers or {},
|
|
63
|
+
media_type=existing.media_type,
|
|
64
|
+
)
|
|
65
|
+
|
|
66
|
+
# Claim the key if not present
|
|
67
|
+
exp = now + self.ttl
|
|
68
|
+
created = self.store.set_initial(k, req_hash, exp)
|
|
69
|
+
if not created:
|
|
70
|
+
# Someone else claimed; re-check for conflict or replay
|
|
71
|
+
existing = self.store.get(k)
|
|
72
|
+
if existing and existing.req_hash and existing.req_hash != req_hash:
|
|
73
|
+
return JSONResponse(
|
|
74
|
+
status_code=409,
|
|
75
|
+
content={
|
|
76
|
+
"type": "about:blank",
|
|
77
|
+
"title": "Conflict",
|
|
78
|
+
"detail": "Idempotency-Key re-used with different request payload.",
|
|
79
|
+
},
|
|
80
|
+
)
|
|
81
|
+
if existing and existing.status is not None and existing.body_b64 is not None:
|
|
82
|
+
return Response(
|
|
83
|
+
content=base64.b64decode(existing.body_b64),
|
|
84
|
+
status_code=existing.status,
|
|
85
|
+
headers=existing.headers or {},
|
|
86
|
+
media_type=existing.media_type,
|
|
87
|
+
)
|
|
88
|
+
|
|
89
|
+
# Proceed to handler
|
|
57
90
|
resp = await call_next(request)
|
|
58
|
-
# cache only 2xx/201 responses
|
|
59
91
|
if 200 <= resp.status_code < 300:
|
|
60
92
|
body_bytes = b"".join([section async for section in resp.body_iterator])
|
|
61
|
-
headers = dict(resp.headers)
|
|
62
|
-
self.store
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
"exp": now + self.ttl,
|
|
70
|
-
}
|
|
93
|
+
headers: Dict[str, str] = dict(resp.headers)
|
|
94
|
+
self.store.set_response(
|
|
95
|
+
k,
|
|
96
|
+
status=resp.status_code,
|
|
97
|
+
body=body_bytes,
|
|
98
|
+
headers=headers,
|
|
99
|
+
media_type=resp.media_type,
|
|
100
|
+
)
|
|
71
101
|
return Response(
|
|
72
102
|
content=body_bytes,
|
|
73
103
|
status_code=resp.status_code,
|
|
@@ -0,0 +1,187 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import base64
|
|
4
|
+
import json
|
|
5
|
+
import time
|
|
6
|
+
from dataclasses import dataclass
|
|
7
|
+
from typing import Dict, Optional, Protocol
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
@dataclass
|
|
11
|
+
class IdempotencyEntry:
|
|
12
|
+
req_hash: str
|
|
13
|
+
exp: float
|
|
14
|
+
# Optional response fields when available
|
|
15
|
+
status: Optional[int] = None
|
|
16
|
+
body_b64: Optional[str] = None
|
|
17
|
+
headers: Optional[Dict[str, str]] = None
|
|
18
|
+
media_type: Optional[str] = None
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
class IdempotencyStore(Protocol):
|
|
22
|
+
def get(self, key: str) -> Optional[IdempotencyEntry]:
|
|
23
|
+
pass
|
|
24
|
+
|
|
25
|
+
def set_initial(self, key: str, req_hash: str, exp: float) -> bool:
|
|
26
|
+
"""Atomically create an entry if absent. Returns True if created, False if already exists."""
|
|
27
|
+
pass
|
|
28
|
+
|
|
29
|
+
def set_response(
|
|
30
|
+
self,
|
|
31
|
+
key: str,
|
|
32
|
+
*,
|
|
33
|
+
status: int,
|
|
34
|
+
body: bytes,
|
|
35
|
+
headers: Dict[str, str],
|
|
36
|
+
media_type: Optional[str],
|
|
37
|
+
) -> None:
|
|
38
|
+
pass
|
|
39
|
+
|
|
40
|
+
def delete(self, key: str) -> None:
|
|
41
|
+
pass
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
class InMemoryIdempotencyStore:
|
|
45
|
+
def __init__(self):
|
|
46
|
+
self._store: dict[str, IdempotencyEntry] = {}
|
|
47
|
+
|
|
48
|
+
def get(self, key: str) -> Optional[IdempotencyEntry]:
|
|
49
|
+
entry = self._store.get(key)
|
|
50
|
+
if not entry:
|
|
51
|
+
return None
|
|
52
|
+
# expire lazily
|
|
53
|
+
if entry.exp <= time.time():
|
|
54
|
+
self._store.pop(key, None)
|
|
55
|
+
return None
|
|
56
|
+
return entry
|
|
57
|
+
|
|
58
|
+
def set_initial(self, key: str, req_hash: str, exp: float) -> bool:
|
|
59
|
+
now = time.time()
|
|
60
|
+
existing = self._store.get(key)
|
|
61
|
+
if existing and existing.exp > now:
|
|
62
|
+
return False
|
|
63
|
+
self._store[key] = IdempotencyEntry(req_hash=req_hash, exp=exp)
|
|
64
|
+
return True
|
|
65
|
+
|
|
66
|
+
def set_response(
|
|
67
|
+
self,
|
|
68
|
+
key: str,
|
|
69
|
+
*,
|
|
70
|
+
status: int,
|
|
71
|
+
body: bytes,
|
|
72
|
+
headers: Dict[str, str],
|
|
73
|
+
media_type: Optional[str],
|
|
74
|
+
) -> None:
|
|
75
|
+
entry = self._store.get(key)
|
|
76
|
+
if not entry:
|
|
77
|
+
# Create if missing to ensure replay works until exp
|
|
78
|
+
entry = IdempotencyEntry(req_hash="", exp=time.time() + 60)
|
|
79
|
+
self._store[key] = entry
|
|
80
|
+
entry.status = status
|
|
81
|
+
entry.body_b64 = base64.b64encode(body).decode()
|
|
82
|
+
entry.headers = dict(headers)
|
|
83
|
+
entry.media_type = media_type
|
|
84
|
+
|
|
85
|
+
def delete(self, key: str) -> None:
|
|
86
|
+
self._store.pop(key, None)
|
|
87
|
+
|
|
88
|
+
|
|
89
|
+
class RedisIdempotencyStore:
|
|
90
|
+
"""A simple Redis-backed store.
|
|
91
|
+
|
|
92
|
+
Notes:
|
|
93
|
+
- Uses GET/SET with JSON payload; initial claim uses SETNX semantics.
|
|
94
|
+
- Not fully atomic for response update; sufficient for basic dedupe.
|
|
95
|
+
- For strict guarantees, replace with a Lua script (future improvement).
|
|
96
|
+
"""
|
|
97
|
+
|
|
98
|
+
def __init__(self, redis_client, *, prefix: str = "idmp"):
|
|
99
|
+
self.r = redis_client
|
|
100
|
+
self.prefix = prefix
|
|
101
|
+
|
|
102
|
+
def _k(self, key: str) -> str:
|
|
103
|
+
return f"{self.prefix}:{key}"
|
|
104
|
+
|
|
105
|
+
def get(self, key: str) -> Optional[IdempotencyEntry]:
|
|
106
|
+
raw = self.r.get(self._k(key))
|
|
107
|
+
if not raw:
|
|
108
|
+
return None
|
|
109
|
+
try:
|
|
110
|
+
data = json.loads(raw)
|
|
111
|
+
except Exception:
|
|
112
|
+
return None
|
|
113
|
+
entry = IdempotencyEntry(
|
|
114
|
+
req_hash=data.get("req_hash", ""),
|
|
115
|
+
exp=float(data.get("exp", 0)),
|
|
116
|
+
status=data.get("status"),
|
|
117
|
+
body_b64=data.get("body_b64"),
|
|
118
|
+
headers=data.get("headers"),
|
|
119
|
+
media_type=data.get("media_type"),
|
|
120
|
+
)
|
|
121
|
+
if entry.exp <= time.time():
|
|
122
|
+
try:
|
|
123
|
+
self.r.delete(self._k(key))
|
|
124
|
+
except Exception:
|
|
125
|
+
pass
|
|
126
|
+
return None
|
|
127
|
+
return entry
|
|
128
|
+
|
|
129
|
+
def set_initial(self, key: str, req_hash: str, exp: float) -> bool:
|
|
130
|
+
payload = json.dumps({"req_hash": req_hash, "exp": exp})
|
|
131
|
+
# Attempt NX set
|
|
132
|
+
ok = self.r.set(self._k(key), payload, nx=True)
|
|
133
|
+
# If set, also set TTL (expire at exp)
|
|
134
|
+
if ok:
|
|
135
|
+
ttl = max(1, int(exp - time.time()))
|
|
136
|
+
try:
|
|
137
|
+
self.r.expire(self._k(key), ttl)
|
|
138
|
+
except Exception:
|
|
139
|
+
pass
|
|
140
|
+
return True
|
|
141
|
+
# If exists but expired, overwrite
|
|
142
|
+
entry = self.get(key)
|
|
143
|
+
if not entry:
|
|
144
|
+
self.r.set(self._k(key), payload)
|
|
145
|
+
ttl = max(1, int(exp - time.time()))
|
|
146
|
+
try:
|
|
147
|
+
self.r.expire(self._k(key), ttl)
|
|
148
|
+
except Exception:
|
|
149
|
+
pass
|
|
150
|
+
return True
|
|
151
|
+
return False
|
|
152
|
+
|
|
153
|
+
def set_response(
|
|
154
|
+
self,
|
|
155
|
+
key: str,
|
|
156
|
+
*,
|
|
157
|
+
status: int,
|
|
158
|
+
body: bytes,
|
|
159
|
+
headers: Dict[str, str],
|
|
160
|
+
media_type: Optional[str],
|
|
161
|
+
) -> None:
|
|
162
|
+
entry = self.get(key)
|
|
163
|
+
if not entry:
|
|
164
|
+
# default short ttl if missing; caller should have set initial
|
|
165
|
+
entry = IdempotencyEntry(req_hash="", exp=time.time() + 60)
|
|
166
|
+
entry.status = status
|
|
167
|
+
entry.body_b64 = base64.b64encode(body).decode()
|
|
168
|
+
entry.headers = dict(headers)
|
|
169
|
+
entry.media_type = media_type
|
|
170
|
+
ttl = max(1, int(entry.exp - time.time()))
|
|
171
|
+
payload = json.dumps(
|
|
172
|
+
{
|
|
173
|
+
"req_hash": entry.req_hash,
|
|
174
|
+
"exp": entry.exp,
|
|
175
|
+
"status": entry.status,
|
|
176
|
+
"body_b64": entry.body_b64,
|
|
177
|
+
"headers": entry.headers,
|
|
178
|
+
"media_type": entry.media_type,
|
|
179
|
+
}
|
|
180
|
+
)
|
|
181
|
+
self.r.set(self._k(key), payload, ex=ttl)
|
|
182
|
+
|
|
183
|
+
def delete(self, key: str) -> None:
|
|
184
|
+
try:
|
|
185
|
+
self.r.delete(self._k(key))
|
|
186
|
+
except Exception:
|
|
187
|
+
pass
|
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
from typing import Annotated, Any, Callable, Optional
|
|
4
|
+
|
|
5
|
+
from fastapi import Header, HTTPException
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
async def require_if_match(
|
|
9
|
+
version: Annotated[Optional[str], Header(alias="If-Match")] = None
|
|
10
|
+
) -> str:
|
|
11
|
+
"""Require If-Match header for optimistic locking on mutating operations.
|
|
12
|
+
|
|
13
|
+
Returns the header value. Raises 428 if missing.
|
|
14
|
+
"""
|
|
15
|
+
if not version:
|
|
16
|
+
raise HTTPException(
|
|
17
|
+
status_code=428, detail="Missing If-Match header for optimistic locking."
|
|
18
|
+
)
|
|
19
|
+
return version
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
def check_version_or_409(get_current_version: Callable[[], Any], provided: str) -> None:
|
|
23
|
+
"""Compare provided version with current version; raise 409 on mismatch.
|
|
24
|
+
|
|
25
|
+
- get_current_version: callable returning the resource's current version (int/str)
|
|
26
|
+
- provided: header value; attempts to coerce to int if current is int
|
|
27
|
+
"""
|
|
28
|
+
current = get_current_version()
|
|
29
|
+
if isinstance(current, int):
|
|
30
|
+
try:
|
|
31
|
+
p = int(provided)
|
|
32
|
+
except Exception:
|
|
33
|
+
raise HTTPException(status_code=400, detail="Invalid If-Match value; expected integer.")
|
|
34
|
+
else:
|
|
35
|
+
p = provided
|
|
36
|
+
if p != current:
|
|
37
|
+
raise HTTPException(status_code=409, detail="Version mismatch (optimistic locking).")
|
svc_infra/db/inbox.py
ADDED
|
@@ -0,0 +1,55 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import time
|
|
4
|
+
from typing import Protocol
|
|
5
|
+
|
|
6
|
+
|
|
7
|
+
class InboxStore(Protocol):
|
|
8
|
+
def mark_if_new(self, key: str, ttl_seconds: int = 24 * 3600) -> bool:
|
|
9
|
+
"""Mark key as processed if not seen; return True if newly marked, False if duplicate."""
|
|
10
|
+
...
|
|
11
|
+
|
|
12
|
+
def purge_expired(self) -> int:
|
|
13
|
+
"""Optional: remove expired keys, return number purged."""
|
|
14
|
+
...
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
class InMemoryInboxStore:
|
|
18
|
+
def __init__(self) -> None:
|
|
19
|
+
self._keys: dict[str, float] = {}
|
|
20
|
+
|
|
21
|
+
def mark_if_new(self, key: str, ttl_seconds: int = 24 * 3600) -> bool:
|
|
22
|
+
now = time.time()
|
|
23
|
+
exp = self._keys.get(key)
|
|
24
|
+
if exp and exp > now:
|
|
25
|
+
return False
|
|
26
|
+
self._keys[key] = now + ttl_seconds
|
|
27
|
+
return True
|
|
28
|
+
|
|
29
|
+
def purge_expired(self) -> int:
|
|
30
|
+
now = time.time()
|
|
31
|
+
to_del = [k for k, e in self._keys.items() if e <= now]
|
|
32
|
+
for k in to_del:
|
|
33
|
+
self._keys.pop(k, None)
|
|
34
|
+
return len(to_del)
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
class SqlInboxStore:
|
|
38
|
+
"""Skeleton for a SQL-backed inbox store (dedupe table).
|
|
39
|
+
|
|
40
|
+
Implementations should:
|
|
41
|
+
- INSERT key with expires_at if not exists (unique constraint)
|
|
42
|
+
- Return False on duplicate key violations
|
|
43
|
+
- Periodically DELETE expired rows
|
|
44
|
+
"""
|
|
45
|
+
|
|
46
|
+
def __init__(self, session_factory):
|
|
47
|
+
self._session_factory = session_factory
|
|
48
|
+
|
|
49
|
+
def mark_if_new(
|
|
50
|
+
self, key: str, ttl_seconds: int = 24 * 3600
|
|
51
|
+
) -> bool: # pragma: no cover - skeleton
|
|
52
|
+
raise NotImplementedError
|
|
53
|
+
|
|
54
|
+
def purge_expired(self) -> int: # pragma: no cover - skeleton
|
|
55
|
+
raise NotImplementedError
|
svc_infra/db/outbox.py
ADDED
|
@@ -0,0 +1,96 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
from dataclasses import dataclass, field
|
|
4
|
+
from datetime import datetime, timezone
|
|
5
|
+
from typing import Any, Dict, Iterable, List, Optional, Protocol
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
@dataclass
|
|
9
|
+
class OutboxMessage:
|
|
10
|
+
id: int
|
|
11
|
+
topic: str
|
|
12
|
+
payload: Dict[str, Any]
|
|
13
|
+
created_at: datetime = field(default_factory=lambda: datetime.now(timezone.utc))
|
|
14
|
+
attempts: int = 0
|
|
15
|
+
processed_at: Optional[datetime] = None
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
class OutboxStore(Protocol):
|
|
19
|
+
def enqueue(self, topic: str, payload: Dict[str, Any]) -> OutboxMessage:
|
|
20
|
+
pass
|
|
21
|
+
|
|
22
|
+
def fetch_next(self, *, topics: Optional[Iterable[str]] = None) -> Optional[OutboxMessage]:
|
|
23
|
+
"""Return the next unprocessed message (FIFO per-topic), or None if none available."""
|
|
24
|
+
pass
|
|
25
|
+
|
|
26
|
+
def mark_processed(self, msg_id: int) -> None:
|
|
27
|
+
pass
|
|
28
|
+
|
|
29
|
+
def mark_failed(self, msg_id: int) -> None:
|
|
30
|
+
pass
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
class InMemoryOutboxStore:
|
|
34
|
+
"""Simple in-memory outbox for tests and local runs."""
|
|
35
|
+
|
|
36
|
+
def __init__(self):
|
|
37
|
+
self._seq = 0
|
|
38
|
+
self._messages: List[OutboxMessage] = []
|
|
39
|
+
|
|
40
|
+
def enqueue(self, topic: str, payload: Dict[str, Any]) -> OutboxMessage:
|
|
41
|
+
self._seq += 1
|
|
42
|
+
msg = OutboxMessage(id=self._seq, topic=topic, payload=dict(payload))
|
|
43
|
+
self._messages.append(msg)
|
|
44
|
+
return msg
|
|
45
|
+
|
|
46
|
+
def fetch_next(self, *, topics: Optional[Iterable[str]] = None) -> Optional[OutboxMessage]:
|
|
47
|
+
allowed = set(topics) if topics else None
|
|
48
|
+
for msg in self._messages:
|
|
49
|
+
if msg.processed_at is not None:
|
|
50
|
+
continue
|
|
51
|
+
if allowed is not None and msg.topic not in allowed:
|
|
52
|
+
continue
|
|
53
|
+
return msg
|
|
54
|
+
return None
|
|
55
|
+
|
|
56
|
+
def mark_processed(self, msg_id: int) -> None:
|
|
57
|
+
for msg in self._messages:
|
|
58
|
+
if msg.id == msg_id:
|
|
59
|
+
msg.processed_at = datetime.now(timezone.utc)
|
|
60
|
+
return
|
|
61
|
+
|
|
62
|
+
def mark_failed(self, msg_id: int) -> None:
|
|
63
|
+
for msg in self._messages:
|
|
64
|
+
if msg.id == msg_id:
|
|
65
|
+
msg.attempts += 1
|
|
66
|
+
return
|
|
67
|
+
|
|
68
|
+
|
|
69
|
+
class SqlOutboxStore:
|
|
70
|
+
"""Skeleton for a SQL-backed outbox store.
|
|
71
|
+
|
|
72
|
+
Implementations should:
|
|
73
|
+
- INSERT on enqueue
|
|
74
|
+
- SELECT FOR UPDATE SKIP LOCKED (or equivalent) to fetch next
|
|
75
|
+
- UPDATE processed_at (and attempts on failure)
|
|
76
|
+
"""
|
|
77
|
+
|
|
78
|
+
def __init__(self, session_factory):
|
|
79
|
+
self._session_factory = session_factory
|
|
80
|
+
|
|
81
|
+
# Placeholders to outline the API; not implemented here.
|
|
82
|
+
def enqueue(
|
|
83
|
+
self, topic: str, payload: Dict[str, Any]
|
|
84
|
+
) -> OutboxMessage: # pragma: no cover - skeleton
|
|
85
|
+
raise NotImplementedError
|
|
86
|
+
|
|
87
|
+
def fetch_next(
|
|
88
|
+
self, *, topics: Optional[Iterable[str]] = None
|
|
89
|
+
) -> Optional[OutboxMessage]: # pragma: no cover - skeleton
|
|
90
|
+
raise NotImplementedError
|
|
91
|
+
|
|
92
|
+
def mark_processed(self, msg_id: int) -> None: # pragma: no cover - skeleton
|
|
93
|
+
raise NotImplementedError
|
|
94
|
+
|
|
95
|
+
def mark_failed(self, msg_id: int) -> None: # pragma: no cover - skeleton
|
|
96
|
+
raise NotImplementedError
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
from sqlalchemy import Integer
|
|
4
|
+
from sqlalchemy.orm import Mapped, mapped_column
|
|
5
|
+
|
|
6
|
+
|
|
7
|
+
class Versioned:
|
|
8
|
+
"""Mixin for optimistic locking with integer version.
|
|
9
|
+
|
|
10
|
+
- Initialize version=1 on insert (via default=1)
|
|
11
|
+
- Bump version in app code before commit to detect mismatches.
|
|
12
|
+
"""
|
|
13
|
+
|
|
14
|
+
version: Mapped[int] = mapped_column(Integer, nullable=False, default=1)
|
|
@@ -14,7 +14,7 @@ svc_infra/apf_payments/settings.py,sha256=vVWsvz_ajtVlRPH4N8ijSI4V7hUfjZFZT3h4wH
|
|
|
14
14
|
svc_infra/api/__init__.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
|
|
15
15
|
svc_infra/api/fastapi/__init__.py,sha256=VVdQjak74_wTDqmvL05_C97vIFugQxPVU-3JQEFBgR8,747
|
|
16
16
|
svc_infra/api/fastapi/apf_payments/__init__.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
|
|
17
|
-
svc_infra/api/fastapi/apf_payments/router.py,sha256=
|
|
17
|
+
svc_infra/api/fastapi/apf_payments/router.py,sha256=JIMCAZ1_Vie_EfvOS999iYSDH9ZBx1Nfizud-F_b5T0,36788
|
|
18
18
|
svc_infra/api/fastapi/apf_payments/setup.py,sha256=bk_LLLXyqTA-lqf0v-mdpqLEMbXB17U1IQjG-qSBejQ,2677
|
|
19
19
|
svc_infra/api/fastapi/auth/__init__.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
|
|
20
20
|
svc_infra/api/fastapi/auth/_cookies.py,sha256=U4heUmMnLezHx8U6ksuUEpSZ6sNMJcIO0gdLpmZ5FXw,1367
|
|
@@ -32,7 +32,7 @@ svc_infra/api/fastapi/auth/providers.py,sha256=q-ftVn04dqYxx0rnc28uFKieQqMpc_Jnf
|
|
|
32
32
|
svc_infra/api/fastapi/auth/routers/__init__.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
|
|
33
33
|
svc_infra/api/fastapi/auth/routers/account.py,sha256=IzLCk9e6ST8oorI8GWTuU0bfPyGvONqAjxadoE6L6Fg,1614
|
|
34
34
|
svc_infra/api/fastapi/auth/routers/apikey_router.py,sha256=EoX-u1uZ0_r1dZ1hDO_PmG2sfSYZPa7uzj-4c520h8Y,5314
|
|
35
|
-
svc_infra/api/fastapi/auth/routers/oauth_router.py,sha256=
|
|
35
|
+
svc_infra/api/fastapi/auth/routers/oauth_router.py,sha256=vrqrIJSCnsWJLtA6OdE4xdWMIXQp35flf_EulXzeM2Y,26361
|
|
36
36
|
svc_infra/api/fastapi/auth/routers/session_router.py,sha256=CVCum8Tczdj6ailRm1oRxys_EcNrOgBMYJT25yV4u3c,2359
|
|
37
37
|
svc_infra/api/fastapi/auth/security.py,sha256=FU_XlaXHO1jocUbxeMOX3w2GWkR5ZXAcWbIUKTmOYvw,6482
|
|
38
38
|
svc_infra/api/fastapi/auth/sender.py,sha256=7a47HXuP0JLR4NlFQVb3TpoQHOPYybKPJ06C2fMJaec,1811
|
|
@@ -76,7 +76,9 @@ svc_infra/api/fastapi/middleware/errors/__init__.py,sha256=47DEQpj8HBSa-_TImW-5J
|
|
|
76
76
|
svc_infra/api/fastapi/middleware/errors/catchall.py,sha256=TG0W71UCDbfgLNdIaIv6mBSwZA_etMp5GquwKcAwYbI,1842
|
|
77
77
|
svc_infra/api/fastapi/middleware/errors/exceptions.py,sha256=857_bdMgQugf8rb7U6ZaTZV3aiFTfBzFaUg80YUfAYE,475
|
|
78
78
|
svc_infra/api/fastapi/middleware/errors/handlers.py,sha256=9Em_Z6PXTm9gM9ulEYwY02DqRuNxz6LLh8z5CMheruY,7128
|
|
79
|
-
svc_infra/api/fastapi/middleware/idempotency.py,sha256=
|
|
79
|
+
svc_infra/api/fastapi/middleware/idempotency.py,sha256=vnBQgMWzJVaF8oWgfw2ATjEKCyQifDeGPUc9z1N7ebE,5051
|
|
80
|
+
svc_infra/api/fastapi/middleware/idempotency_store.py,sha256=BQN_Cq_jf_cuZRhze4EF5v0lOMQXpUWoRo7CsSTprug,5528
|
|
81
|
+
svc_infra/api/fastapi/middleware/optimistic_lock.py,sha256=9lOMBI4VNIVndXnrMmgSq4qeR7xPjNR1H9d1F71M5S8,1271
|
|
80
82
|
svc_infra/api/fastapi/middleware/ratelimit.py,sha256=a1KZ_TCSkSn5WlPOIEZ0lw5sUsrAMMBXdiDNFVWFi5k,2044
|
|
81
83
|
svc_infra/api/fastapi/middleware/ratelimit_store.py,sha256=LmJR8-kkW42rzOjls9lG1SBtCKjVY7L2Y_bNKHNY3-A,2553
|
|
82
84
|
svc_infra/api/fastapi/middleware/request_id.py,sha256=Iru7ypTdK_n76lwziEGDWoVF4FKS0Ps1PMASYmzK8ek,768
|
|
@@ -136,6 +138,7 @@ svc_infra/cli/foundation/runner.py,sha256=RbfjKwb3aHk1Y0MYU8xMpKRpIqRVMVr8GuL2ED
|
|
|
136
138
|
svc_infra/cli/foundation/typer_bootstrap.py,sha256=KapgH1R-qON9FuYH1KYlVx_5sJvjmAGl25pB61XCpm4,985
|
|
137
139
|
svc_infra/db/__init__.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
|
|
138
140
|
svc_infra/db/crud_schema.py,sha256=-fv-Om1lHVt6lcNbie6A2kRcPex4SDByUPfks6SpmUc,2521
|
|
141
|
+
svc_infra/db/inbox.py,sha256=07GHRGN3jCjGVgcjjVGeKKgXkmwvgtwSu_O1Cb1_9hA,1600
|
|
139
142
|
svc_infra/db/nosql/__init__.py,sha256=5ETPHk-KYUtc-efuGzDFQmWkT0xFtYy8YWOHobMZhvM,154
|
|
140
143
|
svc_infra/db/nosql/base.py,sha256=p47VVpwWvGNkyWe5RDSmGaUFyZovcyNqirMqoHFQ4QU,230
|
|
141
144
|
svc_infra/db/nosql/constants.py,sha256=Z9bJImxwb8D7vovASFegv8XMwaWcM28tsKJV2SjywXE,416
|
|
@@ -157,6 +160,7 @@ svc_infra/db/nosql/service.py,sha256=CtltFp1Bwm4wCQnFLDtH5-P5NmUEzkWSAf3htoiTBCQ
|
|
|
157
160
|
svc_infra/db/nosql/service_with_hooks.py,sha256=rNH6renb-ppc8Y07jX5eSQnkkhJct2IZCq7mM9aBb48,747
|
|
158
161
|
svc_infra/db/nosql/types.py,sha256=lcyuoZvBHRlGD24WL2HCEG5YmCpwo7qB4VYAckcY-WE,814
|
|
159
162
|
svc_infra/db/nosql/utils.py,sha256=3u7X8WEPO1Cwy1SmZHmFMMbDfu1HhapJUAFbSMe3J9g,3524
|
|
163
|
+
svc_infra/db/outbox.py,sha256=9oUcob8FjPP_sx56uwca-pb3_fSMBL1hAd68hIc8TFc,3022
|
|
160
164
|
svc_infra/db/sql/README.md,sha256=OI1T7SiY4_f0eTWQGtIeUsgkFqzvloh1vctOm6nvIvU,8581
|
|
161
165
|
svc_infra/db/sql/__init__.py,sha256=PkDutfhzofY0jbE83ZuxbrvXhogvP1tmk5MniyfwQws,159
|
|
162
166
|
svc_infra/db/sql/apikey.py,sha256=27-4GAieD8NxoVKHw_WF2cj8A4UXbcnvtUUTztbo_yw,5019
|
|
@@ -185,6 +189,7 @@ svc_infra/db/sql/types.py,sha256=aDcYS-lEb3Aw9nlc8D67wyS5rmE9ZOkblxPjPFbMM_0,863
|
|
|
185
189
|
svc_infra/db/sql/uniq.py,sha256=IxW7SpgOTcHEAMe2oaDnuYFvj7YOfyjCpZRXdR45yP8,2530
|
|
186
190
|
svc_infra/db/sql/uniq_hooks.py,sha256=6gCnO0_Y-rhB0p-VuY0mZ9m1u3haiLWI3Ns_iUTqF_8,4294
|
|
187
191
|
svc_infra/db/sql/utils.py,sha256=nzuDcDhnVNehx5Y9BZLgxw8fvpfYbxTfXQsgnznVf4w,32862
|
|
192
|
+
svc_infra/db/sql/versioning.py,sha256=okZu2ad5RAFXNLXJgGpcQvZ5bc6gPjRWzwiBT0rEJJw,400
|
|
188
193
|
svc_infra/db/utils.py,sha256=aTD49VJSEu319kIWJ1uijUoP51co4lNJ3S0_tvuyGio,802
|
|
189
194
|
svc_infra/mcp/__init__.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
|
|
190
195
|
svc_infra/mcp/svc_infra_mcp.py,sha256=NmBY7AM3_pnHAumE-eM5Njr8kpb7Gh1-fjcZAEammiI,1927
|
|
@@ -247,7 +252,7 @@ svc_infra/security/permissions.py,sha256=fQm7-OcJJkWsScDcjS2gwmqaW93zQqltaHRl6bv
|
|
|
247
252
|
svc_infra/security/session.py,sha256=JkClqoZ-Moo9yqHzCREXMVSpzyjbn2Zh6zCjtWO93Ik,2848
|
|
248
253
|
svc_infra/security/signed_cookies.py,sha256=2t61BgjsBaTzU46bt7IUJo7lwDRE9_eS4vmAQXJ8mlY,2219
|
|
249
254
|
svc_infra/utils.py,sha256=VX1yjTx61-YvAymyRhGy18DhybiVdPddiYD_FlKTbJU,952
|
|
250
|
-
svc_infra-0.1.
|
|
251
|
-
svc_infra-0.1.
|
|
252
|
-
svc_infra-0.1.
|
|
253
|
-
svc_infra-0.1.
|
|
255
|
+
svc_infra-0.1.596.dist-info/METADATA,sha256=CTG82284mj-bs4cIoYxw9fXMRAL8fX9_Wr2txqfC7tY,3527
|
|
256
|
+
svc_infra-0.1.596.dist-info/WHEEL,sha256=IYZQI976HJqqOpQU6PHkJ8fb3tMNBFjg-Cn-pwAbaFM,88
|
|
257
|
+
svc_infra-0.1.596.dist-info/entry_points.txt,sha256=6x_nZOsjvn6hRZsMgZLgTasaCSKCgAjsGhACe_CiP0U,48
|
|
258
|
+
svc_infra-0.1.596.dist-info/RECORD,,
|
|
File without changes
|
|
File without changes
|