Merge pull request #1187 from ryanduguid/issue-744-content-change

Add response content change indicator
diff --git a/.gitignore b/.gitignore
index 931e573..ce438af 100644
--- a/.gitignore
+++ b/.gitignore
@@ -41,3 +41,6 @@
 docs/_build/
 docs/modules/
 docs/requirements.txt
+
+# Project-agnostic
+.cache
diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml
index 30d853e..54b99ba 100644
--- a/.pre-commit-config.yaml
+++ b/.pre-commit-config.yaml
@@ -44,3 +44,9 @@
         types_or: [css, javascript, json, yaml]
         args: ['--single-quote']
         additional_dependencies: [prettier@3.5.1]
+  - repo: https://github.com/alessio-locatelli/ruff-extra-rules
+    rev: v0.2.2
+    hooks:
+      - id: ruff-extra-rules
+      - id: ruff-extra-rules-ty
+        additional_dependencies: [ty]
diff --git a/HISTORY.md b/HISTORY.md
index 33ce642..e44edc2 100644
--- a/HISTORY.md
+++ b/HISTORY.md
@@ -1,5 +1,8 @@
 # History
 
+## Unreleased
+* Fix unhandled `EOFError` when a cache value fails to deserialize
+
 ## 1.3.3 (2026-07-03)
 * Add `CachedResponse.has_content_changed` to flag whether a refresh differs from the cache, when a reliable comparison is available.
 * Fix SQLite `vacuum()` not freeing disk space
diff --git a/pyproject.toml b/pyproject.toml
index 93fa132..fe69acf 100644
--- a/pyproject.toml
+++ b/pyproject.toml
@@ -180,6 +180,16 @@
 [tool.ruff.lint.pycodestyle]
 max-line-length = 120
 
