hishel 1.0.0.dev2__py3-none-any.whl → 1.0.0.dev3__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.
- hishel/__init__.py +15 -14
- hishel/_async_cache.py +42 -36
- hishel/_async_httpx.py +243 -0
- hishel/_core/_headers.py +11 -1
- hishel/_core/_spec.py +88 -84
- hishel/_core/_storages/_async_base.py +71 -0
- hishel/_core/{_async/_storages/_sqlite.py → _storages/_async_sqlite.py} +95 -132
- hishel/_core/_storages/_packing.py +144 -0
- hishel/_core/_storages/_sync_base.py +71 -0
- hishel/_core/{_sync/_storages/_sqlite.py → _storages/_sync_sqlite.py} +95 -132
- hishel/_core/models.py +6 -22
- hishel/_sync_cache.py +42 -36
- hishel/_sync_httpx.py +243 -0
- hishel/_utils.py +49 -2
- hishel/asgi.py +400 -0
- hishel/fastapi.py +263 -0
- hishel/httpx.py +3 -326
- hishel/requests.py +25 -17
- {hishel-1.0.0.dev2.dist-info → hishel-1.0.0.dev3.dist-info}/METADATA +100 -6
- hishel-1.0.0.dev3.dist-info/RECORD +23 -0
- hishel/_core/__init__.py +0 -59
- hishel/_core/_base/_storages/_base.py +0 -272
- hishel/_core/_base/_storages/_packing.py +0 -165
- hishel-1.0.0.dev2.dist-info/RECORD +0 -19
- {hishel-1.0.0.dev2.dist-info → hishel-1.0.0.dev3.dist-info}/WHEEL +0 -0
- {hishel-1.0.0.dev2.dist-info → hishel-1.0.0.dev3.dist-info}/licenses/LICENSE +0 -0
hishel/asgi.py
ADDED
|
@@ -0,0 +1,400 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import logging
|
|
4
|
+
import typing as t
|
|
5
|
+
from email.utils import formatdate
|
|
6
|
+
from typing import AsyncIterator
|
|
7
|
+
|
|
8
|
+
from hishel import AsyncBaseStorage, CacheOptions, Headers, Request, Response
|
|
9
|
+
from hishel._async_cache import AsyncCacheProxy
|
|
10
|
+
|
|
11
|
+
# Configure logger for this module
|
|
12
|
+
logger = logging.getLogger(__name__)
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
class _ASGIScope(t.TypedDict, total=False):
|
|
16
|
+
"""ASGI HTTP scope type."""
|
|
17
|
+
|
|
18
|
+
type: str
|
|
19
|
+
asgi: dict[str, str]
|
|
20
|
+
http_version: str
|
|
21
|
+
method: str
|
|
22
|
+
scheme: str
|
|
23
|
+
path: str
|
|
24
|
+
query_string: bytes
|
|
25
|
+
root_path: str
|
|
26
|
+
headers: list[tuple[bytes, bytes]]
|
|
27
|
+
server: tuple[str, int | None] | None
|
|
28
|
+
client: tuple[str, int] | None
|
|
29
|
+
state: dict[str, t.Any]
|
|
30
|
+
extensions: dict[str, t.Any]
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
_Scope = _ASGIScope
|
|
34
|
+
_Receive = t.Callable[[], t.Awaitable[dict[str, t.Any]]]
|
|
35
|
+
_Send = t.Callable[[dict[str, t.Any]], t.Awaitable[None]]
|
|
36
|
+
_ASGIApp = t.Callable[[_Scope, _Receive, _Send], t.Awaitable[None]]
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
class ASGICacheMiddleware:
|
|
40
|
+
"""
|
|
41
|
+
ASGI middleware that provides HTTP caching capabilities.
|
|
42
|
+
|
|
43
|
+
This middleware intercepts HTTP requests and responses, caching them
|
|
44
|
+
according to HTTP caching specifications (RFC 9111) or custom rules.
|
|
45
|
+
|
|
46
|
+
The middleware uses async iterators for request and response bodies,
|
|
47
|
+
ensuring memory-efficient streaming without loading entire payloads
|
|
48
|
+
into memory. This is particularly important for large file uploads
|
|
49
|
+
or downloads.
|
|
50
|
+
|
|
51
|
+
This implementation is thread-safe by creating a new cache proxy for
|
|
52
|
+
each request with closures that capture the request context.
|
|
53
|
+
|
|
54
|
+
Args:
|
|
55
|
+
app: The ASGI application to wrap.
|
|
56
|
+
storage: The storage backend to use for caching. Defaults to AsyncSqliteStorage.
|
|
57
|
+
cache_options: Configuration options for caching behavior.
|
|
58
|
+
ignore_specification: If True, bypasses HTTP caching rules and caches all responses.
|
|
59
|
+
|
|
60
|
+
Example:
|
|
61
|
+
```python
|
|
62
|
+
from hishel.asgi import ASGICacheMiddleware
|
|
63
|
+
from hishel import AsyncSqliteStorage, CacheOptions
|
|
64
|
+
|
|
65
|
+
# Wrap your ASGI app
|
|
66
|
+
app = ASGICacheMiddleware(
|
|
67
|
+
app=my_asgi_app,
|
|
68
|
+
storage=AsyncSqliteStorage(),
|
|
69
|
+
cache_options=CacheOptions(),
|
|
70
|
+
)
|
|
71
|
+
```
|
|
72
|
+
"""
|
|
73
|
+
|
|
74
|
+
def __init__(
|
|
75
|
+
self,
|
|
76
|
+
app: _ASGIApp,
|
|
77
|
+
storage: AsyncBaseStorage | None = None,
|
|
78
|
+
cache_options: CacheOptions | None = None,
|
|
79
|
+
ignore_specification: bool = False,
|
|
80
|
+
) -> None:
|
|
81
|
+
self.app = app
|
|
82
|
+
self.storage = storage
|
|
83
|
+
self._cache_options = cache_options
|
|
84
|
+
self._ignore_specification = ignore_specification
|
|
85
|
+
|
|
86
|
+
logger.info(
|
|
87
|
+
"Initialized ASGICacheMiddleware with storage=%s, ignore_specification=%s",
|
|
88
|
+
type(storage).__name__ if storage else "None",
|
|
89
|
+
ignore_specification,
|
|
90
|
+
)
|
|
91
|
+
|
|
92
|
+
async def __call__(self, scope: _Scope, receive: _Receive, send: _Send) -> None:
|
|
93
|
+
"""
|
|
94
|
+
Handle an ASGI request.
|
|
95
|
+
|
|
96
|
+
Args:
|
|
97
|
+
scope: The ASGI scope dictionary.
|
|
98
|
+
receive: The ASGI receive callable.
|
|
99
|
+
send: The ASGI send callable.
|
|
100
|
+
"""
|
|
101
|
+
# Only handle HTTP requests
|
|
102
|
+
if scope["type"] != "http":
|
|
103
|
+
logger.debug("Skipping non-HTTP request: type=%s", scope["type"])
|
|
104
|
+
await self.app(scope, receive, send)
|
|
105
|
+
return
|
|
106
|
+
|
|
107
|
+
method = scope.get("method", "UNKNOWN")
|
|
108
|
+
path = scope.get("path", "/")
|
|
109
|
+
query_string = scope.get("query_string", b"").decode("latin1")
|
|
110
|
+
full_path = f"{path}?{query_string}" if query_string else path
|
|
111
|
+
|
|
112
|
+
logger.debug("Incoming HTTP request: method=%s path=%s", method, full_path)
|
|
113
|
+
|
|
114
|
+
try:
|
|
115
|
+
# Create a closure that captures scope and receive for this specific request
|
|
116
|
+
# This makes the code thread-safe by avoiding shared instance state
|
|
117
|
+
async def send_request_to_app(request: Request) -> Response:
|
|
118
|
+
"""
|
|
119
|
+
Send a request to the wrapped ASGI application and return the response.
|
|
120
|
+
This closure captures 'scope' and 'receive' from the outer function scope.
|
|
121
|
+
"""
|
|
122
|
+
logger.debug("Sending request to wrapped application: url=%s", request.url)
|
|
123
|
+
|
|
124
|
+
# Create a buffered receive callable that replays the request body from the stream
|
|
125
|
+
body_iterator = request.aiter_stream()
|
|
126
|
+
body_exhausted = False
|
|
127
|
+
bytes_received = 0
|
|
128
|
+
|
|
129
|
+
async def inner_receive() -> dict[str, t.Any]:
|
|
130
|
+
nonlocal body_exhausted, bytes_received
|
|
131
|
+
if body_exhausted:
|
|
132
|
+
return {"type": "http.disconnect"}
|
|
133
|
+
|
|
134
|
+
try:
|
|
135
|
+
chunk = await body_iterator.__anext__()
|
|
136
|
+
bytes_received += len(chunk)
|
|
137
|
+
logger.debug("Received request body chunk: size=%d bytes", len(chunk))
|
|
138
|
+
return {
|
|
139
|
+
"type": "http.request",
|
|
140
|
+
"body": chunk,
|
|
141
|
+
"more_body": True,
|
|
142
|
+
}
|
|
143
|
+
except StopAsyncIteration:
|
|
144
|
+
body_exhausted = True
|
|
145
|
+
logger.debug(
|
|
146
|
+
"Request body fully consumed: total_bytes=%d",
|
|
147
|
+
bytes_received,
|
|
148
|
+
)
|
|
149
|
+
return {
|
|
150
|
+
"type": "http.request",
|
|
151
|
+
"body": b"",
|
|
152
|
+
"more_body": False,
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
# Collect response from the app
|
|
156
|
+
response_started = False
|
|
157
|
+
status_code = 200
|
|
158
|
+
response_headers: list[tuple[bytes, bytes]] = []
|
|
159
|
+
response_body_chunks: list[bytes] = []
|
|
160
|
+
bytes_sent = 0
|
|
161
|
+
|
|
162
|
+
async def inner_send(message: dict[str, t.Any]) -> None:
|
|
163
|
+
nonlocal response_started, status_code, response_headers, bytes_sent
|
|
164
|
+
if message["type"] == "http.response.start":
|
|
165
|
+
response_started = True
|
|
166
|
+
status_code = message["status"]
|
|
167
|
+
response_headers = message.get("headers", [])
|
|
168
|
+
logger.debug("Application response started: status=%d", status_code)
|
|
169
|
+
elif message["type"] == "http.response.body":
|
|
170
|
+
body_chunk = message.get("body", b"")
|
|
171
|
+
if body_chunk:
|
|
172
|
+
response_body_chunks.append(body_chunk)
|
|
173
|
+
bytes_sent += len(body_chunk)
|
|
174
|
+
logger.debug(
|
|
175
|
+
"Received response body chunk: size=%d bytes",
|
|
176
|
+
len(body_chunk),
|
|
177
|
+
)
|
|
178
|
+
|
|
179
|
+
try:
|
|
180
|
+
# Call the wrapped application with captured scope
|
|
181
|
+
await self.app(scope, inner_receive, inner_send)
|
|
182
|
+
logger.info(
|
|
183
|
+
"Application response complete: status=%d total_bytes=%d chunks=%d",
|
|
184
|
+
status_code,
|
|
185
|
+
bytes_sent,
|
|
186
|
+
len(response_body_chunks),
|
|
187
|
+
)
|
|
188
|
+
except Exception as e:
|
|
189
|
+
logger.error(
|
|
190
|
+
"Error calling wrapped application: url=%s error=%s",
|
|
191
|
+
request.url,
|
|
192
|
+
str(e),
|
|
193
|
+
exc_info=True,
|
|
194
|
+
)
|
|
195
|
+
raise
|
|
196
|
+
|
|
197
|
+
# Convert to internal Response
|
|
198
|
+
headers_dict = {key.decode("latin1"): value.decode("latin1") for key, value in response_headers}
|
|
199
|
+
|
|
200
|
+
# Add Date header if not present
|
|
201
|
+
if not any(key.lower() == "date" for key in headers_dict.keys()):
|
|
202
|
+
date_header = formatdate(timeval=None, localtime=False, usegmt=True)
|
|
203
|
+
headers_dict["Date"] = date_header
|
|
204
|
+
logger.debug("Added Date header to response: %s", date_header)
|
|
205
|
+
|
|
206
|
+
async def response_stream() -> AsyncIterator[bytes]:
|
|
207
|
+
for chunk in response_body_chunks:
|
|
208
|
+
yield chunk
|
|
209
|
+
|
|
210
|
+
return Response(
|
|
211
|
+
status_code=status_code,
|
|
212
|
+
headers=Headers(headers_dict),
|
|
213
|
+
stream=response_stream(),
|
|
214
|
+
metadata={},
|
|
215
|
+
)
|
|
216
|
+
|
|
217
|
+
# Create a new cache proxy for this request with the closure
|
|
218
|
+
# This ensures complete isolation between concurrent requests
|
|
219
|
+
cache_proxy = AsyncCacheProxy(
|
|
220
|
+
request_sender=send_request_to_app,
|
|
221
|
+
storage=self.storage,
|
|
222
|
+
cache_options=self._cache_options,
|
|
223
|
+
ignore_specification=self._ignore_specification,
|
|
224
|
+
)
|
|
225
|
+
|
|
226
|
+
# Convert ASGI request to internal Request (using async iterator, not reading into memory)
|
|
227
|
+
request = self._asgi_to_internal_request(scope, receive)
|
|
228
|
+
logger.debug("Converted ASGI request to internal format: url=%s", request.url)
|
|
229
|
+
|
|
230
|
+
# Handle request through cache proxy
|
|
231
|
+
logger.debug("Handling request through cache proxy")
|
|
232
|
+
response = await cache_proxy.handle_request(request)
|
|
233
|
+
|
|
234
|
+
logger.info(
|
|
235
|
+
"Request processed: method=%s path=%s status=%d",
|
|
236
|
+
method,
|
|
237
|
+
full_path,
|
|
238
|
+
response.status_code,
|
|
239
|
+
)
|
|
240
|
+
|
|
241
|
+
# Send the cached or fresh response
|
|
242
|
+
await self._send_internal_response(response, send)
|
|
243
|
+
logger.debug("Response sent successfully")
|
|
244
|
+
|
|
245
|
+
except Exception as e:
|
|
246
|
+
logger.error(
|
|
247
|
+
"Error processing request: method=%s path=%s error=%s",
|
|
248
|
+
method,
|
|
249
|
+
full_path,
|
|
250
|
+
str(e),
|
|
251
|
+
exc_info=True,
|
|
252
|
+
)
|
|
253
|
+
raise
|
|
254
|
+
|
|
255
|
+
def _asgi_to_internal_request(self, scope: _Scope, receive: _Receive) -> Request:
|
|
256
|
+
"""
|
|
257
|
+
Convert an ASGI HTTP scope to an internal Request object.
|
|
258
|
+
|
|
259
|
+
Args:
|
|
260
|
+
scope: The ASGI scope dictionary.
|
|
261
|
+
receive: The ASGI receive callable.
|
|
262
|
+
|
|
263
|
+
Returns:
|
|
264
|
+
The internal Request object.
|
|
265
|
+
"""
|
|
266
|
+
# Build URL
|
|
267
|
+
scheme = scope.get("scheme", "http")
|
|
268
|
+
server = scope.get("server")
|
|
269
|
+
|
|
270
|
+
if server is None:
|
|
271
|
+
server = ("localhost", 80)
|
|
272
|
+
logger.debug("No server info in scope, using default: localhost:80")
|
|
273
|
+
|
|
274
|
+
host = server[0]
|
|
275
|
+
port = server[1] if server[1] is not None else (443 if scheme == "https" else 80)
|
|
276
|
+
|
|
277
|
+
# Add port to host if non-standard
|
|
278
|
+
if (scheme == "http" and port != 80) or (scheme == "https" and port != 443):
|
|
279
|
+
host = f"{host}:{port}"
|
|
280
|
+
|
|
281
|
+
path = scope.get("path", "/")
|
|
282
|
+
query_string = scope.get("query_string", b"")
|
|
283
|
+
if query_string:
|
|
284
|
+
path = f"{path}?{query_string.decode('latin1')}"
|
|
285
|
+
|
|
286
|
+
url = f"{scheme}://{host}{path}"
|
|
287
|
+
method = scope.get("method", "GET")
|
|
288
|
+
|
|
289
|
+
# Extract headers
|
|
290
|
+
headers_dict = {key.decode("latin1"): value.decode("latin1") for key, value in scope.get("headers", [])}
|
|
291
|
+
|
|
292
|
+
logger.debug(
|
|
293
|
+
"Building internal request: method=%s url=%s headers_count=%d",
|
|
294
|
+
method,
|
|
295
|
+
url,
|
|
296
|
+
len(headers_dict),
|
|
297
|
+
)
|
|
298
|
+
|
|
299
|
+
# Create async iterator for request body that reads from ASGI receive
|
|
300
|
+
async def request_stream() -> AsyncIterator[bytes]:
|
|
301
|
+
while True:
|
|
302
|
+
message = await receive()
|
|
303
|
+
if message["type"] == "http.request":
|
|
304
|
+
body = message.get("body", b"")
|
|
305
|
+
if body:
|
|
306
|
+
yield body
|
|
307
|
+
if not message.get("more_body", False):
|
|
308
|
+
break
|
|
309
|
+
elif message["type"] == "http.disconnect":
|
|
310
|
+
logger.debug("Client disconnected during request body streaming")
|
|
311
|
+
break
|
|
312
|
+
|
|
313
|
+
return Request(
|
|
314
|
+
method=method,
|
|
315
|
+
url=url,
|
|
316
|
+
headers=Headers(headers_dict),
|
|
317
|
+
stream=request_stream(),
|
|
318
|
+
# Metadatas don't make sense in ASGI scope, so we leave it empty
|
|
319
|
+
metadata={},
|
|
320
|
+
)
|
|
321
|
+
|
|
322
|
+
async def _send_internal_response(self, response: Response, send: _Send) -> None:
|
|
323
|
+
"""
|
|
324
|
+
Send an internal Response to the ASGI send callable.
|
|
325
|
+
|
|
326
|
+
Args:
|
|
327
|
+
response: The internal Response object.
|
|
328
|
+
send: The ASGI send callable.
|
|
329
|
+
"""
|
|
330
|
+
logger.debug(
|
|
331
|
+
"Sending response to client: status=%d headers_count=%d",
|
|
332
|
+
response.status_code,
|
|
333
|
+
len(response.headers),
|
|
334
|
+
)
|
|
335
|
+
|
|
336
|
+
# Convert headers to ASGI format
|
|
337
|
+
headers: list[tuple[bytes, bytes]] = [
|
|
338
|
+
(key.encode("latin1"), value.encode("latin1")) for key, value in response.headers.items()
|
|
339
|
+
]
|
|
340
|
+
|
|
341
|
+
try:
|
|
342
|
+
# Send response.start
|
|
343
|
+
await send(
|
|
344
|
+
{
|
|
345
|
+
"type": "http.response.start",
|
|
346
|
+
"status": response.status_code,
|
|
347
|
+
"headers": headers,
|
|
348
|
+
}
|
|
349
|
+
)
|
|
350
|
+
logger.debug("Response headers sent")
|
|
351
|
+
|
|
352
|
+
# Send response body in chunks
|
|
353
|
+
bytes_sent = 0
|
|
354
|
+
chunk_count = 0
|
|
355
|
+
async for chunk in response.aiter_stream():
|
|
356
|
+
await send(
|
|
357
|
+
{
|
|
358
|
+
"type": "http.response.body",
|
|
359
|
+
"body": chunk,
|
|
360
|
+
"more_body": True,
|
|
361
|
+
}
|
|
362
|
+
)
|
|
363
|
+
bytes_sent += len(chunk)
|
|
364
|
+
chunk_count += 1
|
|
365
|
+
logger.debug("Sent response chunk: size=%d bytes", len(chunk))
|
|
366
|
+
|
|
367
|
+
# Send final empty chunk to signal end
|
|
368
|
+
await send(
|
|
369
|
+
{
|
|
370
|
+
"type": "http.response.body",
|
|
371
|
+
"body": b"",
|
|
372
|
+
"more_body": False,
|
|
373
|
+
}
|
|
374
|
+
)
|
|
375
|
+
logger.info(
|
|
376
|
+
"Response fully sent: status=%d total_bytes=%d chunks=%d",
|
|
377
|
+
response.status_code,
|
|
378
|
+
bytes_sent,
|
|
379
|
+
chunk_count,
|
|
380
|
+
)
|
|
381
|
+
|
|
382
|
+
except Exception as e:
|
|
383
|
+
logger.error(
|
|
384
|
+
"Error sending response: status=%d error=%s",
|
|
385
|
+
response.status_code,
|
|
386
|
+
str(e),
|
|
387
|
+
exc_info=True,
|
|
388
|
+
)
|
|
389
|
+
raise
|
|
390
|
+
|
|
391
|
+
async def aclose(self) -> None:
|
|
392
|
+
"""Close the storage backend and release resources."""
|
|
393
|
+
logger.info("Closing ASGICacheMiddleware and storage backend")
|
|
394
|
+
try:
|
|
395
|
+
if self.storage:
|
|
396
|
+
await self.storage.close()
|
|
397
|
+
logger.info("Storage backend closed successfully")
|
|
398
|
+
except Exception as e:
|
|
399
|
+
logger.error("Error closing storage backend: %s", str(e), exc_info=True)
|
|
400
|
+
raise
|
hishel/fastapi.py
ADDED
|
@@ -0,0 +1,263 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import typing as t
|
|
4
|
+
|
|
5
|
+
from hishel._utils import generate_http_date
|
|
6
|
+
|
|
7
|
+
try:
|
|
8
|
+
import fastapi
|
|
9
|
+
except ImportError as e:
|
|
10
|
+
raise ImportError(
|
|
11
|
+
"fastapi is required to use hishel.fastapi module. "
|
|
12
|
+
"Please install hishel with the 'fastapi' extra, "
|
|
13
|
+
"e.g., 'pip install hishel[fastapi]'."
|
|
14
|
+
) from e
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
def cache(
|
|
18
|
+
*,
|
|
19
|
+
max_age: int | None = None,
|
|
20
|
+
s_maxage: int | None = None,
|
|
21
|
+
public: bool = False,
|
|
22
|
+
private: bool | list[str] = False,
|
|
23
|
+
no_cache: bool | list[str] = False,
|
|
24
|
+
no_store: bool = False,
|
|
25
|
+
no_transform: bool = False,
|
|
26
|
+
must_revalidate: bool = False,
|
|
27
|
+
must_understand: bool = False,
|
|
28
|
+
proxy_revalidate: bool = False,
|
|
29
|
+
immutable: bool = False,
|
|
30
|
+
stale_while_revalidate: int | None = None,
|
|
31
|
+
stale_if_error: int | None = None,
|
|
32
|
+
) -> t.Any:
|
|
33
|
+
"""
|
|
34
|
+
Add HTTP Cache-Control headers to FastAPI responses.
|
|
35
|
+
|
|
36
|
+
This function provides a convenient way to set cache control directives
|
|
37
|
+
on FastAPI responses according to RFC 9111 (HTTP Caching) and related standards.
|
|
38
|
+
|
|
39
|
+
Args:
|
|
40
|
+
max_age: Maximum time in seconds a response can be cached.
|
|
41
|
+
[RFC 9111, Section 5.2.2.1]
|
|
42
|
+
Example: max_age=3600 sets "Cache-Control: max-age=3600"
|
|
43
|
+
Use for both private and shared caches.
|
|
44
|
+
|
|
45
|
+
s_maxage: Maximum time in seconds for shared caches (proxies, CDNs).
|
|
46
|
+
[RFC 9111, Section 5.2.2.10]
|
|
47
|
+
Overrides max_age for shared caches only.
|
|
48
|
+
Example: s_maxage=7200 sets "s-maxage=7200"
|
|
49
|
+
Private caches (browsers) ignore this directive.
|
|
50
|
+
|
|
51
|
+
public: Marks response as cacheable by any cache.
|
|
52
|
+
[RFC 9111, Section 5.2.2.9]
|
|
53
|
+
Explicitly allows caching even if Authorization header is present.
|
|
54
|
+
Example: public=True adds "public" to Cache-Control
|
|
55
|
+
|
|
56
|
+
private: Marks response as cacheable only by private caches (browsers).
|
|
57
|
+
[RFC 9111, Section 5.2.2.7]
|
|
58
|
+
Shared caches (proxies, CDNs) MUST NOT store the response.
|
|
59
|
+
Can be True (applies to entire response) or a list of field names
|
|
60
|
+
(applies only to specific headers).
|
|
61
|
+
Examples:
|
|
62
|
+
- private=True adds "private" to Cache-Control
|
|
63
|
+
- private=["Set-Cookie"] adds 'private="Set-Cookie"' to Cache-Control
|
|
64
|
+
Useful for user-specific data.
|
|
65
|
+
|
|
66
|
+
no_cache: Response can be cached but MUST be revalidated before use.
|
|
67
|
+
[RFC 9111, Section 5.2.2.4]
|
|
68
|
+
Cache MUST check with origin server before serving cached copy.
|
|
69
|
+
Can be True (requires revalidation for entire response) or a list
|
|
70
|
+
of field names (requires revalidation only for specific headers).
|
|
71
|
+
Examples:
|
|
72
|
+
- no_cache=True adds "no-cache" to Cache-Control
|
|
73
|
+
- no_cache=["Set-Cookie", "Authorization"] adds 'no-cache="Set-Cookie, Authorization"'
|
|
74
|
+
Different from no_store - allows caching with mandatory validation.
|
|
75
|
+
|
|
76
|
+
no_store: Response MUST NOT be stored in any cache.
|
|
77
|
+
[RFC 9111, Section 5.2.2.5]
|
|
78
|
+
Most restrictive directive - prevents all caching.
|
|
79
|
+
Example: no_store=True adds "no-store" to Cache-Control
|
|
80
|
+
Use for sensitive data (passwords, personal information).
|
|
81
|
+
|
|
82
|
+
no_transform: Prohibits any transformations to the response.
|
|
83
|
+
[RFC 9111, Section 5.2.2.6]
|
|
84
|
+
Prevents proxies from modifying content (compression, format conversion).
|
|
85
|
+
Example: no_transform=True adds "no-transform" to Cache-Control
|
|
86
|
+
|
|
87
|
+
must_revalidate: Cache MUST revalidate stale responses.
|
|
88
|
+
[RFC 9111, Section 5.2.2.2]
|
|
89
|
+
Prevents serving stale content even if client accepts it.
|
|
90
|
+
Example: must_revalidate=True adds "must-revalidate" to Cache-Control
|
|
91
|
+
Stronger than no-cache - applies only when response becomes stale.
|
|
92
|
+
|
|
93
|
+
must_understand: Cache MUST understand response status code to cache it.
|
|
94
|
+
[RFC 9111, Section 5.2.2.3]
|
|
95
|
+
Prevents caching of responses with unknown status codes.
|
|
96
|
+
Example: must_understand=True adds "must-understand" to Cache-Control
|
|
97
|
+
|
|
98
|
+
proxy_revalidate: Like must_revalidate but only for shared caches.
|
|
99
|
+
[RFC 9111, Section 5.2.2.8]
|
|
100
|
+
Shared caches MUST revalidate stale responses.
|
|
101
|
+
Private caches can serve stale content without revalidation.
|
|
102
|
+
Example: proxy_revalidate=True adds "proxy-revalidate" to Cache-Control
|
|
103
|
+
|
|
104
|
+
immutable: Response body will never change.
|
|
105
|
+
[RFC 8246]
|
|
106
|
+
Optimization hint that revalidation is unnecessary during freshness period.
|
|
107
|
+
Example: immutable=True adds "immutable" to Cache-Control
|
|
108
|
+
Useful for versioned assets (e.g., /static/app.v123.js)
|
|
109
|
+
|
|
110
|
+
stale_while_revalidate: Allow stale response while revalidating in background.
|
|
111
|
+
[RFC 5861, Section 3]
|
|
112
|
+
Time in seconds cache can serve stale content while fetching fresh copy.
|
|
113
|
+
Example: stale_while_revalidate=86400 adds "stale-while-revalidate=86400"
|
|
114
|
+
Improves performance by avoiding cache misses.
|
|
115
|
+
|
|
116
|
+
stale_if_error: Allow stale response if origin server returns error.
|
|
117
|
+
[RFC 5861, Section 4]
|
|
118
|
+
Time in seconds cache can serve stale content when origin is unavailable.
|
|
119
|
+
Example: stale_if_error=3600 adds "stale-if-error=3600"
|
|
120
|
+
Improves availability during server failures.
|
|
121
|
+
|
|
122
|
+
Returns:
|
|
123
|
+
A dependency function that adds Cache-Control headers to the response.
|
|
124
|
+
|
|
125
|
+
Examples:
|
|
126
|
+
>>> from fastapi import FastAPI
|
|
127
|
+
>>> from hishel.fastapi import cache
|
|
128
|
+
>>>
|
|
129
|
+
>>> app = FastAPI()
|
|
130
|
+
>>>
|
|
131
|
+
>>> # Static assets - cache for 1 year, immutable
|
|
132
|
+
>>> @app.get("/static/logo.png")
|
|
133
|
+
>>> async def get_logo(
|
|
134
|
+
... _: None = cache(max_age=31536000, public=True, immutable=True)
|
|
135
|
+
... ):
|
|
136
|
+
... return {"image": "logo.png"}
|
|
137
|
+
>>>
|
|
138
|
+
>>> # API endpoint - cache for 5 minutes, private
|
|
139
|
+
>>> @app.get("/api/user/profile")
|
|
140
|
+
>>> async def get_profile(
|
|
141
|
+
... _: None = cache(max_age=300, private=True)
|
|
142
|
+
... ):
|
|
143
|
+
... return {"name": "John"}
|
|
144
|
+
>>>
|
|
145
|
+
>>> # CDN with shared cache - different TTLs for browsers and proxies
|
|
146
|
+
>>> @app.get("/api/public/data")
|
|
147
|
+
>>> async def get_data(
|
|
148
|
+
... _: None = cache(max_age=300, s_maxage=3600, public=True)
|
|
149
|
+
... ):
|
|
150
|
+
... return {"data": "public"}
|
|
151
|
+
>>>
|
|
152
|
+
>>> # Sensitive data - no caching
|
|
153
|
+
>>> @app.get("/api/secrets")
|
|
154
|
+
>>> async def get_secrets(
|
|
155
|
+
... _: None = cache(no_store=True)
|
|
156
|
+
... ):
|
|
157
|
+
... return {"secret": "value"}
|
|
158
|
+
>>>
|
|
159
|
+
>>> # Cacheable but must revalidate
|
|
160
|
+
>>> @app.get("/api/critical")
|
|
161
|
+
>>> async def get_critical(
|
|
162
|
+
... _: None = cache(max_age=3600, must_revalidate=True)
|
|
163
|
+
... ):
|
|
164
|
+
... return {"critical": "data"}
|
|
165
|
+
>>>
|
|
166
|
+
>>> # Stale-while-revalidate for better performance
|
|
167
|
+
>>> @app.get("/api/news")
|
|
168
|
+
>>> async def get_news(
|
|
169
|
+
... _: None = cache(max_age=300, stale_while_revalidate=86400, public=True)
|
|
170
|
+
... ):
|
|
171
|
+
... return {"news": "articles"}
|
|
172
|
+
>>>
|
|
173
|
+
>>> # Private directive with specific field names
|
|
174
|
+
>>> @app.get("/api/user/data")
|
|
175
|
+
>>> async def get_user_data(
|
|
176
|
+
... _: None = cache(max_age=600, private=["Set-Cookie"])
|
|
177
|
+
... ):
|
|
178
|
+
... return {"data": "user_specific"}
|
|
179
|
+
>>>
|
|
180
|
+
>>> # No-cache with field names - revalidate only specific headers
|
|
181
|
+
>>> @app.get("/api/conditional")
|
|
182
|
+
>>> async def get_conditional(
|
|
183
|
+
... _: None = cache(max_age=3600, no_cache=["Set-Cookie", "Authorization"])
|
|
184
|
+
... ):
|
|
185
|
+
... return {"data": "conditional_cache"}
|
|
186
|
+
|
|
187
|
+
Notes:
|
|
188
|
+
- Conflicting directives (e.g., public and private) will both be set.
|
|
189
|
+
Choose appropriate combinations based on your caching strategy.
|
|
190
|
+
- no_store is the strongest directive and prevents all caching.
|
|
191
|
+
- For CDNs, use s_maxage to set different TTLs for proxies vs browsers.
|
|
192
|
+
- Use immutable with versioned URLs for maximum cache efficiency.
|
|
193
|
+
- Combine stale_while_revalidate with max_age for better UX.
|
|
194
|
+
- private and no_cache can accept field names to apply directives
|
|
195
|
+
selectively to specific headers rather than the entire response.
|
|
196
|
+
|
|
197
|
+
See Also:
|
|
198
|
+
- RFC 9111: HTTP Caching (https://www.rfc-editor.org/rfc/rfc9111.html)
|
|
199
|
+
- RFC 8246: HTTP Immutable Responses (https://www.rfc-editor.org/rfc/rfc8246.html)
|
|
200
|
+
- RFC 5861: HTTP Cache-Control Extensions (https://www.rfc-editor.org/rfc/rfc5861.html)
|
|
201
|
+
"""
|
|
202
|
+
|
|
203
|
+
def add_cache_headers(response: fastapi.Response) -> t.Any:
|
|
204
|
+
"""Add Cache-Control headers to the response."""
|
|
205
|
+
directives: list[str] = []
|
|
206
|
+
|
|
207
|
+
# IMPORTANT
|
|
208
|
+
response.headers["Date"] = generate_http_date()
|
|
209
|
+
|
|
210
|
+
# Add directives with values
|
|
211
|
+
if max_age is not None:
|
|
212
|
+
directives.append(f"max-age={max_age}")
|
|
213
|
+
|
|
214
|
+
if s_maxage is not None:
|
|
215
|
+
directives.append(f"s-maxage={s_maxage}")
|
|
216
|
+
|
|
217
|
+
if stale_while_revalidate is not None:
|
|
218
|
+
directives.append(f"stale-while-revalidate={stale_while_revalidate}")
|
|
219
|
+
|
|
220
|
+
if stale_if_error is not None:
|
|
221
|
+
directives.append(f"stale-if-error={stale_if_error}")
|
|
222
|
+
|
|
223
|
+
# Add boolean directives
|
|
224
|
+
if public:
|
|
225
|
+
directives.append("public")
|
|
226
|
+
|
|
227
|
+
# Handle private (can be bool or list of field names)
|
|
228
|
+
if private is True:
|
|
229
|
+
directives.append("private")
|
|
230
|
+
elif isinstance(private, list) and private:
|
|
231
|
+
field_names = ", ".join(private)
|
|
232
|
+
directives.append(f'private="{field_names}"')
|
|
233
|
+
|
|
234
|
+
# Handle no_cache (can be bool or list of field names)
|
|
235
|
+
if no_cache is True:
|
|
236
|
+
directives.append("no-cache")
|
|
237
|
+
elif isinstance(no_cache, list) and no_cache:
|
|
238
|
+
field_names = ", ".join(no_cache)
|
|
239
|
+
directives.append(f'no-cache="{field_names}"')
|
|
240
|
+
|
|
241
|
+
if no_store:
|
|
242
|
+
directives.append("no-store")
|
|
243
|
+
|
|
244
|
+
if no_transform:
|
|
245
|
+
directives.append("no-transform")
|
|
246
|
+
|
|
247
|
+
if must_revalidate:
|
|
248
|
+
directives.append("must-revalidate")
|
|
249
|
+
|
|
250
|
+
if must_understand:
|
|
251
|
+
directives.append("must-understand")
|
|
252
|
+
|
|
253
|
+
if proxy_revalidate:
|
|
254
|
+
directives.append("proxy-revalidate")
|
|
255
|
+
|
|
256
|
+
if immutable:
|
|
257
|
+
directives.append("immutable")
|
|
258
|
+
|
|
259
|
+
# Set the Cache-Control header if any directives were specified
|
|
260
|
+
if directives:
|
|
261
|
+
response.headers["Cache-Control"] = ", ".join(directives)
|
|
262
|
+
|
|
263
|
+
return fastapi.Depends(add_cache_headers)
|