Skip to content

Commit 9c95c2f

Browse files
authored
feat: add support for request no-cache directive (#416)
* Add support for request no-cache directive * fix tests * fix: ensure `is_corrupted` coroutine is awaited (#417)
1 parent 16d08bf commit 9c95c2f

File tree

6 files changed

+84
-7
lines changed

6 files changed

+84
-7
lines changed

hishel/_core/_spec.py

Lines changed: 38 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1207,6 +1207,7 @@ def next(
12071207
- Unsafe methods (POST, PUT, DELETE, etc.) are written through to origin
12081208
- Multiple matching responses are sorted by Date header (most recent first)
12091209
- Age header is updated when serving from cache
1210+
- Request no-cache directive forces revalidation of cached responses
12101211
12111212
Examples:
12121213
--------
@@ -1229,6 +1230,18 @@ def next(
12291230
>>> next_state = idle.next(get_request, [cached_pair])
12301231
>>> isinstance(next_state, NeedRevalidation)
12311232
True
1233+
1234+
>>> # Need revalidation - request no-cache forces validation of fresh response
1235+
>>> idle = IdleClient(options=default_options)
1236+
>>> no_cache_request = Request(
1237+
... method="GET",
1238+
... url="https://example.com",
1239+
... headers=Headers({"cache-control": "no-cache"})
1240+
... )
1241+
>>> cached_pair = CompletePair(no_cache_request, fresh_response)
1242+
>>> next_state = idle.next(no_cache_request, [cached_pair])
1243+
>>> isinstance(next_state, NeedRevalidation)
1244+
True
12321245
"""
12331246

12341247
# ============================================================================
@@ -1388,7 +1401,31 @@ def fresh_or_allowed_stale(pair: Entry) -> bool:
13881401
ready_to_use, need_revalidation = partition(filtered_pairs, fresh_or_allowed_stale)
13891402

13901403
# ============================================================================
1391-
# STEP 7: Determine Next State Based on Available Responses
1404+
# STEP 7: Handle Request no-cache Directive
1405+
# ============================================================================
1406+
# RFC 9111 Section 5.2.1.4: no-cache Request Directive
1407+
# https://www.rfc-editor.org/rfc/rfc9111.html#section-5.2.1.4
1408+
#
1409+
# "The no-cache request directive indicates that a cache MUST NOT use a
1410+
# stored response to satisfy the request without successful validation on
1411+
# the origin server."
1412+
#
1413+
# When a client sends Cache-Control: no-cache in the request, it's explicitly
1414+
# requesting that the cache not use any stored response without first validating
1415+
# it with the origin server. This is different from the response no-cache directive,
1416+
# which applies to how responses should be cached.
1417+
request_cache_control = parse_cache_control(request.headers.get("cache-control"))
1418+
1419+
if request_cache_control.no_cache is True:
1420+
# Move all fresh responses to the revalidation queue
1421+
# This ensures that even fresh cached responses will be validated
1422+
# with the origin server via conditional requests (If-None-Match,
1423+
# If-Modified-Since) before being served to the client.
1424+
need_revalidation.extend(ready_to_use)
1425+
ready_to_use = []
1426+
1427+
# ============================================================================
1428+
# STEP 8: Determine Next State Based on Available Responses
13921429
# ============================================================================
13931430

13941431
if ready_to_use:

hishel/_core/_storages/_async_sqlite.py

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -340,8 +340,8 @@ async def _batch_cleanup(
340340
await connection.commit()
341341

342342
async def _is_corrupted(self, pair: Entry, cursor: anysqlite.Cursor) -> bool:
343-
# if entry was created more than 1 hour ago and still has no response (incomplete)
344-
if pair.meta.created_at + 3600 < time.time() and not self._is_stream_complete(pair.id, cursor):
343+
# if entry was created more than 1 hour ago and still has no full response data
344+
if pair.meta.created_at + 3600 < time.time() and not (await self._is_stream_complete(pair.id, cursor)):
345345
return True
346346
return False
347347

hishel/_core/_storages/_sync_sqlite.py

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -340,8 +340,8 @@ def _batch_cleanup(
340340
connection.commit()
341341

342342
def _is_corrupted(self, pair: Entry, cursor: sqlite3.Cursor) -> bool:
343-
# if entry was created more than 1 hour ago and still has no response (incomplete)
344-
if pair.meta.created_at + 3600 < time.time() and not self._is_stream_complete(pair.id, cursor):
343+
# if entry was created more than 1 hour ago and still has no full response data
344+
if pair.meta.created_at + 3600 < time.time() and not (self._is_stream_complete(pair.id, cursor)):
345345
return True
346346
return False
347347

tests/_core/_async/test_sqlite_storage.py

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -379,6 +379,7 @@ async def mock_connect(*args, **kwargs):
379379

380380

381381
@pytest.mark.anyio
382+
@travel(datetime(2024, 1, 1, 0, 0, 0, tzinfo=ZoneInfo("UTC")))
382383
async def test_incomplete_entries() -> None:
383384
"""Test incomplete entries"""
384385
storage = AsyncSqliteStorage(connection=await anysqlite.connect(":memory:"))
@@ -412,7 +413,7 @@ async def test_incomplete_entries() -> None:
412413
id = (bytes) 0x0000000000000000000000000000000a (16 bytes)
413414
cache_key = (str) 'incomplete_key'
414415
data = (bytes) 0x85a26964c4100000000000000000000000000000000aa772657175657374... (186 bytes)
415-
created_at = 2025-11-08
416+
created_at = 2024-01-01
416417
deleted_at = NULL
417418
418419
TABLE: streams

tests/_core/_sync/test_sqlite_storage.py

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -379,6 +379,7 @@ def mock_connect(*args, **kwargs):
379379

380380

381381

382+
@travel(datetime(2024, 1, 1, 0, 0, 0, tzinfo=ZoneInfo("UTC")))
382383
def test_incomplete_entries() -> None:
383384
"""Test incomplete entries"""
384385
storage = SyncSqliteStorage(connection=sqlite3.connect(":memory:"))
@@ -412,7 +413,7 @@ def test_incomplete_entries() -> None:
412413
id = (bytes) 0x0000000000000000000000000000000a (16 bytes)
413414
cache_key = (str) 'incomplete_key'
414415
data = (bytes) 0x85a26964c4100000000000000000000000000000000aa772657175657374... (186 bytes)
415-
created_at = 2025-11-08
416+
created_at = 2024-01-01
416417
deleted_at = NULL
417418
418419
TABLE: streams

tests/_core/spec/test_idle_client.py

Lines changed: 38 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -736,6 +736,44 @@ def test_multiple_stale_responses_all_included_in_revalidation(self, idle_client
736736
assert isinstance(next_state, NeedRevalidation)
737737
assert len(next_state.revalidating_entries) == 2
738738

739+
def test_request_no_cache_forces_revalidation_of_fresh_response(self, idle_client: IdleClient) -> None:
740+
"""
741+
Test: Request no-cache directive forces revalidation even for fresh responses.
742+
743+
RFC 9111 Section 5.2.1.4: no-cache Request Directive
744+
"The no-cache request directive indicates that a cache MUST NOT use a
745+
stored response to satisfy the request without successful validation on
746+
the origin server."
747+
748+
Even if a cached response is fresh, the request no-cache directive
749+
forces the cache to revalidate it with the origin server.
750+
"""
751+
# Arrange
752+
# Request with no-cache directive
753+
request = create_request(headers={"cache-control": "no-cache"})
754+
755+
# Fresh cached response: age 1800s < max-age 3600s
756+
fresh_response = create_response(
757+
age_seconds=1800,
758+
max_age_seconds=3600,
759+
headers={"etag": '"abc123"'},
760+
)
761+
cached_pair = create_pair(request=request, response=fresh_response)
762+
763+
# Act
764+
next_state = idle_client.next(request, [cached_pair])
765+
766+
# Assert
767+
# Despite being fresh, response must be revalidated due to request no-cache
768+
assert isinstance(next_state, NeedRevalidation)
769+
assert next_state.original_request == request
770+
assert len(next_state.revalidating_entries) == 1
771+
assert next_state.revalidating_entries[0] == cached_pair
772+
773+
# Verify conditional request is created with validators
774+
assert "if-none-match" in next_state.request.headers
775+
assert next_state.request.headers["if-none-match"] == '"abc123"'
776+
739777

740778
# =============================================================================
741779
# Test Suite 4: Edge Cases and RFC 9111 Compliance

0 commit comments

Comments
 (0)