hishel 0.1.5__py3-none-any.whl → 1.0.0b1__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.
Files changed (55) hide show
  1. hishel/__init__.py +59 -52
  2. hishel/_async_cache.py +213 -0
  3. hishel/_async_httpx.py +236 -0
  4. hishel/{beta/_core → _core}/_headers.py +11 -1
  5. hishel/{beta/_core → _core}/_spec.py +270 -136
  6. hishel/_core/_storages/_async_base.py +71 -0
  7. hishel/_core/_storages/_async_sqlite.py +420 -0
  8. hishel/_core/_storages/_packing.py +144 -0
  9. hishel/_core/_storages/_sync_base.py +71 -0
  10. hishel/_core/_storages/_sync_sqlite.py +420 -0
  11. hishel/{beta/_core → _core}/models.py +100 -37
  12. hishel/_policies.py +49 -0
  13. hishel/_sync_cache.py +213 -0
  14. hishel/_sync_httpx.py +236 -0
  15. hishel/_utils.py +37 -366
  16. hishel/asgi.py +400 -0
  17. hishel/fastapi.py +263 -0
  18. hishel/httpx.py +12 -0
  19. hishel/{beta/requests.py → requests.py} +31 -25
  20. hishel-1.0.0b1.dist-info/METADATA +509 -0
  21. hishel-1.0.0b1.dist-info/RECORD +24 -0
  22. hishel/_async/__init__.py +0 -5
  23. hishel/_async/_client.py +0 -30
  24. hishel/_async/_mock.py +0 -43
  25. hishel/_async/_pool.py +0 -201
  26. hishel/_async/_storages.py +0 -768
  27. hishel/_async/_transports.py +0 -282
  28. hishel/_controller.py +0 -581
  29. hishel/_exceptions.py +0 -10
  30. hishel/_files.py +0 -54
  31. hishel/_headers.py +0 -215
  32. hishel/_lfu_cache.py +0 -71
  33. hishel/_lmdb_types_.pyi +0 -53
  34. hishel/_s3.py +0 -122
  35. hishel/_serializers.py +0 -329
  36. hishel/_sync/__init__.py +0 -5
  37. hishel/_sync/_client.py +0 -30
  38. hishel/_sync/_mock.py +0 -43
  39. hishel/_sync/_pool.py +0 -201
  40. hishel/_sync/_storages.py +0 -768
  41. hishel/_sync/_transports.py +0 -282
  42. hishel/_synchronization.py +0 -37
  43. hishel/beta/__init__.py +0 -59
  44. hishel/beta/_async_cache.py +0 -167
  45. hishel/beta/_core/__init__.py +0 -0
  46. hishel/beta/_core/_async/_storages/_sqlite.py +0 -411
  47. hishel/beta/_core/_base/_storages/_base.py +0 -272
  48. hishel/beta/_core/_base/_storages/_packing.py +0 -165
  49. hishel/beta/_core/_sync/_storages/_sqlite.py +0 -411
  50. hishel/beta/_sync_cache.py +0 -167
  51. hishel/beta/httpx.py +0 -328
  52. hishel-0.1.5.dist-info/METADATA +0 -258
  53. hishel-0.1.5.dist-info/RECORD +0 -41
  54. {hishel-0.1.5.dist-info → hishel-1.0.0b1.dist-info}/WHEEL +0 -0
  55. {hishel-0.1.5.dist-info → hishel-1.0.0b1.dist-info}/licenses/LICENSE +0 -0
@@ -9,17 +9,17 @@ from typing import (
9
9
  TYPE_CHECKING,
10
10
  Any,
11
11
  Dict,
12
- Literal,
13
12
  Optional,
14
13
  TypeVar,
15
14
  Union,
16
15
  )
17
16
 
17
+ from hishel._core._headers import Headers, Range, Vary, parse_cache_control
18
+ from hishel._core.models import ResponseMetadata
18
19
  from hishel._utils import parse_date, partition
19
- from hishel.beta._core._headers import Headers, Range, Vary, parse_cache_control
20
20
 
21
21
  if TYPE_CHECKING:
22
- from hishel.beta import CompletePair, Request, Response
22
+ from hishel import Entry, Request, Response
23
23
 
24
24
 
25
25
  TState = TypeVar("TState", bound="State")
@@ -41,9 +41,96 @@ logger = logging.getLogger("hishel.core.spec")
41
41
 
42
42
  @dataclass
43
43
  class CacheOptions:
44
+ """
45
+ Configuration options for HTTP cache behavior.
46
+
47
+ These options control how the cache interprets and applies RFC 9111 caching rules.
48
+ All options have sensible defaults that follow the specification.
49
+
50
+ Attributes:
51
+ ----------
52
+ shared : bool
53
+ Determines whether the cache operates as a shared cache or private cache.
54
+
55
+ RFC 9111 Section 3.5: Authenticated Responses
56
+ https://www.rfc-editor.org/rfc/rfc9111.html#section-3.5
57
+
58
+ - Shared cache (True): Acts as a proxy, CDN, or gateway cache serving multiple users.
59
+ Must respect private directives and Authorization header restrictions.
60
+ Can use s-maxage directive instead of max-age for shared-specific freshness.
61
+
62
+ - Private cache (False): Acts as a browser or user-agent cache for a single user.
63
+ Can cache private responses and ignore s-maxage directives.
64
+
65
+ Default: True (shared cache)
66
+
67
+ Examples:
68
+ --------
69
+ >>> # Shared cache (proxy/CDN)
70
+ >>> options = CacheOptions(shared=True)
71
+
72
+ >>> # Private cache (browser)
73
+ >>> options = CacheOptions(shared=False)
74
+
75
+ supported_methods : list[str]
76
+ HTTP methods that are allowed to be cached by this cache implementation.
77
+
78
+ RFC 9111 Section 3, paragraph 2.1:
79
+ https://www.rfc-editor.org/rfc/rfc9111.html#section-3-2.1.1
80
+
81
+ "A cache MUST NOT store a response to a request unless:
82
+ - the request method is understood by the cache"
83
+
84
+ Default: ["GET", "HEAD"] (most commonly cached methods)
85
+
86
+ Examples:
87
+ --------
88
+ >>> # Default: cache GET and HEAD only
89
+ >>> options = CacheOptions()
90
+ >>> options.supported_methods
91
+ ['GET', 'HEAD']
92
+
93
+ >>> # Cache POST responses (advanced use case)
94
+ >>> options = CacheOptions(supported_methods=["GET", "HEAD", "POST"])
95
+
96
+ allow_stale : bool
97
+ Controls whether stale responses can be served without revalidation.
98
+
99
+ RFC 9111 Section 4.2.4: Serving Stale Responses
100
+ https://www.rfc-editor.org/rfc/rfc9111.html#section-4.2.4
101
+
102
+ "A cache MUST NOT generate a stale response unless it is disconnected or
103
+ doing so is explicitly permitted by the client or origin server (e.g., by
104
+ the max-stale request directive in Section 5.2.1, extension directives
105
+ such as those defined in [RFC5861], or configuration in accordance with
106
+ an out-of-band contract)."
107
+
108
+ Default: False (no stale responses)
109
+
110
+ Examples:
111
+ --------
112
+ >>> # Conservative: never serve stale
113
+ >>> options = CacheOptions(allow_stale=False)
114
+
115
+ >>> # Permissive: serve stale when allowed
116
+ >>> options = CacheOptions(allow_stale=True)
117
+
118
+ >>> # Stale-while-revalidate pattern (RFC 5861)
119
+ >>> # Even with allow_stale=True, directives are respected
120
+ >>> options = CacheOptions(allow_stale=True)
121
+ """
122
+
44
123
  shared: bool = True
124
+ """
125
+ When True, the cache operates as a shared cache (proxy/CDN).
126
+ When False, as a private cache (browser).
127
+ """
128
+
45
129
  supported_methods: list[str] = field(default_factory=lambda: ["GET", "HEAD"])
130
+ """HTTP methods that are allowed to be cached."""
131
+
46
132
  allow_stale: bool = False
133
+ """When True, stale responses can be served without revalidation."""
47
134
 
48
135
 
49
136
  @dataclass
@@ -57,7 +144,7 @@ class State(ABC):
57
144
 
58
145
  def vary_headers_match(
59
146
  original_request: Request,
60
- associated_pair: CompletePair,
147
+ associated_entry: Entry,
61
148
  ) -> bool:
62
149
  """
63
150
  Determines if request headers match the Vary requirements of a cached response.
@@ -73,8 +160,8 @@ def vary_headers_match(
73
160
  ----------
74
161
  original_request : Request
75
162
  The new incoming request that we're trying to satisfy
76
- associated_pair : CompletePair
77
- A cached request-response pair that might match the new request
163
+ associated_entry : Entry
164
+ A cached request-response entry that might match the new request
78
165
 
79
166
  Returns:
80
167
  -------
@@ -107,31 +194,31 @@ def vary_headers_match(
107
194
  >>> # No Vary header - always matches
108
195
  >>> request = Request(headers=Headers({"accept": "application/json"}))
109
196
  >>> response = Response(headers=Headers({})) # No Vary
110
- >>> pair = CompletePair(request=request, response=response)
111
- >>> vary_headers_match(request, pair)
197
+ >>> entry = Entry(request=request, response=response)
198
+ >>> vary_headers_match(request, entry)
112
199
  True
113
200
 
114
201
  >>> # Vary: Accept with matching Accept header
115
202
  >>> request1 = Request(headers=Headers({"accept": "application/json"}))
116
203
  >>> response = Response(headers=Headers({"vary": "Accept"}))
117
- >>> pair = CompletePair(request=request1, response=response)
204
+ >>> entry = Entry(request=request1, response=response)
118
205
  >>> request2 = Request(headers=Headers({"accept": "application/json"}))
119
- >>> vary_headers_match(request2, pair)
206
+ >>> vary_headers_match(request2, entry)
120
207
  True
121
208
 
122
209
  >>> # Vary: Accept with non-matching Accept header
123
210
  >>> request2 = Request(headers=Headers({"accept": "application/xml"}))
124
- >>> vary_headers_match(request2, pair)
211
+ >>> vary_headers_match(request2, entry)
125
212
  False
126
213
 
127
214
  >>> # Vary: * always fails
128
215
  >>> response = Response(headers=Headers({"vary": "*"}))
129
- >>> pair = CompletePair(request=request1, response=response)
130
- >>> vary_headers_match(request2, pair)
216
+ >>> entry = Entry(request=request1, response=response)
217
+ >>> vary_headers_match(request2, entry)
131
218
  False
132
219
  """
