idemkit 0.1.0__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.
- idemkit/__init__.py +101 -0
- idemkit/_version.py +1 -0
- idemkit/adapters/__init__.py +10 -0
- idemkit/adapters/ai.py +723 -0
- idemkit/adapters/asgi.py +533 -0
- idemkit/adapters/queue.py +413 -0
- idemkit/adapters/route.py +273 -0
- idemkit/adapters/wsgi.py +393 -0
- idemkit/backends/__init__.py +30 -0
- idemkit/backends/base.py +116 -0
- idemkit/backends/dynamodb.py +489 -0
- idemkit/backends/memory.py +325 -0
- idemkit/backends/mongo.py +395 -0
- idemkit/backends/postgres.py +682 -0
- idemkit/backends/redis.py +547 -0
- idemkit/cli.py +165 -0
- idemkit/conformance/__init__.py +25 -0
- idemkit/conformance/backend.py +257 -0
- idemkit/conformance/report.py +54 -0
- idemkit/contrib/__init__.py +30 -0
- idemkit/contrib/drf.py +181 -0
- idemkit/contrib/fastapi.py +141 -0
- idemkit/contrib/kafka.py +96 -0
- idemkit/contrib/logging.py +61 -0
- idemkit/contrib/mcp.py +113 -0
- idemkit/contrib/prometheus.py +79 -0
- idemkit/contrib/pubsub.py +98 -0
- idemkit/contrib/rabbitmq.py +138 -0
- idemkit/contrib/reconciliation.py +86 -0
- idemkit/contrib/sqs.py +117 -0
- idemkit/core/__init__.py +36 -0
- idemkit/core/codecs.py +229 -0
- idemkit/core/config.py +255 -0
- idemkit/core/engine.py +331 -0
- idemkit/core/events.py +63 -0
- idemkit/core/exception_cache.py +88 -0
- idemkit/core/exceptions.py +79 -0
- idemkit/core/fingerprint.py +153 -0
- idemkit/core/policy.py +143 -0
- idemkit/core/runner.py +664 -0
- idemkit/core/state.py +95 -0
- idemkit/core/sync_bridge.py +115 -0
- idemkit/problem_details.py +119 -0
- idemkit/py.typed +0 -0
- idemkit-0.1.0.dist-info/METADATA +446 -0
- idemkit-0.1.0.dist-info/RECORD +48 -0
- idemkit-0.1.0.dist-info/WHEEL +4 -0
- idemkit-0.1.0.dist-info/entry_points.txt +2 -0
idemkit/adapters/asgi.py
ADDED
|
@@ -0,0 +1,533 @@
|
|
|
1
|
+
"""ASGI middleware for idemkit, compatible with FastAPI / Starlette / any ASGI 3 app.
|
|
2
|
+
|
|
3
|
+
Spec coverage: §4.7 (key + scope extraction), §4.4 (release on
|
|
4
|
+
exception / disconnect), §4.9 (response size enforcement by streamed bytes),
|
|
5
|
+
§4.11 (encoding verbatim replay), §6 (wire format).
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
from __future__ import annotations
|
|
9
|
+
|
|
10
|
+
import logging
|
|
11
|
+
from collections.abc import Awaitable, Callable
|
|
12
|
+
from typing import Any
|
|
13
|
+
|
|
14
|
+
from idemkit import problem_details
|
|
15
|
+
from idemkit.backends.base import IdempotencyBackend
|
|
16
|
+
from idemkit.core.config import resolve_http_config
|
|
17
|
+
from idemkit.core.engine import IdempotencyEngine, NeutralRequest, NeutralResponse
|
|
18
|
+
from idemkit.core.policy import HttpConfig
|
|
19
|
+
|
|
20
|
+
_logger = logging.getLogger(__name__)
|
|
21
|
+
|
|
22
|
+
_inmemory_warned = False
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
def _warn_inmemory_backend() -> None:
|
|
26
|
+
"""Warn once per process that InMemoryBackend is not production-safe (§7.4)."""
|
|
27
|
+
global _inmemory_warned
|
|
28
|
+
if _inmemory_warned:
|
|
29
|
+
return
|
|
30
|
+
_inmemory_warned = True
|
|
31
|
+
_logger.warning(
|
|
32
|
+
"idemkit: InMemoryBackend is for development and tests only. Its state "
|
|
33
|
+
"lives in one process, so idempotency is NOT shared across multiple "
|
|
34
|
+
"workers/instances (e.g. `uvicorn --workers N`, gunicorn, k8s replicas) "
|
|
35
|
+
"and duplicate requests can slip through. Use RedisBackend or "
|
|
36
|
+
"PostgresBackend in production."
|
|
37
|
+
)
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
# ASGI types — kept generic to avoid framework deps in core.
|
|
41
|
+
ASGIScope = dict[str, Any]
|
|
42
|
+
ASGIMessage = dict[str, Any]
|
|
43
|
+
ASGIReceive = Callable[[], Awaitable[ASGIMessage]]
|
|
44
|
+
ASGISend = Callable[[ASGIMessage], Awaitable[None]]
|
|
45
|
+
ASGIApp = Callable[[ASGIScope, ASGIReceive, ASGISend], Awaitable[None]]
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
class IdempotencyMiddleware:
|
|
49
|
+
"""ASGI middleware that applies idempotency per the configured backend.
|
|
50
|
+
|
|
51
|
+
Usage::
|
|
52
|
+
|
|
53
|
+
from idemkit import IdempotencyMiddleware, HttpConfig, InMemoryBackend
|
|
54
|
+
|
|
55
|
+
app.add_middleware(
|
|
56
|
+
IdempotencyMiddleware,
|
|
57
|
+
backend=InMemoryBackend(),
|
|
58
|
+
config=HttpConfig(scope=lambda req: req.state.user.id),
|
|
59
|
+
)
|
|
60
|
+
|
|
61
|
+
On the ASGI ``lifespan`` shutdown the middleware closes the backend it was
|
|
62
|
+
given (pool + background listener), so the one-line install does not leak.
|
|
63
|
+
Pass ``manage_backend=False`` if you share the backend elsewhere and close it
|
|
64
|
+
yourself.
|
|
65
|
+
"""
|
|
66
|
+
|
|
67
|
+
def __init__(
|
|
68
|
+
self,
|
|
69
|
+
app: ASGIApp,
|
|
70
|
+
*,
|
|
71
|
+
backend: IdempotencyBackend,
|
|
72
|
+
config: HttpConfig | None = None,
|
|
73
|
+
manage_backend: bool = True,
|
|
74
|
+
) -> None:
|
|
75
|
+
resolved = resolve_http_config(config)
|
|
76
|
+
self.app = app
|
|
77
|
+
self.config = resolved
|
|
78
|
+
self._backend = backend
|
|
79
|
+
self._manage_backend = manage_backend
|
|
80
|
+
self.engine = IdempotencyEngine(backend=backend, config=resolved)
|
|
81
|
+
if type(backend).__name__ == "InMemoryBackend":
|
|
82
|
+
_warn_inmemory_backend()
|
|
83
|
+
|
|
84
|
+
async def __call__(self, scope: ASGIScope, receive: ASGIReceive, send: ASGISend) -> None:
|
|
85
|
+
if scope.get("type") == "lifespan" and self._manage_backend:
|
|
86
|
+
# Run the app's own lifespan, then close the backend on shutdown so the
|
|
87
|
+
# pool + subscriber task don't leak. aclose is optional on the backend
|
|
88
|
+
# Protocol, so call it only if present (custom backends may omit it).
|
|
89
|
+
try:
|
|
90
|
+
await self.app(scope, receive, send)
|
|
91
|
+
finally:
|
|
92
|
+
aclose = getattr(self._backend, "aclose", None)
|
|
93
|
+
if aclose is not None:
|
|
94
|
+
try:
|
|
95
|
+
await aclose()
|
|
96
|
+
except Exception:
|
|
97
|
+
_logger.exception("idemkit: error closing backend on shutdown")
|
|
98
|
+
return
|
|
99
|
+
|
|
100
|
+
if scope.get("type") != "http":
|
|
101
|
+
await self.app(scope, receive, send)
|
|
102
|
+
return
|
|
103
|
+
|
|
104
|
+
# Read and buffer the request body so both fingerprinting and the
|
|
105
|
+
# downstream handler can see it. Per spec §4.5, the body is decoded
|
|
106
|
+
# (Content-Encoding handled by the server upstream of ASGI). The buffer
|
|
107
|
+
# is bounded by ``max_request_body_bytes`` to prevent a large-request
|
|
108
|
+
# memory DoS; an oversized request is streamed through WITHOUT
|
|
109
|
+
# idempotency rather than buffered whole.
|
|
110
|
+
max_request = self.config.max_request_body_bytes
|
|
111
|
+
body_chunks: list[bytes] = []
|
|
112
|
+
body_size = 0
|
|
113
|
+
client_disconnected = False
|
|
114
|
+
oversized_request = False
|
|
115
|
+
trailing_more = False
|
|
116
|
+
more = True
|
|
117
|
+
while more:
|
|
118
|
+
message = await receive()
|
|
119
|
+
mtype = message["type"]
|
|
120
|
+
if mtype == "http.request":
|
|
121
|
+
chunk = message.get("body", b"") or b""
|
|
122
|
+
body_chunks.append(chunk)
|
|
123
|
+
body_size += len(chunk)
|
|
124
|
+
more = message.get("more_body", False)
|
|
125
|
+
if max_request is not None and body_size > max_request:
|
|
126
|
+
oversized_request = True
|
|
127
|
+
trailing_more = more
|
|
128
|
+
break
|
|
129
|
+
elif mtype == "http.disconnect":
|
|
130
|
+
client_disconnected = True
|
|
131
|
+
break
|
|
132
|
+
else:
|
|
133
|
+
# Unknown message; bail out of buffering and let the
|
|
134
|
+
# downstream app see it via a passthrough receive.
|
|
135
|
+
body_chunks.append(b"")
|
|
136
|
+
more = False
|
|
137
|
+
if client_disconnected:
|
|
138
|
+
# Client gave up before the body finished. Nothing to do.
|
|
139
|
+
return
|
|
140
|
+
|
|
141
|
+
method = (scope.get("method") or "GET").upper()
|
|
142
|
+
path = scope.get("path") or "/"
|
|
143
|
+
query_bytes: bytes = scope.get("query_string", b"") or b""
|
|
144
|
+
try:
|
|
145
|
+
query = query_bytes.decode("latin1")
|
|
146
|
+
except Exception:
|
|
147
|
+
query = ""
|
|
148
|
+
|
|
149
|
+
headers = _read_headers(scope.get("headers") or [])
|
|
150
|
+
|
|
151
|
+
if oversized_request:
|
|
152
|
+
# Too large to buffer for fingerprinting, so idempotency is bypassed.
|
|
153
|
+
# The require_key_for_mutations gate is a separate safety control and
|
|
154
|
+
# MUST still apply: don't let an oversized body smuggle a mutation
|
|
155
|
+
# past it.
|
|
156
|
+
prefix = b"".join(body_chunks)
|
|
157
|
+
key = self._extract_idempotency_key(scope, headers, prefix)
|
|
158
|
+
if (
|
|
159
|
+
self.config.require_key_for_mutations
|
|
160
|
+
and method in self.config.applicable_methods
|
|
161
|
+
and not key
|
|
162
|
+
):
|
|
163
|
+
status, p_headers, p_body = problem_details.missing_key()
|
|
164
|
+
await _send_neutral_response(
|
|
165
|
+
send,
|
|
166
|
+
NeutralResponse(status=status, headers=p_headers, body=p_body),
|
|
167
|
+
)
|
|
168
|
+
return
|
|
169
|
+
_logger.warning(
|
|
170
|
+
"idemkit: request body exceeded max_request_body_bytes=%s; "
|
|
171
|
+
"bypassing idempotency and streaming the request through.",
|
|
172
|
+
max_request,
|
|
173
|
+
)
|
|
174
|
+
chained = _build_chained_receive(body_chunks, trailing_more, receive)
|
|
175
|
+
await self.app(scope, chained, send)
|
|
176
|
+
return
|
|
177
|
+
|
|
178
|
+
full_body = b"".join(body_chunks)
|
|
179
|
+
|
|
180
|
+
idempotency_key = self._extract_idempotency_key(scope, headers, full_body)
|
|
181
|
+
caller_scope = self._extract_caller_identity(scope)
|
|
182
|
+
|
|
183
|
+
request = NeutralRequest(
|
|
184
|
+
idempotency_key=idempotency_key,
|
|
185
|
+
scope=caller_scope,
|
|
186
|
+
method=method,
|
|
187
|
+
path=path,
|
|
188
|
+
query=query,
|
|
189
|
+
body=full_body,
|
|
190
|
+
content_type=headers.get("content-type"),
|
|
191
|
+
)
|
|
192
|
+
|
|
193
|
+
outcome = await self.engine.decide(request)
|
|
194
|
+
|
|
195
|
+
if outcome.kind == "replay":
|
|
196
|
+
assert outcome.response is not None
|
|
197
|
+
await _send_neutral_response(send, outcome.response)
|
|
198
|
+
return
|
|
199
|
+
|
|
200
|
+
if outcome.kind == "reject":
|
|
201
|
+
assert outcome.response is not None
|
|
202
|
+
await _send_neutral_response(send, outcome.response)
|
|
203
|
+
return
|
|
204
|
+
|
|
205
|
+
# pass_through — replay the buffered body to the downstream app and
|
|
206
|
+
# capture its response so we can call engine.on_complete / on_error.
|
|
207
|
+
receive_replay = _build_receive_replay(full_body)
|
|
208
|
+
|
|
209
|
+
if outcome.effective_key is None or outcome.claim_token is None:
|
|
210
|
+
# Idempotency does not apply to this request (e.g., method not in
|
|
211
|
+
# applicable_methods or key absent and not required). Just forward.
|
|
212
|
+
await self.app(scope, receive_replay, send)
|
|
213
|
+
return
|
|
214
|
+
|
|
215
|
+
capturer = _ResponseCapturer(
|
|
216
|
+
inner_send=send,
|
|
217
|
+
max_body_bytes=self.config.max_body_bytes,
|
|
218
|
+
)
|
|
219
|
+
|
|
220
|
+
try:
|
|
221
|
+
await self.app(scope, receive_replay, capturer.send)
|
|
222
|
+
except Exception:
|
|
223
|
+
await self.engine.on_error(outcome.effective_key, outcome.claim_token)
|
|
224
|
+
raise
|
|
225
|
+
|
|
226
|
+
if not capturer.completed:
|
|
227
|
+
# Handler did not produce a complete response (e.g., disconnect).
|
|
228
|
+
await self.engine.on_error(outcome.effective_key, outcome.claim_token)
|
|
229
|
+
return
|
|
230
|
+
|
|
231
|
+
if capturer.oversize:
|
|
232
|
+
# Spec §4.9: response exceeded max_body_bytes. Don't cache it —
|
|
233
|
+
# release the claim so a retry can re-execute. Client already
|
|
234
|
+
# received the full response from the streaming pass-through.
|
|
235
|
+
await self.engine.on_error(outcome.effective_key, outcome.claim_token)
|
|
236
|
+
return
|
|
237
|
+
|
|
238
|
+
captured = capturer.to_neutral()
|
|
239
|
+
await self.engine.on_complete(outcome.effective_key, outcome.claim_token, captured)
|
|
240
|
+
|
|
241
|
+
# ----- extraction hooks -----
|
|
242
|
+
|
|
243
|
+
def _extract_idempotency_key(
|
|
244
|
+
self,
|
|
245
|
+
scope: ASGIScope,
|
|
246
|
+
headers: dict[str, str],
|
|
247
|
+
body: bytes,
|
|
248
|
+
) -> str | None:
|
|
249
|
+
if self.config.key is not None:
|
|
250
|
+
# Custom extractor sees a minimal request-like object via scope.
|
|
251
|
+
request_proxy = _RequestProxy(scope, headers, body)
|
|
252
|
+
try:
|
|
253
|
+
return self.config.key(request_proxy)
|
|
254
|
+
except Exception:
|
|
255
|
+
_logger.exception("idemkit: key raised; treating as no key")
|
|
256
|
+
return None
|
|
257
|
+
return _unwrap_sf_string(headers.get("idempotency-key"))
|
|
258
|
+
|
|
259
|
+
def _extract_caller_identity(self, scope: ASGIScope) -> str | None:
|
|
260
|
+
if self.config.scope is None:
|
|
261
|
+
return None # only reachable in single-tenant mode
|
|
262
|
+
request_proxy = _RequestProxy(scope, _read_headers(scope.get("headers") or []), b"")
|
|
263
|
+
try:
|
|
264
|
+
value = self.config.scope(request_proxy)
|
|
265
|
+
except Exception:
|
|
266
|
+
# The extractor raised — commonly because it reached for something the
|
|
267
|
+
# middleware proxy doesn't have (e.g. `req.state` / `req.client`; the
|
|
268
|
+
# proxy exposes `req.headers` / `req.scope` / `req.body` only). We do
|
|
269
|
+
# NOT bucket the request as anonymous: a configured-but-failing
|
|
270
|
+
# extractor fails closed (the engine returns 500, §4.6), because
|
|
271
|
+
# silently sharing a namespace is the cross-tenant bug we prevent.
|
|
272
|
+
_logger.exception(
|
|
273
|
+
"idemkit: scope extractor raised; this request will be "
|
|
274
|
+
"refused (500). The middleware passes extractors a lightweight "
|
|
275
|
+
"proxy with req.headers / req.scope / req.body — not a Starlette "
|
|
276
|
+
"Request, so req.state / req.client are unavailable here. Read "
|
|
277
|
+
"identity from a header or req.scope, or use the @Idempotency "
|
|
278
|
+
"decorator (which gets the real Request)."
|
|
279
|
+
)
|
|
280
|
+
return None
|
|
281
|
+
return value or None
|
|
282
|
+
|
|
283
|
+
|
|
284
|
+
class _CaseInsensitiveHeaders(dict[str, str]):
|
|
285
|
+
"""A header dict with case-insensitive lookups.
|
|
286
|
+
|
|
287
|
+
HTTP header names are case-insensitive, and extractors written against a
|
|
288
|
+
Starlette ``Request`` expect ``req.headers.get("X-User-Id")`` to work
|
|
289
|
+
regardless of case. Keys are stored lowercased; lookups lowercase the query.
|
|
290
|
+
"""
|
|
291
|
+
|
|
292
|
+
def __getitem__(self, key: str) -> str:
|
|
293
|
+
return super().__getitem__(key.lower())
|
|
294
|
+
|
|
295
|
+
def get(self, key: str, default: str | None = None) -> str | None: # type: ignore[override]
|
|
296
|
+
return super().get(key.lower(), default)
|
|
297
|
+
|
|
298
|
+
def __contains__(self, key: object) -> bool:
|
|
299
|
+
return super().__contains__(key.lower() if isinstance(key, str) else key)
|
|
300
|
+
|
|
301
|
+
|
|
302
|
+
class _RequestProxy:
|
|
303
|
+
"""Minimal request-like object exposed to user-defined extractors.
|
|
304
|
+
|
|
305
|
+
Extractors receive this for raw ASGI. It exposes:
|
|
306
|
+
|
|
307
|
+
* ``headers`` — case-insensitive ``dict``-like of request headers;
|
|
308
|
+
* ``scope`` — the raw ASGI scope (e.g. ``scope["client"]`` for the peer
|
|
309
|
+
address, ``scope["state"]`` for data set by upstream middleware);
|
|
310
|
+
* ``body`` — the raw request body bytes (passed to ``key`` only).
|
|
311
|
+
"""
|
|
312
|
+
|
|
313
|
+
__slots__ = ("body", "headers", "scope")
|
|
314
|
+
|
|
315
|
+
def __init__(self, scope: ASGIScope, headers: dict[str, str], body: bytes) -> None:
|
|
316
|
+
self.scope = scope
|
|
317
|
+
self.headers = headers
|
|
318
|
+
self.body = body
|
|
319
|
+
|
|
320
|
+
|
|
321
|
+
def _unwrap_sf_string(value: str | None) -> str | None:
|
|
322
|
+
"""Accept both quoted and unquoted Idempotency-Key forms (§6.1).
|
|
323
|
+
|
|
324
|
+
The IETF draft specifies an RFC 8941 Structured Field String (quoted), but
|
|
325
|
+
most clients send the value bare. Strip the surrounding quotes (and unescape
|
|
326
|
+
``\\"`` / ``\\\\``) so ``"abc"`` and ``abc`` map to the same key.
|
|
327
|
+
"""
|
|
328
|
+
if value is None:
|
|
329
|
+
return None
|
|
330
|
+
v = value.strip()
|
|
331
|
+
if len(v) >= 2 and v[0] == '"' and v[-1] == '"':
|
|
332
|
+
out: list[str] = []
|
|
333
|
+
escaped = False
|
|
334
|
+
for ch in v[1:-1]:
|
|
335
|
+
if escaped:
|
|
336
|
+
out.append(ch)
|
|
337
|
+
escaped = False
|
|
338
|
+
elif ch == "\\":
|
|
339
|
+
escaped = True
|
|
340
|
+
else:
|
|
341
|
+
out.append(ch)
|
|
342
|
+
return "".join(out)
|
|
343
|
+
return value
|
|
344
|
+
|
|
345
|
+
|
|
346
|
+
def _read_headers(raw: list[tuple[bytes, bytes]]) -> dict[str, str]:
|
|
347
|
+
"""ASGI headers list → case-insensitive lowercase dict (last wins)."""
|
|
348
|
+
out: _CaseInsensitiveHeaders = _CaseInsensitiveHeaders()
|
|
349
|
+
for name, value in raw:
|
|
350
|
+
try:
|
|
351
|
+
n = name.decode("latin1").lower()
|
|
352
|
+
v = value.decode("latin1")
|
|
353
|
+
except Exception:
|
|
354
|
+
continue
|
|
355
|
+
out[n] = v
|
|
356
|
+
return out
|
|
357
|
+
|
|
358
|
+
|
|
359
|
+
def _build_receive_replay(body: bytes) -> ASGIReceive:
|
|
360
|
+
"""Return a receive callable that replays the buffered body once."""
|
|
361
|
+
state = {"sent": False}
|
|
362
|
+
|
|
363
|
+
async def receive() -> ASGIMessage:
|
|
364
|
+
if not state["sent"]:
|
|
365
|
+
state["sent"] = True
|
|
366
|
+
return {"type": "http.request", "body": body, "more_body": False}
|
|
367
|
+
# After body is delivered, the downstream app should not call receive
|
|
368
|
+
# again, but if it does (some frameworks), report disconnect.
|
|
369
|
+
return {"type": "http.disconnect"}
|
|
370
|
+
|
|
371
|
+
return receive
|
|
372
|
+
|
|
373
|
+
|
|
374
|
+
def _build_chained_receive(
|
|
375
|
+
buffered: list[bytes], more_remaining: bool, original: ASGIReceive
|
|
376
|
+
) -> ASGIReceive:
|
|
377
|
+
"""Receive callable for the oversized-request bypass path.
|
|
378
|
+
|
|
379
|
+
Replays the buffered prefix as one ``http.request`` message, then (if the
|
|
380
|
+
body wasn't fully read) delegates to the original ``receive`` for the
|
|
381
|
+
remaining messages. This bounds idemkit's memory to the cap while letting
|
|
382
|
+
the handler consume the full request.
|
|
383
|
+
"""
|
|
384
|
+
state = {"sent": False}
|
|
385
|
+
|
|
386
|
+
async def receive() -> ASGIMessage:
|
|
387
|
+
if not state["sent"]:
|
|
388
|
+
state["sent"] = True
|
|
389
|
+
return {
|
|
390
|
+
"type": "http.request",
|
|
391
|
+
"body": b"".join(buffered),
|
|
392
|
+
"more_body": more_remaining,
|
|
393
|
+
}
|
|
394
|
+
if more_remaining:
|
|
395
|
+
return await original()
|
|
396
|
+
return {"type": "http.disconnect"}
|
|
397
|
+
|
|
398
|
+
return receive
|
|
399
|
+
|
|
400
|
+
|
|
401
|
+
async def _send_neutral_response(send: ASGISend, response: NeutralResponse) -> None:
|
|
402
|
+
headers_list = [
|
|
403
|
+
(name.encode("latin1"), value.encode("latin1")) for name, value in response.headers.items()
|
|
404
|
+
]
|
|
405
|
+
await send(
|
|
406
|
+
{
|
|
407
|
+
"type": "http.response.start",
|
|
408
|
+
"status": response.status,
|
|
409
|
+
"headers": headers_list,
|
|
410
|
+
}
|
|
411
|
+
)
|
|
412
|
+
await send(
|
|
413
|
+
{
|
|
414
|
+
"type": "http.response.body",
|
|
415
|
+
"body": response.body,
|
|
416
|
+
"more_body": False,
|
|
417
|
+
}
|
|
418
|
+
)
|
|
419
|
+
|
|
420
|
+
|
|
421
|
+
class _ResponseCapturer:
|
|
422
|
+
"""Wraps the outer ASGI ``send`` to capture status, headers, and body.
|
|
423
|
+
|
|
424
|
+
Caching decision is made at the first body message, by its ``more_body`` flag:
|
|
425
|
+
|
|
426
|
+
* **Single-shot** (``more_body=False``): the whole body is in this one
|
|
427
|
+
message. If it fits in ``max_body_bytes`` it is cached and sent verbatim;
|
|
428
|
+
if not, the start message is sent with an added
|
|
429
|
+
``Idempotency-Replay-Unavailable: size-exceeded`` header and the body is
|
|
430
|
+
passed through uncached (spec §4.9 / §6.2). Enforcement counts the actual
|
|
431
|
+
bytes in hand, never the ``Content-Length`` header.
|
|
432
|
+
* **Incremental / streaming** (``more_body=True``): buffering would delay
|
|
433
|
+
delivery to the client, so the response is NOT buffered. The start message
|
|
434
|
+
goes out with ``Idempotency-Replay-Unavailable: streaming`` and every chunk
|
|
435
|
+
streams straight through, uncached (spec §4.9 streaming bypass).
|
|
436
|
+
|
|
437
|
+
The start message is otherwise forwarded VERBATIM, preserving duplicate
|
|
438
|
+
headers like multiple ``Set-Cookie`` on the path to the first client. A
|
|
439
|
+
separate dict view of the headers is built for storage (where duplicates do
|
|
440
|
+
not survive anyway because the allow/deny filter drops Set-Cookie etc.).
|
|
441
|
+
"""
|
|
442
|
+
|
|
443
|
+
def __init__(self, inner_send: ASGISend, max_body_bytes: int) -> None:
|
|
444
|
+
self._send = inner_send
|
|
445
|
+
self._max = max_body_bytes
|
|
446
|
+
self.status: int = 0
|
|
447
|
+
self.headers: dict[str, str] = {}
|
|
448
|
+
self._start_message: ASGIMessage | None = None
|
|
449
|
+
self._start_sent: bool = False
|
|
450
|
+
self._first_body_seen: bool = False
|
|
451
|
+
self._body_chunks: list[bytes] = []
|
|
452
|
+
self.oversize: bool = False
|
|
453
|
+
self.completed: bool = False
|
|
454
|
+
|
|
455
|
+
@property
|
|
456
|
+
def body(self) -> bytes:
|
|
457
|
+
if self.oversize:
|
|
458
|
+
return b""
|
|
459
|
+
return b"".join(self._body_chunks)
|
|
460
|
+
|
|
461
|
+
def to_neutral(self) -> NeutralResponse:
|
|
462
|
+
return NeutralResponse(
|
|
463
|
+
status=self.status,
|
|
464
|
+
headers=dict(self.headers),
|
|
465
|
+
body=self.body,
|
|
466
|
+
)
|
|
467
|
+
|
|
468
|
+
async def _flush_start(self, *, bypass_reason: str | None = None) -> None:
|
|
469
|
+
if self._start_sent or self._start_message is None:
|
|
470
|
+
return
|
|
471
|
+
message = self._start_message
|
|
472
|
+
if bypass_reason is not None:
|
|
473
|
+
headers = list(message.get("headers", []))
|
|
474
|
+
headers.append((b"idempotency-replay-unavailable", bypass_reason.encode("latin1")))
|
|
475
|
+
message = {**message, "headers": headers}
|
|
476
|
+
self._start_sent = True
|
|
477
|
+
await self._send(message)
|
|
478
|
+
|
|
479
|
+
async def send(self, message: ASGIMessage) -> None:
|
|
480
|
+
mtype = message["type"]
|
|
481
|
+
if mtype == "http.response.start":
|
|
482
|
+
self.status = int(message["status"])
|
|
483
|
+
# Build a dict view for storage (duplicates not significant here
|
|
484
|
+
# because Set-Cookie / WWW-Authenticate are denied by §4.10).
|
|
485
|
+
self.headers = {
|
|
486
|
+
k.decode("latin1").lower(): v.decode("latin1")
|
|
487
|
+
for k, v in message.get("headers", [])
|
|
488
|
+
}
|
|
489
|
+
# Hold the start message just long enough to learn, at the first body
|
|
490
|
+
# message, whether the response is cacheable (the bypass header, if
|
|
491
|
+
# any, has to precede the body).
|
|
492
|
+
self._start_message = message
|
|
493
|
+
return
|
|
494
|
+
if mtype != "http.response.body":
|
|
495
|
+
await self._send(message)
|
|
496
|
+
return
|
|
497
|
+
|
|
498
|
+
chunk = message.get("body", b"") or b""
|
|
499
|
+
more = message.get("more_body", False)
|
|
500
|
+
|
|
501
|
+
if self.oversize:
|
|
502
|
+
# Already streaming through (size-exceeded or streaming bypass).
|
|
503
|
+
await self._send(message)
|
|
504
|
+
if not more:
|
|
505
|
+
self.completed = True
|
|
506
|
+
return
|
|
507
|
+
|
|
508
|
+
if not self._first_body_seen:
|
|
509
|
+
self._first_body_seen = True
|
|
510
|
+
if more:
|
|
511
|
+
# Incremental response: don't buffer (it would stall delivery).
|
|
512
|
+
self.oversize = True
|
|
513
|
+
await self._flush_start(bypass_reason="streaming")
|
|
514
|
+
await self._send(message)
|
|
515
|
+
return
|
|
516
|
+
# Single-shot: the whole body is this chunk. Enforce the cap on the
|
|
517
|
+
# bytes actually in hand (not Content-Length).
|
|
518
|
+
if len(chunk) > self._max:
|
|
519
|
+
self.oversize = True
|
|
520
|
+
await self._flush_start(bypass_reason="size-exceeded")
|
|
521
|
+
await self._send(message)
|
|
522
|
+
self.completed = True
|
|
523
|
+
return
|
|
524
|
+
self._body_chunks.append(chunk)
|
|
525
|
+
await self._flush_start()
|
|
526
|
+
await self._send(message)
|
|
527
|
+
self.completed = True
|
|
528
|
+
return
|
|
529
|
+
|
|
530
|
+
# Defensive: a single-shot response shouldn't emit further body messages.
|
|
531
|
+
await self._send(message)
|
|
532
|
+
if not more:
|
|
533
|
+
self.completed = True
|