+[tool.ruff-extra-rules]
+fix = true
+
+[tool.ruff-extra-rules.per-file-ignores]
+# Keeping the library interface (regardless of whether it is a public or
+# private function) is more important than renaming a function for a negligible
+# clarity improvement. However, this can be done in the next major release.
+"requests_cache/*" = ["validate-function-name"]
+"examples/*" = ["validate-function-name"]
+
 [tool.typos]
 files.extend-exclude = [
     '.all-contributorsrc',
diff --git a/requests_cache/backends/base.py b/requests_cache/backends/base.py
index e980ae7..9dfaf18 100644
--- a/requests_cache/backends/base.py
+++ b/requests_cache/backends/base.py
@@ -25,7 +25,7 @@
 from ..serializers import SerializerType, init_serializer
 
 # Specific exceptions that may be raised during deserialization
-DESERIALIZE_ERRORS = (AttributeError, ImportError, PickleError, TypeError, ValueError)
+DESERIALIZE_ERRORS = (AttributeError, EOFError, ImportError, PickleError, TypeError, ValueError)
 
 logger = getLogger(__name__)
 
diff --git a/requests_cache/backends/dynamodb.py b/requests_cache/backends/dynamodb.py
index ca3b429..6650b20 100644
--- a/requests_cache/backends/dynamodb.py
+++ b/requests_cache/backends/dynamodb.py
@@ -124,10 +124,10 @@
                 raise
 
     def __getitem__(self, key):
-        result = self._table.get_item(Key={'key': key})
-        if 'Item' not in result:
+        response = self._table.get_item(Key={'key': key})
+        if 'Item' not in response:
             raise KeyError
-        return self.deserialize(key, result['Item']['value'])
+        return self.deserialize(key, response['Item']['value'])
 
     def __setitem__(self, key, value):
         item = {'key': key, 'value': self.serialize(value)}
diff --git a/requests_cache/backends/filesystem.py b/requests_cache/backends/filesystem.py
index e254e34..fc48b24 100644
--- a/requests_cache/backends/filesystem.py
+++ b/requests_cache/backends/filesystem.py
@@ -221,7 +221,7 @@
 
     def __getitem__(self, key):
         """Get a value and update its access time in the LRU index"""
-        value = super().__getitem__(key)
+        value = super().__getitem__(key)  # pytriage: TR5
 
         # Update access time in LRU index
         try:
diff --git a/requests_cache/backends/gridfs.py b/requests_cache/backends/gridfs.py
index c34a783..a791507 100644
--- a/requests_cache/backends/gridfs.py
+++ b/requests_cache/backends/gridfs.py
@@ -85,10 +85,10 @@
     def __getitem__(self, key):
         try:
             with self._lock:
-                result = self.fs.find_one({'_id': key})
-                if result is None:
+                file_handle = self.fs.find_one({'_id': key})
+                if file_handle is None:
                     raise KeyError
-                return self.deserialize(key, result.read())
+                return self.deserialize(key, file_handle.read())
         except CorruptGridFile as e:
             logger.warning(e, exc_info=True)
             raise KeyError from e
diff --git a/requests_cache/backends/mongodb.py b/requests_cache/backends/mongodb.py
index d708660..a8b52ba 100644
--- a/requests_cache/backends/mongodb.py
+++ b/requests_cache/backends/mongodb.py
@@ -117,10 +117,10 @@
             self.collection.create_index('created_at', name='ttl_idx', expireAfterSeconds=ttl)
 
     def __getitem__(self, key):
-        result = self.collection.find_one({'_id': key})
-        if result is None:
+        document = self.collection.find_one({'_id': key})
+        if document is None:
             raise KeyError
-        value = result['data'] if 'data' in result else result
+        value = document['data'] if 'data' in document else document
         return self.deserialize(key, value)
 
     def __setitem__(self, key, value):
@@ -133,8 +133,8 @@
         self.collection.replace_one({'_id': key}, value, upsert=True)
 
     def __delitem__(self, key):
-        result = self.collection.find_one_and_delete({'_id': key}, {'_id': True})
-        if result is None:
+        document = self.collection.find_one_and_delete({'_id': key}, {'_id': True})
+        if document is None:
             raise KeyError
 
     def __len__(self):
diff --git a/tests/conftest.py b/tests/conftest.py
index aeff1e1..ad56aff 100644
--- a/tests/conftest.py
+++ b/tests/conftest.py
@@ -171,14 +171,14 @@
 
 
 def mount_mock_adapter(session: CachedSession) -> CachedSession:
-    adapter = get_mock_adapter()
+    adapter = create_mock_adapter()
     for protocol in MOCK_PROTOCOLS:
         session.mount(protocol, adapter)
     session.mock_adapter = adapter
     return session
 
 
-def get_mock_adapter() -> Adapter:
+def create_mock_adapter() -> Adapter:
     """Get a requests-mock Adapter with some URLs mocked by default"""
     adapter = Adapter()
     adapter.register_uri(
@@ -273,7 +273,7 @@
     return adapter
 
 
-def get_mock_response(
+def create_mock_response(
     method='GET',
     url='https://img.site.com/base/img.jpg',
     status_code=200,
diff --git a/tests/integration/base_cache_test.py b/tests/integration/base_cache_test.py
index 588ce53..a8ac563 100644
--- a/tests/integration/base_cache_test.py
+++ b/tests/integration/base_cache_test.py
@@ -140,21 +140,21 @@
     def test_cookies(self):
         session = self.init_session()
 
-        def get_json(url):
+        def parse_json(url):
             return json.loads(session.get(url).text)
 
-        response_1 = get_json(httpbin('cookies/set?test1=test2'))
+        response_1 = parse_json(httpbin('cookies/set?test1=test2'))
         with session.cache_disabled():
-            assert get_json(httpbin('cookies')) == response_1
+            assert parse_json(httpbin('cookies')) == response_1
 
         # From cache
-        response_2 = get_json(httpbin('cookies'))
-        assert response_2 == get_json(httpbin('cookies'))
+        response_2 = parse_json(httpbin('cookies'))
+        assert response_2 == parse_json(httpbin('cookies'))
 
         # Not from cache
         with session.cache_disabled():
-            response_3 = get_json(httpbin('cookies/set?test3=test4'))
-            assert response_3 == get_json(httpbin('cookies'))
+            response_3 = parse_json(httpbin('cookies/set?test3=test4'))
+            assert response_3 == parse_json(httpbin('cookies'))
 
     @pytest.mark.parametrize(
         'cache_control, request_headers, expected_expiration',
@@ -267,8 +267,7 @@
 
     def test_decode_gzip_response(self):
         """Test that gzip-compressed responses read decompressed content with decode_content=True"""
-        session = self.init_session()
-        response = session.get(httpbin('gzip'))
+        response = self.init_session().get(httpbin('gzip'))
         assert b'gzipped' in response.content
         response.raw._fp = BytesIO(response.content)
 
diff --git a/tests/unit/policy/test_actions.py b/tests/unit/policy/test_actions.py
index 2054b5e..ece8ef6 100644
--- a/tests/unit/policy/test_actions.py
+++ b/tests/unit/policy/test_actions.py
@@ -8,7 +8,7 @@
 from requests_cache.cache_keys import create_key
 from requests_cache.models import CachedRequest, CachedResponse
 from requests_cache.policy import EXPIRE_IMMEDIATELY, CacheActions, CacheSettings, utcnow
-from tests.conftest import ETAG, HTTPDATE_STR, LAST_MODIFIED, MOCKED_URL, get_mock_response
+from tests.conftest import ETAG, HTTPDATE_STR, LAST_MODIFIED, MOCKED_URL, create_mock_response
 
 IGNORED_DIRECTIVES = [
     'no-transform',
@@ -576,7 +576,7 @@
 def test_update_from_response(headers, expected_expiration):
     """Test with Cache-Control response headers"""
     actions = CacheActions.from_request('key', BASIC_REQUEST, CacheSettings(cache_control=True))
-    actions.update_from_response(get_mock_response(headers=headers))
+    actions.update_from_response(create_mock_response(headers=headers))
 
     assert actions.expire_after == expected_expiration
     assert actions.skip_write is (expected_expiration == EXPIRE_IMMEDIATELY)
@@ -584,13 +584,13 @@
 
 def test_update_from_response__no_store():
     actions = CacheActions.from_request('key', BASIC_REQUEST, CacheSettings(cache_control=True))
-    actions.update_from_response(get_mock_response(headers={'Cache-Control': 'no-store'}))
+    actions.update_from_response(create_mock_response(headers={'Cache-Control': 'no-store'}))
     assert actions.skip_write is True
 
 
 def test_update_from_response__ignored():
     actions = CacheActions.from_request('key', BASIC_REQUEST, CacheSettings(cache_control=False))
-    actions.update_from_response(get_mock_response(headers={'Cache-Control': 'max-age=5'}))
+    actions.update_from_response(create_mock_response(headers={'Cache-Control': 'max-age=5'}))
     assert actions.expire_after is None
 
 
@@ -602,7 +602,7 @@
     expiration
     """
     actions = CacheActions.from_request('key', BASIC_REQUEST, CacheSettings(cache_control=True))
-    response = get_mock_response(headers={**cache_headers, **validator_headers})
+    response = create_mock_response(headers={**cache_headers, **validator_headers})
     actions.update_from_response(response)
 
     assert actions.expires == mock_utcnow()
@@ -625,10 +625,10 @@
     actions = CacheActions.from_request(
         'key', BASIC_REQUEST, CacheSettings(cache_control=True, read_only=read_only)
     )
-    cached_response = get_mock_response(headers={'ETag': ETAG})
+    cached_response = create_mock_response(headers={'ETag': ETAG})
     cached_response.expires = None
     new_headers = {'ETag': ETAG, 'X-Custom-Header': 'new'} if headers_changed else {'ETag': ETAG}
-    new_response = get_mock_response(status_code=304, headers=new_headers)
+    new_response = create_mock_response(status_code=304, headers=new_headers)
     actions.update_revalidated_response(new_response, cached_response)
     assert actions.skip_write is expected_skip_write
 
@@ -638,8 +638,10 @@
     don't set both `Content-Length` and `Transfer-Encoding`
     """
     actions = CacheActions.from_request('key', BASIC_REQUEST, CacheSettings(cache_control=True))
-    cached_response = get_mock_response(headers={'ETag': ETAG, 'Transfer-Encoding': 'chunked'})
-    new_response = get_mock_response(status_code=304, headers={'ETag': ETAG, 'Content-Length': 0})
+    cached_response = create_mock_response(headers={'ETag': ETAG, 'Transfer-Encoding': 'chunked'})
+    new_response = create_mock_response(
+        status_code=304, headers={'ETag': ETAG, 'Content-Length': 0}
+    )
     cached_response = actions.update_revalidated_response(new_response, cached_response)
     assert cached_response.headers['Transfer-Encoding'] == 'chunked'
     assert 'Content-Length' not in cached_response.headers
diff --git a/tests/unit/test_cache_keys.py b/tests/unit/test_cache_keys.py
index eec2c63..13a6a27 100644
--- a/tests/unit/test_cache_keys.py
+++ b/tests/unit/test_cache_keys.py
@@ -232,8 +232,7 @@
 
 
 def test_normalize_headers__single_header_value_as_bytes():
-    headers = {'Accept': b'gzip'}
-    norm_headers = normalize_headers(headers)
+    norm_headers = normalize_headers({'Accept': b'gzip'})
     assert norm_headers == {'Accept': 'gzip'}
 
 
@@ -244,8 +243,7 @@
 
 
 def test_normalize_headers__single_header_value_as_string():
-    headers = {'Accept': 'gzip'}
-    norm_headers = normalize_headers(headers)
+    norm_headers = normalize_headers({'Accept': 'gzip'})
     assert norm_headers == {'Accept': 'gzip'}
 
 
diff --git a/tests/unit/test_normalize.py b/tests/unit/test_normalize.py
index 3b57868..18a1886 100644
--- a/tests/unit/test_normalize.py
+++ b/tests/unit/test_normalize.py
@@ -5,22 +5,22 @@
 def test_ignores_nested():
     original = b'{"data":{"key":"value","timestamp":"2022-08-04"}}'
     # Only work for timestamp under data subtree
-    result = normalize_json_body(
+    json_text = normalize_json_body(
         original,
         ignored_parameters=['timestamp'],
         content_root_key='data',
     )
-    body = json.loads(result)
+    body = json.loads(json_text)
     assert body == {'data': {'key': 'value', 'timestamp': 'REDACTED'}}
 
 
 def test_ignores_nested_specialcase():
     original = b'{"data":{"foo":"bar"},"extra":"keep"}'
-    result = normalize_json_body(
+    json_text = normalize_json_body(
         original,
         ignored_parameters=['timestamp'],
         content_root_key='data',
     )
-    body = json.loads(result)
+    body = json.loads(json_text)
     # No timestamp under data, should be no filter
     assert body == {'data': {'foo': 'bar'}, 'extra': 'keep'}
diff --git a/tests/unit/test_session.py b/tests/unit/test_session.py
index a2dc71a..bf92794 100644
--- a/tests/unit/test_session.py
+++ b/tests/unit/test_session.py
@@ -325,10 +325,10 @@
         'HTTPS://site.com?param_1=value_1&param_2=value_2',
     ]
 
-    def get_request(url):
+    def fetch_request(url):
         return mock_session.prepare_request(requests.Request('GET', url))
 
-    keys = [mock_session.cache.create_key(get_request(url)) for url in urls]
+    keys = [mock_session.cache.create_key(fetch_request(url)) for url in urls]
     assert len(set(keys)) == 1
 
 
@@ -609,11 +609,12 @@
     assert mock_session.get(MOCKED_URL).from_cache is True
 
 
-def test_unpickle_errors(mock_session):
+@pytest.mark.parametrize('error', [PickleError, EOFError])
+def test_unpickle_errors(mock_session, error):
     """If there is an error during deserialization, the request should be made again"""
     assert mock_session.get(MOCKED_URL_JSON).from_cache is False
 
-    with patch.object(mock_session.cache.responses.serializer, 'loads', side_effect=PickleError):
+    with patch.object(mock_session.cache.responses.serializer, 'loads', side_effect=error):
         resp = mock_session.get(MOCKED_URL_JSON)
         assert resp.from_cache is False
         assert resp.json()['message'] == 'mock json response'