133
220
  # Extract the Vary header from the cached response
134
- vary_header = associated_pair.response.headers.get("vary")
221
+ vary_header = associated_entry.response.headers.get("vary")
135
222
 
136
223
  # If no Vary header exists, any request matches
137
224
  # The response doesn't vary based on request headers
@@ -154,7 +241,7 @@ def vary_headers_match(
154
241
 
155
242
  # Compare the specific header value between original and new request
156
243
  # Both headers must have the same value (or both be absent)
157
- if original_request.headers.get(vary_header) != associated_pair.request.headers.get(vary_header):
244
+ if original_request.headers.get(vary_header) != associated_entry.request.headers.get(vary_header):
158
245
  return False
159
246
 
160
247
  # All Vary headers matched
@@ -1033,19 +1120,13 @@ AnyState = Union[
1033
1120
  "NeedToBeUpdated",
1034
1121
  "NeedRevalidation",
1035
1122
  "IdleClient",
1036
- "InvalidatePairs",
1123
+ "InvalidateEntries",
1037
1124
  ]
1038
1125
 
1039
1126
  # Defined in https://www.rfc-editor.org/rfc/rfc9110#name-safe-methods
1040
1127
  SAFE_METHODS = frozenset(["GET", "HEAD", "OPTIONS", "TRACE"])
1041
1128
 
1042
1129
 
1043
- def create_idle_state(role: Literal["client", "server"], options: Optional[CacheOptions] = None) -> IdleClient:
1044
- if role == "server":
1045
- raise NotImplementedError("Server role is not implemented yet.")
1046
- return IdleClient(options=options or CacheOptions())
1047
-
1048
-
1049
1130
  @dataclass
1050
1131
  class IdleClient(State):
1051
1132
  """
@@ -1079,7 +1160,7 @@ class IdleClient(State):
1079
1160
  """
1080
1161
 
1081
1162
  def next(
1082
- self, request: Request, associated_pairs: list[CompletePair]
1163
+ self, request: Request, associated_entries: list[Entry]
1083
1164
  ) -> Union["CacheMiss", "FromCache", "NeedRevalidation"]:
1084
1165
  """
1085
1166
  Determines the next state transition based on the request and available cached responses.
@@ -1092,9 +1173,9 @@ class IdleClient(State):
1092
1173
  ----------
1093
1174
  request : Request
1094
1175
  The incoming HTTP request from the client
1095
- associated_pairs : list[CompletePair]
1096
- List of request-response pairs previously stored in the cache that may match
1097
- this request. These pairs are pre-filtered by cache key (typically URI).
1176
+ associated_entries : list[Entry]
1177
+ List of request-response entries previously stored in the cache that may match
1178
+ this request. These entries are pre-filtered by cache key (typically URI).
1098
1179
 
1099
1180
  Returns:
1100
1181
  -------
@@ -1229,7 +1310,7 @@ class IdleClient(State):
1229
1310
  #
1230
1311
  # If a cached response has Cache-Control: no-cache, it cannot be reused without
1231
1312
  # validation, regardless of its freshness.
1232
- def no_cache_missing(pair: CompletePair) -> bool:
1313
+ def no_cache_missing(pair: Entry) -> bool:
1233
1314
  """Check if the cached response lacks the no-cache directive."""
1234
1315
  return parse_cache_control(pair.response.headers.get("cache-control")).no_cache is False
1235
1316
 
@@ -1244,7 +1325,7 @@ class IdleClient(State):
1244
1325
  #
1245
1326
  # Note: Condition 5.3 (successfully validated) is handled in the
1246
1327
  # NeedRevalidation state, not here.
1247
- def fresh_or_allowed_stale(pair: CompletePair) -> bool:
1328
+ def fresh_or_allowed_stale(pair: Entry) -> bool:
1248
1329
  """
1249
1330
  Determine if a cached response is fresh or allowed to be served stale.
1250
1331
 
@@ -1273,7 +1354,7 @@ class IdleClient(State):
1273
1354
  # "ready to use" and "needs revalidation" groups.
1274
1355
  filtered_pairs = [
1275
1356
  pair
1276
- for pair in associated_pairs
1357
+ for pair in associated_entries
1277
1358
  if url_matches(pair) and method_matches(pair) and vary_headers_same(pair) and no_cache_missing(pair) # type: ignore[no-untyped-call]
1278
1359
  ]
1279
1360
 
@@ -1325,18 +1406,13 @@ class IdleClient(State):
1325
1406
  #
1326
1407
  # The Age header informs the client how old the cached response is.
1327
1408
 
1328
- # Mark all ready-to-use responses with metadata (for observability)
1329
- for pair in ready_to_use:
1330
- pair.response.metadata["hishel_from_cache"] = True # type: ignore
1331
-
1332
1409
  # Use the most recent response (first in sorted list)
1333
1410
  selected_pair = ready_to_use[0]
1334
1411
 
1335
1412
  # Calculate current age and update the Age header
1336
1413
  current_age = get_age(selected_pair.response)
1337
-
1338
1414
  return FromCache(
1339
- pair=replace(
1415
+ entry=replace(
1340
1416
  selected_pair,
1341
1417
  response=replace(
1342
1418
  selected_pair.response,
@@ -1372,7 +1448,7 @@ class IdleClient(State):
1372
1448
  # (ETag, Last-Modified) from the cached response.
1373
1449
  return NeedRevalidation(
1374
1450
  request=make_conditional_request(request, need_revalidation[-1].response),
1375
- revalidating_pairs=need_revalidation,
1451
+ revalidating_entries=need_revalidation,
1376
1452
  options=self.options,
1377
1453
  original_request=request,
1378
1454
  )
@@ -1442,7 +1518,7 @@ class CacheMiss(State):
1442
1518
  Indicates whether the cache miss occurred after a revalidation attempt.
1443
1519
  """
1444
1520
 
1445
- def next(self, response: Response, pair_id: uuid.UUID) -> Union["StoreAndUse", "CouldNotBeStored"]:
1521
+ def next(self, response: Response) -> Union["StoreAndUse", "CouldNotBeStored"]:
1446
1522
  """
1447
1523
  Evaluates whether a response can be stored in the cache.
1448
1524
 
@@ -1491,20 +1567,6 @@ class CacheMiss(State):
1491
1567
  * an s-maxage response directive (if cache is shared)
1492
1568
  * a status code that is defined as heuristically cacheable"
1493
1569
 
1494
- Side Effects:
1495
- ------------
1496
- Sets metadata flags on the response object:
1497
- - hishel_spec_ignored: False (caching spec is being followed)
1498
- - hishel_from_cache: False (response is from origin, not cache)
1499
- - hishel_revalidated: True (if after_revalidation is True)
1500
- - hishel_stored: True/False (whether response was stored)
1501
-
1502
- Logging:
1503
- -------
1504
- When a response cannot be stored, detailed debug logs are emitted explaining
1505
- which specific RFC requirement failed, with direct links to the relevant
1506
- RFC sections.
1507
-
1508
1570
  Examples:
1509
1571
  --------
1510
1572
  >>> # Cacheable response
@@ -1513,7 +1575,7 @@ class CacheMiss(State):
1513
1575
  ... status_code=200,
1514
1576
  ... headers=Headers({"cache-control": "max-age=3600"})
1515
1577
  ... )
1516
- >>> next_state = cache_miss.next(response, uuid.uuid4())
1578
+ >>> next_state = cache_miss.next(response)
1517
1579
  >>> isinstance(next_state, StoreAndUse)
1518
1580
  True
1519
1581
 
@@ -1522,26 +1584,11 @@ class CacheMiss(State):
1522
1584
  ... status_code=200,
1523
1585
  ... headers=Headers({"cache-control": "no-store"})
1524
1586
  ... )
1525
- >>> next_state = cache_miss.next(response, uuid.uuid4())
1587
+ >>> next_state = cache_miss.next(response)
1526
1588
  >>> isinstance(next_state, CouldNotBeStored)
1527
1589
  True
1528
1590
  """
1529
1591
 
1530
- # ============================================================================
1531
- # STEP 1: Set Response Metadata
1532
- # ============================================================================
1533
- # Initialize metadata flags to track the response lifecycle
1534
-
1535
- response.metadata["hishel_spec_ignored"] = False # type: ignore
1536
- # We are following the caching specification
1537
-
1538
- response.metadata["hishel_from_cache"] = False # type: ignore
1539
- # This response came from origin server, not cache
1540
-
1541
- if self.after_revalidation:
1542
- response.metadata["hishel_revalidated"] = True # type: ignore
1543
- # Mark that this response is the result of a revalidation
1544
-
1545
1592
  # ============================================================================
1546
1593
  # STEP 2: Parse Cache-Control Directive
1547
1594
  # ============================================================================
@@ -1636,11 +1683,14 @@ class CacheMiss(State):
1636
1683
  #
1637
1684
  # Requests with Authorization headers often contain user-specific data.
1638
1685
  # Shared caches must be careful not to serve one user's data to another.
1639
- #
1640
- # This check is inverted in the current implementation and needs review:
1641
- # TODO: Fix logic - should be: (not shared) OR (no auth header) OR (has explicit directive)
1642
- # Current logic: (shared) AND (no auth header)
1643
- is_shared_and_authorized = not (self.options.shared and "authorization" in request.headers)
1686
+ has_explicit_directive = (
1687
+ response_cache_control.public
1688
+ or response_cache_control.s_maxage is not None
1689
+ or response_cache_control.must_revalidate
1690
+ )
1691
+ can_cache_auth_request = (
1692
+ not self.options.shared or "authorization" not in request.headers or has_explicit_directive
1693
+ )
1644
1694
 
1645
1695
  # CONDITION 7: Response Contains Required Caching Information
1646
1696
  # RFC 9111 Section 3, paragraph 2.7:
@@ -1710,7 +1760,7 @@ class CacheMiss(State):
1710
1760
  or not understands_how_to_cache
1711
1761
  or not no_store_is_not_present
1712
1762
  or not private_directive_allows_storing
1713
- or not is_shared_and_authorized
1763
+ or not can_cache_auth_request
1714
1764
  or not contains_required_component
1715
1765
  ):
1716
1766
  # --------------------------------------------------------------------
@@ -1746,10 +1796,11 @@ class CacheMiss(State):
1746
1796
  "Cannot store the response because the `private` response directive does not "
1747
1797
  "allow shared caches to store it. See: https://www.rfc-editor.org/rfc/rfc9111.html#section-3-2.5.1"
1748
1798
  )
1749
- elif not is_shared_and_authorized:
1799
+ elif not can_cache_auth_request:
1750
1800
  logger.debug(
1751
- "Cannot store the response because the cache is shared and the request contains "
1752
- "an Authorization header field. See: https://www.rfc-editor.org/rfc/rfc9111.html#section-3-2.6.1"
1801
+ "Cannot store the response because the request contained an Authorization header "
1802
+ "and there was no explicit directive allowing shared caching. "
1803
+ "See: https://www.rfc-editor.org/rfc/rfc9111.html#section-3-5"
1753
1804
  )
1754
1805
  elif not contains_required_component:
1755
1806
  logger.debug(
@@ -1757,10 +1808,11 @@ class CacheMiss(State):
1757
1808
  "See: https://www.rfc-editor.org/rfc/rfc9111.html#section-3-2.7.1"
1758
1809
  )
1759
1810
 
1760
- # Mark response as not stored
1761
- response.metadata["hishel_stored"] = False # type: ignore
1762
-
1763
- return CouldNotBeStored(response=response, pair_id=pair_id, options=self.options)
1811
+ return CouldNotBeStored(
1812
+ response=response,
1813
+ options=self.options,
1814
+ after_revalidation=self.after_revalidation,
1815
+ )
1764
1816
 
1765
1817
  # --------------------------------------------------------------------
1766
1818
  # Transition to: StoreAndUse
@@ -1769,9 +1821,6 @@ class CacheMiss(State):
1769
1821
 
1770
1822
  logger.debug("Storing response in cache")
1771
1823
 
1772
- # Mark response as stored
1773
- response.metadata["hishel_stored"] = True # type: ignore
1774
-
1775
1824
  # Remove headers that should not be stored
1776
1825
  # RFC 9111 Section 3.1: Storing Header and Trailer Fields
1777
1826
  # https://www.rfc-editor.org/rfc/rfc9111.html#section-3.1
@@ -1779,9 +1828,9 @@ class CacheMiss(State):
1779
1828
  cleaned_response = exclude_unstorable_headers(response, self.options.shared)
1780
1829
 
1781
1830
  return StoreAndUse(
1782
- pair_id=pair_id,
1783
1831
  response=cleaned_response,
1784
1832
  options=self.options,
1833
+ after_revalidation=self.after_revalidation,
1785
1834
  )
1786
1835
 
1787
1836
 
@@ -1801,7 +1850,7 @@ class NeedRevalidation(State):
1801
1850
  State Transitions:
1802
1851
  -----------------
1803
1852
  - NeedToBeUpdated: 304 response received, cached responses can be freshened
1804
- - InvalidatePairs + CacheMiss: 2xx/5xx response received, new response must be cached
1853
+ - InvalidateEntries + CacheMiss: 2xx/5xx response received, new response must be cached
1805
1854
  - CacheMiss: No matching responses found during freshening
1806
1855
 
1807
1856
  RFC 9111 References:
@@ -1822,8 +1871,8 @@ class NeedRevalidation(State):
1822
1871
  original_request : Request
1823
1872
  The original client request (without conditional headers) that initiated
1824
1873
  this revalidation. This is used when creating new cache entries.
1825
- revalidating_pairs : list[CompletePair]
1826
- The cached request-response pairs that are being revalidated. These are
1874
+ revalidating_entries : list[Entry]
1875
+ The cached request-response entries that are being revalidated. These are
1827
1876
  stale responses that might still be usable if the server confirms they
1828
1877
  haven't changed (304 response).
1829
1878
  options : CacheOptions
@@ -1837,12 +1886,14 @@ class NeedRevalidation(State):
1837
1886
 
1838
1887
  original_request: Request
1839
1888
 
1840
- revalidating_pairs: list[CompletePair]
1889
+ revalidating_entries: list[Entry]
1841
1890
  """
1842
- The stored pairs that the request was sent for revalidation.
1891
+ The stored entries that the request was sent for revalidation.
1843
1892
  """
1844
1893
 
1845
- def next(self, revalidation_response: Response) -> Union["NeedToBeUpdated", "InvalidatePairs", "CacheMiss"]:
1894
+ def next(
1895
+ self, revalidation_response: Response
1896
+ ) -> Union["NeedToBeUpdated", "InvalidateEntries", "CacheMiss", "FromCache"]:
1846
1897
  """
1847
1898
  Handles the response to a conditional request and determines the next state.
1848
1899
 
@@ -1862,9 +1913,9 @@ class NeedRevalidation(State):
1862
1913
 
1863
1914
  Returns:
1864
1915
  -------
1865
- Union[NeedToBeUpdated, InvalidatePairs, CacheMiss]
1916
+ Union[NeedToBeUpdated, InvalidateEntries, CacheMiss]
1866
1917
  - NeedToBeUpdated: When 304 response allows cached responses to be freshened
1867
- - InvalidatePairs: When old responses must be invalidated (wraps next state)
1918
+ - InvalidateEntries: When old responses must be invalidated (wraps next state)
1868
1919
  - CacheMiss: When no matching responses found or storing new response
1869
1920
 
1870
1921
  RFC 9111 Compliance:
@@ -1899,7 +1950,7 @@ class NeedRevalidation(State):
1899
1950
  >>> need_revalidation = NeedRevalidation(
1900
1951
  ... request=conditional_request,
1901
1952
  ... original_request=original_request,
1902
- ... revalidating_pairs=[cached_pair],
1953
+ ... revalidating_entries=[cached_entry],
1903
1954
  ... options=default_options
1904
1955
  ... )
1905
1956
  >>> response_304 = Response(status_code=304, headers=Headers({"etag": '"abc123"'}))
@@ -1910,7 +1961,7 @@ class NeedRevalidation(State):
1910
1961
  >>> # 200 OK - use new response
1911
1962
  >>> response_200 = Response(status_code=200, headers=Headers({"cache-control": "max-age=3600"}))
1912
1963
  >>> next_state = need_revalidation.next(response_200)
1913
- >>> isinstance(next_state, InvalidatePairs)
1964
+ >>> isinstance(next_state, InvalidateEntries)
1914
1965
  True
1915
1966
  """
1916
1967
 
@@ -1949,17 +2000,19 @@ class NeedRevalidation(State):
1949
2000
  # 2. Store the new response (if cacheable)
1950
2001
  # 3. Use the new response to satisfy the request
1951
2002
  elif revalidation_response.status_code // 100 == 2:
1952
- # Invalidate all old pairs except the last one
1953
- # The last pair's ID will be reused for the new response
1954
- return InvalidatePairs(
2003
+ # Invalidate all old entries except the last one
2004
+ # The last entry's ID will be reused for the new response
2005
+ return InvalidateEntries(
1955
2006
  options=self.options,
1956
- pair_ids=[pair.id for pair in self.revalidating_pairs[:-1]],
2007
+ entry_ids=[entry.id for entry in self.revalidating_entries[:-1]],
1957
2008
  # After invalidation, attempt to cache the new response
1958
2009
  next_state=CacheMiss(
1959
2010
  request=self.original_request,
1960
2011
  options=self.options,
1961
2012
  after_revalidation=True, # Mark that this occurred during revalidation
1962
- ).next(revalidation_response, pair_id=self.revalidating_pairs[-1].id),
2013
+ ).next(
2014
+ revalidation_response,
2015
+ ),
1963
2016
  )
1964
2017
 
1965
2018
  # ============================================================================
@@ -1988,14 +2041,25 @@ class NeedRevalidation(State):
1988
2041
  elif revalidation_response.status_code // 100 == 5:
1989
2042
  # Same as 2xx: invalidate old responses and store the error response
1990
2043
  # This ensures clients see the error rather than potentially stale data
1991
- return InvalidatePairs(
2044
+ return InvalidateEntries(
1992
2045
  options=self.options,
1993
- pair_ids=[pair.id for pair in self.revalidating_pairs[:-1]],
2046
+ entry_ids=[entry.id for entry in self.revalidating_entries[:-1]],
1994
2047
  next_state=CacheMiss(
1995
2048
  request=self.original_request,
1996
2049
  options=self.options,
1997
2050
  after_revalidation=True,
1998
- ).next(revalidation_response, pair_id=self.revalidating_pairs[-1].id),
2051
+ ).next(
2052
+ revalidation_response,
2053
+ ),
2054
+ )
2055
+ elif revalidation_response.status_code // 100 == 3:
2056
+ # 3xx Redirects should have been followed by the HTTP client
2057
+ return FromCache(
2058
+ entry=replace(
2059
+ self.revalidating_entries[-1],
2060
+ response=revalidation_response,
2061
+ ),
2062
+ options=self.options,
1999
2063
  )
2000
2064
 
2001
2065
  # ============================================================================
@@ -2015,7 +2079,7 @@ class NeedRevalidation(State):
2015
2079
 
2016
2080
  def freshening_stored_responses(
2017
2081
  self, revalidation_response: Response
2018
- ) -> "NeedToBeUpdated" | "InvalidatePairs" | "CacheMiss":
2082
+ ) -> "NeedToBeUpdated" | "InvalidateEntries" | "CacheMiss":
2019
2083
  """
2020
2084
  Freshens cached responses after receiving a 304 Not Modified response.
2021
2085
 
@@ -2038,9 +2102,9 @@ class NeedRevalidation(State):
2038
2102
 
2039
2103
  Returns:
2040
2104
  -------
2041
- Union[NeedToBeUpdated, InvalidatePairs, CacheMiss]
2105
+ Union[NeedToBeUpdated, InvalidateEntries, CacheMiss]
2042
2106
  - NeedToBeUpdated: When matching responses are found and updated
2043
- - InvalidatePairs: Wraps NeedToBeUpdated if non-matching responses exist
2107
+ - InvalidateEntries: Wraps NeedToBeUpdated if non-matching responses exist
2044
2108
  - CacheMiss: When no matching responses are found
2045
2109
 
2046
2110
  RFC 9111 Compliance:
@@ -2101,7 +2165,7 @@ class NeedRevalidation(State):
2101
2165
  # Priority 2: Last-Modified timestamp
2102
2166
  # Priority 3: Single response assumption
2103
2167
 
2104
- identified_for_revalidation: list[CompletePair]
2168
+ identified_for_revalidation: list[Entry]
2105
2169
 
2106
2170
  # MATCHING STRATEGY 1: Strong ETag
2107
2171
  # RFC 9110 Section 8.8.3: ETag
@@ -2121,7 +2185,7 @@ class NeedRevalidation(State):
2121
2185
  # Found a strong ETag in the 304 response
2122
2186
  # Partition cached responses: matching vs non-matching ETags
2123
2187
  identified_for_revalidation, need_to_be_invalidated = partition(
2124
- self.revalidating_pairs,
2188
+ self.revalidating_entries,
2125
2189
  lambda pair: pair.response.headers.get("etag") == revalidation_response.headers.get("etag"), # type: ignore[no-untyped-call]
2126
2190
  )
2127
2191
 
@@ -2139,7 +2203,7 @@ class NeedRevalidation(State):
2139
2203
  # Found Last-Modified in the 304 response
2140
2204
  # Partition cached responses: matching vs non-matching timestamps
2141
2205
  identified_for_revalidation, need_to_be_invalidated = partition(
2142
- self.revalidating_pairs,
2206
+ self.revalidating_entries,
2143
2207
  lambda pair: pair.response.headers.get("last-modified")
2144
2208
  == revalidation_response.headers.get("last-modified"), # type: ignore[no-untyped-call]
2145
2209
  )
@@ -2153,14 +2217,20 @@ class NeedRevalidation(State):
2153
2217
  # we can safely assume that single response is the one being confirmed.
2154
2218
  # This handles cases where the server doesn't return validators in the 304.
2155
2219
  else:
2156
- if len(self.revalidating_pairs) == 1:
2220
+ if len(self.revalidating_entries) == 1:
2157
2221
  # Only one cached response - it must be the matching one
2158
- identified_for_revalidation, need_to_be_invalidated = [self.revalidating_pairs[0]], []
2222
+ identified_for_revalidation, need_to_be_invalidated = (
2223
+ [self.revalidating_entries[0]],
2224
+ [],
2225
+ )
2159
2226
  else:
2160
2227
  # Multiple cached responses but no validators to match them
2161
2228
  # We cannot determine which (if any) are valid
2162
2229
  # Conservative approach: invalidate all of them
2163
- identified_for_revalidation, need_to_be_invalidated = [], self.revalidating_pairs
2230
+ identified_for_revalidation, need_to_be_invalidated = (
2231
+ [],
2232
+ self.revalidating_entries,
2233
+ )
2164
2234
 
2165
2235
  # ============================================================================
2166
2236
  # STEP 2: Update Matching Responses or Create Cache Miss
@@ -2185,7 +2255,7 @@ class NeedRevalidation(State):
2185
2255
  # while excluding certain headers that shouldn't be updated
2186
2256
  # (Content-Encoding, Content-Type, Content-Range).
2187
2257
  next_state = NeedToBeUpdated(
2188
- updating_pairs=[
2258
+ updating_entries=[
2189
2259
  replace(
2190
2260
  pair,
2191
2261
  response=refresh_response_headers(pair.response, revalidation_response),
@@ -2219,9 +2289,9 @@ class NeedRevalidation(State):
2219
2289
 
2220
2290
  if need_to_be_invalidated:
2221
2291
  # Wrap the next state in an invalidation operation
2222
- return InvalidatePairs(
2292
+ return InvalidateEntries(
2223
2293
  options=self.options,
2224
- pair_ids=[pair.id for pair in need_to_be_invalidated],
2294
+ entry_ids=[entry.id for entry in need_to_be_invalidated],
2225
2295
  next_state=next_state,
2226
2296
  )
2227
2297
 
@@ -2229,41 +2299,94 @@ class NeedRevalidation(State):
2229
2299
  return next_state
2230
2300
 
2231
2301
 
2232
- @dataclass
2233
2302
  class StoreAndUse(State):
2234
2303
  """
2235
2304
  The state that indicates that the response can be stored in the cache and used.
2236
- """
2237
2305
 
2238
- pair_id: uuid.UUID
2306
+ Attributes:
2307
+ ----------
2308
+ response : Response
2309
+ The HTTP response to be stored in the cache.
2310
+ after_revalidation : bool
2311
+ Indicates if the storage is occurring after a revalidation process.
2312
+ """
2239
2313
 
2240
- response: Response
2314
+ def __init__(
2315
+ self,
2316
+ response: Response,
2317
+ options: CacheOptions,
2318
+ after_revalidation: bool = False,
2319
+ ) -> None:
2320
+ super().__init__(options)
2321
+ self.response = response
2322
+ self.after_revalidation = after_revalidation
2323
+ response_meta = ResponseMetadata(
2324
+ hishel_created_at=time.time(),
2325
+ hishel_from_cache=False,
2326
+ hishel_revalidated=after_revalidation,
2327
+ hishel_stored=True,
2328
+ )
2329
+ self.response.metadata.update(response_meta) # type: ignore
2241
2330
 
2242
2331
  def next(self) -> None:
2243
- return None # pragma: nocover
2332
+ return None
2333
+
2334
+
2335
+ # @dataclass
2336
+ # class CouldNotBeStored(State):
2337
+ # """
2338
+ # The state that indicates that the response could not be stored in the cache.
2339
+ # """
2340
+
2341
+ # response: Response
2342
+
2343
+ # pair_id: uuid.UUID
2344
+
2345
+ # def next(self) -> None:
2346
+ # return None # pragma: nocover
2244
2347
 
2245
2348
 
2246
- @dataclass
2247
2349
  class CouldNotBeStored(State):
2248
2350
  """
2249
2351
  The state that indicates that the response could not be stored in the cache.
2250
- """
2251
2352
 
2252
- response: Response
2353
+ Attributes:
2354
+ ----------
2355
+ response : Response
2356
+ The HTTP response that could not be stored.
2357
+ pair_id : uuid.UUID
2358
+ The unique identifier for the cache pair.
2359
+ after_revalidation : bool
2360
+ Indicates if the storage attempt occurred after a revalidation process.
2361
+ """
2253
2362
 
2254
- pair_id: uuid.UUID
2363
+ def __init__(
2364
+ self,
2365
+ response: Response,
2366
+ options: CacheOptions,
2367
+ after_revalidation: bool = False,
2368
+ ) -> None:
2369
+ super().__init__(options)
2370
+ self.response = response
2371
+ response_meta = ResponseMetadata(
2372
+ hishel_created_at=time.time(),
2373
+ hishel_from_cache=False,
2374
+ hishel_revalidated=after_revalidation,
2375
+ hishel_stored=False,
2376
+ )
2377
+ self.response.metadata.update(response_meta) # type: ignore
2255
2378
 
2256
2379
  def next(self) -> None:
2257
- return None # pragma: nocover
2380
+ return None
2258
2381
 
2259
2382
 
2260
2383
  @dataclass
2261
- class InvalidatePairs(State):
2384
+ class InvalidateEntries(State):
2262
2385
  """
2263
- The state that represents the deletion of cache pairs.
2386
+ The state that represents the deletion of cache entries.
2264
2387
  """
2265
2388
 
2266
- pair_ids: list[uuid.UUID]
2389
+ entry_ids: list[uuid.UUID]
2267
2390
 
2268
2391
  next_state: AnyState
2269
2392
 
@@ -2271,21 +2394,32 @@ class InvalidatePairs(State):
2271
2394
  return self.next_state
2272
2395
 
2273
2396
 
2274
- @dataclass
2275
2397
  class FromCache(State):
2276
- pair: CompletePair
2277
- """
2278
- List of pairs that can be used to satisfy the request.
2279
- """
2398
+ def __init__(
2399
+ self,
2400
+ entry: Entry,
2401
+ options: CacheOptions,
2402
+ after_revalidation: bool = False,
2403
+ ) -> None:
2404
+ super().__init__(options)
2405
+ self.entry = entry
2406
+ self.after_revalidation = after_revalidation
2407
+ response_meta = ResponseMetadata(
2408
+ hishel_created_at=entry.meta.created_at,
2409
+ hishel_from_cache=True,
2410
+ hishel_revalidated=after_revalidation,
2411
+ hishel_stored=False,
2412
+ )
2413
+ self.entry.response.metadata.update(response_meta) # type: ignore
2280
2414
 
2281
2415
  def next(self) -> None:
2282
- return None # pragma: nocover
2416
+ return None
2283
2417
 
2284
2418
 
2285
2419
  @dataclass
2286
2420
  class NeedToBeUpdated(State):
2287
- updating_pairs: list[CompletePair]
2421
+ updating_entries: list[Entry]
2288
2422
  original_request: Request
2289
2423
 
2290
2424
  def next(self) -> FromCache:
2291
- return FromCache(pair=self.updating_pairs[-1], options=self.options) # pragma: nocover
2425
+ return FromCache(entry=self.updating_entries[-1], options=self.options)