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.

@@ -1,7 +1,7 @@
1
1
  from __future__ import annotations
2
2
 
3
3
  import inspect
4
- from typing import Annotated, Awaitable, Callable, Literal, Optional, cast
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
- # --- deps ---
74
- TenantOverrideHook = Callable[
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(resolver: TenantOverrideHook | None) -> None:
83
- """Override the default tenant resolution used by the payments router.
77
+ def set_payments_tenant_resolver(fn):
78
+ """Set or clear an override hook for payments tenant resolution.
84
79
 
85
- Projects can call this during startup to plug custom logic (e.g. multi-tenant
86
- mappings). Passing ``None`` resets to the built-in behavior.
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
- global _tenant_override_hook
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: OptionalIdentity = None,
96
- tenant_header: Annotated[Optional[str], Header(alias="X-Tenant-Id", default=None)] = None,
89
+ identity: Principal | None = None,
90
+ tenant_header: str | None = None,
97
91
  ) -> str:
98
- """Determine the tenant id for the current request.
99
-
100
- The default strategy prefers authenticated principals (API keys first, then
101
- user accounts) and falls back to the ``X-Tenant-Id`` header or ``request.state``.
102
- Applications may override the behavior via
103
- :func:`set_payments_tenant_resolver`.
104
- """
105
-
106
- if _tenant_override_hook is not None:
107
- maybe = _tenant_override_hook(request, identity, tenant_header)
108
- if inspect.isawaitable(maybe): # pragma: no cover - depends on override type
109
- maybe = await maybe
110
- if maybe is not None:
111
- return maybe
112
-
113
- if identity:
114
- api_key_tenant = getattr(getattr(identity, "api_key", None), "tenant_id", None)
115
- if api_key_tenant:
116
- return api_key_tenant
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
- state_tenant = getattr(request.state, "tenant_id", None)
126
- if state_tenant:
127
- return state_tenant
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(session: SqlSessionDep, tenant_id: PaymentsTenantDep) -> PaymentsService:
136
- return PaymentsService(session=session, tenant_id=tenant_id)
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
- try:
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__(self, app, ttl_seconds: int = 24 * 3600, store=None):
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 {} # replace with Redis
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 = getattr(request, "_body", None)
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("Idempotency-Key")
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
- if entry and entry["exp"] > now:
50
- cached = entry["resp"]
51
- return Response(
52
- content=cached["body"],
53
- status_code=cached["status"],
54
- headers=cached["headers"],
55
- media_type=cached.get("media_type"),
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[k] = {
63
- "resp": {
64
- "status": resp.status_code,
65
- "body": body_bytes,
66
- "headers": headers,
67
- "media_type": resp.media_type,
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)
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.3
2
2
  Name: svc-infra
3
- Version: 0.1.595
3
+ Version: 0.1.596
4
4
  Summary: Infrastructure for building and deploying prod-ready services
5
5
  License: MIT
6
6
  Keywords: fastapi,sqlalchemy,alembic,auth,infra,async,pydantic
@@ -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=NVcLUNU-6Fx_3C_T_PbpsX1bN4LPVhiPuTSiqMcT7Ko,36275
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=ZxGemG7jXa_CNlsCf-MWY2vQwBDgQ7DJamqpgerrmRQ,26575
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=Wc9uzFQmatPAGF6oEP35YxRsIn_dBsRL1vyMk6y6A4k,3284
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.595.dist-info/METADATA,sha256=wxxuM6mbWaeSQdvV72wZI6B_XrCfpj1kMwGdecksB3I,3527
251
- svc_infra-0.1.595.dist-info/WHEEL,sha256=IYZQI976HJqqOpQU6PHkJ8fb3tMNBFjg-Cn-pwAbaFM,88
252
- svc_infra-0.1.595.dist-info/entry_points.txt,sha256=6x_nZOsjvn6hRZsMgZLgTasaCSKCgAjsGhACe_CiP0U,48
253
- svc_infra-0.1.595.dist-info/RECORD,,
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,,