From 8c1a863ccc68f66e55a999db9cb7f84a4e794250 Mon Sep 17 00:00:00 2001 From: Nikita Yakovlev Date: Tue, 8 Jul 2025 10:37:43 +0300 Subject: [PATCH 01/14] new exception - NotFoundError --- fast_cache_middleware/exceptions.py | 7 +++++++ 1 file changed, 7 insertions(+) diff --git a/fast_cache_middleware/exceptions.py b/fast_cache_middleware/exceptions.py index 674be8c..05de2f4 100644 --- a/fast_cache_middleware/exceptions.py +++ b/fast_cache_middleware/exceptions.py @@ -4,3 +4,10 @@ class FastCacheMiddlewareError(Exception): class StorageError(FastCacheMiddlewareError): pass + + +class NotFoundError(StorageError): + def __init__(self, key: str, message: str = "Data not found") -> None: + self.key = key + self.message = message + super().__init__(f"{message}. Key: {key}.") From 20ab7178cbbc0d820a153cfd01abe1fa963fa1cb Mon Sep 17 00:00:00 2001 From: Nikita Yakovlev Date: Tue, 8 Jul 2025 10:38:07 +0300 Subject: [PATCH 02/14] remove None, rise exception --- .../storages/in_memory_storage.py | 7 +++---- .../storages/redis_storage.py | 18 +++++++----------- 2 files changed, 10 insertions(+), 15 deletions(-) diff --git a/fast_cache_middleware/storages/in_memory_storage.py b/fast_cache_middleware/storages/in_memory_storage.py index 6fdfaef..d26226c 100644 --- a/fast_cache_middleware/storages/in_memory_storage.py +++ b/fast_cache_middleware/storages/in_memory_storage.py @@ -7,7 +7,7 @@ from starlette.requests import Request from starlette.responses import Response -from fast_cache_middleware.exceptions import StorageError +from fast_cache_middleware.exceptions import NotFoundError, StorageError from fast_cache_middleware.serializers import BaseSerializer, Metadata from .base_storage import BaseStorage, StoredResponse @@ -101,13 +101,12 @@ async def get(self, key: str) -> Optional[StoredResponse]: Tuple (response, request, metadata) if found and not expired, None if not found or expired """ if key not in self._storage: - return None + raise NotFoundError(key, message="Key not found at storage") # Lazy TTL check if self._is_expired(key): self._pop_item(key) - logger.debug("Element %s removed from cache - TTL expired", key) - return None + raise NotFoundError(key, message="Key removed from cache - TTL expired") self._storage.move_to_end(key) diff --git a/fast_cache_middleware/storages/redis_storage.py b/fast_cache_middleware/storages/redis_storage.py index 8bc4629..570905e 100644 --- a/fast_cache_middleware/storages/redis_storage.py +++ b/fast_cache_middleware/storages/redis_storage.py @@ -11,7 +11,7 @@ from starlette.requests import Request from starlette.responses import Response -from fast_cache_middleware.exceptions import StorageError +from fast_cache_middleware.exceptions import NotFoundError, StorageError from fast_cache_middleware.serializers import BaseSerializer, JSONSerializer, Metadata from .base_storage import BaseStorage, StoredResponse @@ -62,7 +62,7 @@ async def set( await self._storage.set(full_key, value, ex=ttl) logger.info("Data written to Redis") - async def get(self, key: str) -> Optional[StoredResponse]: + async def get(self, key: str) -> StoredResponse: """ Get response from Redis. If TTL expired returns None. """ @@ -70,17 +70,13 @@ async def get(self, key: str) -> Optional[StoredResponse]: raw_data = await self._storage.get(full_key) if raw_data is None: - logger.debug("Key %s will be removed from Redis - TTL expired", full_key) - return None + raise NotFoundError( + full_key, message="Key will be removed from Redis - TTL expired" + ) logger.debug(f"Takin data from Redis: %s", raw_data) - try: - return self._serializer.loads(raw_data) - except Exception as e: - logger.warning( - "Failed to deserialize cached response for key %s: %s", key, e - ) - return None + + return self._serializer.loads(raw_data) async def delete(self, path: re.Pattern) -> None: """ From 631183aa85c763e0c7a53b77c17539018d92bfa1 Mon Sep 17 00:00:00 2001 From: Nikita Yakovlev Date: Tue, 8 Jul 2025 10:38:33 +0300 Subject: [PATCH 03/14] try/except if not found cache --- fast_cache_middleware/controller.py | 12 ++++++++++-- 1 file changed, 10 insertions(+), 2 deletions(-) diff --git a/fast_cache_middleware/controller.py b/fast_cache_middleware/controller.py index bb5dbf5..abfecc3 100644 --- a/fast_cache_middleware/controller.py +++ b/fast_cache_middleware/controller.py @@ -7,6 +7,7 @@ from starlette.requests import Request from starlette.responses import Response +from .exceptions import NotFoundError from .schemas import CacheConfiguration from .storages import BaseStorage @@ -181,7 +182,7 @@ async def cache_response( async def get_cached_response( self, cache_key: str, storage: BaseStorage - ) -> Optional[Response]: + ) -> Response | None: """Gets cached response if it exists and is valid. Args: @@ -191,9 +192,16 @@ async def get_cached_response( Returns: Response or None if cache is invalid/missing """ - result = await storage.get(cache_key) + + try: + result = await storage.get(cache_key) + except NotFoundError as e: + logger.warning("No cache found: %s", e) + return None + if result is None: return None + response, _, _ = result return response From 424813dab3be8856870b31673d6bf59c5c1ec446 Mon Sep 17 00:00:00 2001 From: Nikita Yakovlev Date: Tue, 8 Jul 2025 10:38:58 +0300 Subject: [PATCH 04/14] fix tests --- tests/storages/test_in_memory_storage.py | 31 ++++++++++++------------ tests/storages/test_redis_storage.py | 14 ++++++----- tests/test_controller.py | 21 +++++++++++++++- 3 files changed, 43 insertions(+), 23 deletions(-) diff --git a/tests/storages/test_in_memory_storage.py b/tests/storages/test_in_memory_storage.py index 075f910..ade63d4 100644 --- a/tests/storages/test_in_memory_storage.py +++ b/tests/storages/test_in_memory_storage.py @@ -9,7 +9,7 @@ from starlette.requests import Request from starlette.responses import Response -from fast_cache_middleware.exceptions import StorageError +from fast_cache_middleware.exceptions import NotFoundError, StorageError from fast_cache_middleware.serializers import Metadata from fast_cache_middleware.storages import InMemoryStorage @@ -56,19 +56,14 @@ async def test_store_and_retrieve_with_ttl( await storage.set("test_key", response, request, metadata) - if should_expire: - await asyncio.sleep(wait_time) - - result = await storage.get("test_key") + await asyncio.sleep(wait_time) if should_expire: - assert result is None + with pytest.raises(NotFoundError): + await storage.get("test_key") else: + result = await storage.get("test_key") assert result is not None - stored_response, _, stored_metadata = result - assert stored_response.body == b"test" - assert stored_response.status_code == 200 - assert stored_metadata["key"] == "value" @pytest.mark.asyncio @@ -101,10 +96,12 @@ async def test_expired_items_cleanup( await storage.set("test_key2", response, request, metadata) # Проверяем результат - result = await storage.get("test_key") if expected_cleanup_calls > 0: - assert result is None # Элемент должен быть удален + with pytest.raises(NotFoundError): + result = await storage.get("test_key") + assert result is None # Элемент должен быть удален else: + result = await storage.get("test_key") assert result is not None # Элемент должен остаться @@ -195,7 +192,8 @@ async def test_retrieve_updates_lru_position( assert await storage.get(key) is not None for key in expire_keys: - assert await storage.get(key) is None + with pytest.raises(NotFoundError): + await storage.get(key) @pytest.mark.asyncio @@ -254,14 +252,15 @@ async def test_retrieve_nonexistent_key( if should_exist: await storage.set(key, *mock_store_data) - result = await storage.get(key) - if should_exist: + result = await storage.get(key) + assert result is not None stored_response, stored_request, stored_metadata = result assert stored_response.body == mock_store_data[0].body else: - assert result is None + with pytest.raises(NotFoundError): + await storage.get(key) @pytest.mark.asyncio diff --git a/tests/storages/test_redis_storage.py b/tests/storages/test_redis_storage.py index f504e00..55802f0 100644 --- a/tests/storages/test_redis_storage.py +++ b/tests/storages/test_redis_storage.py @@ -6,7 +6,7 @@ from starlette.requests import Request from starlette.responses import Response -from fast_cache_middleware.exceptions import StorageError +from fast_cache_middleware.exceptions import NotFoundError, StorageError from fast_cache_middleware.serializers import JSONSerializer from fast_cache_middleware.storages import RedisStorage @@ -89,8 +89,10 @@ async def test_retrieve_returns_none_on_missing_key(): storage = RedisStorage(redis_client=mock_redis) mock_redis.get.return_value = None - result = await storage.get("missing") - assert result is None + with pytest.raises( + NotFoundError, match="Key will be removed from Redis - TTL expired" + ): + await storage.get("missing") @pytest.mark.asyncio @@ -98,7 +100,7 @@ async def test_retrieve_returns_none_on_deserialization_error(): mock_redis = AsyncMock() def raise_error(_): - raise ValueError("bad format") + raise NotFoundError("corrupt") mock_serializer = MagicMock() mock_serializer.loads = raise_error @@ -109,8 +111,8 @@ def raise_error(_): mock_redis.get.return_value = b"invalid" - result = await storage.get("corrupt") - assert result is None + with pytest.raises(NotFoundError): + await storage.get("corrupt") @pytest.mark.asyncio diff --git a/tests/test_controller.py b/tests/test_controller.py index 99e2a7b..534fb31 100644 --- a/tests/test_controller.py +++ b/tests/test_controller.py @@ -1,5 +1,5 @@ """Тесты для контроллера кеширования.""" - +import logging import typing as tp from datetime import UTC, datetime from unittest.mock import AsyncMock, MagicMock @@ -9,6 +9,7 @@ from starlette.responses import Response from fast_cache_middleware.controller import Controller +from fast_cache_middleware.exceptions import NotFoundError from fast_cache_middleware.schemas import CacheConfiguration, RouteInfo from fast_cache_middleware.storages import BaseStorage @@ -193,6 +194,24 @@ async def test_get_cached_response_expired( assert result is None mock_storage.get.assert_called_once_with("test_key") + @pytest.mark.asyncio + async def test_get_cached_deserialization_error( + self, + controller: Controller, + mock_storage: MagicMock, + caplog: pytest.LogCaptureFixture, + ) -> None: + message = "deserialization failed" + mock_storage.get.side_effect = NotFoundError("test_key", message) + + result = await controller.get_cached_response("test_key", mock_storage) + + assert result is None + mock_storage.get.assert_awaited_once_with("test_key") + + with caplog.at_level("WARNING"): + assert message in caplog.text + class TestCacheResponse: """Тесты для сохранения ответа в кеш.""" From b15e7e124cde177c0cb7383554234594d23f33d5 Mon Sep 17 00:00:00 2001 From: Nikita Yakovlev Date: Sun, 27 Jul 2025 20:46:19 +0300 Subject: [PATCH 05/14] write annotation to eng --- tests/storages/test_in_memory_storage.py | 132 +++++++++++++---------- 1 file changed, 76 insertions(+), 56 deletions(-) diff --git a/tests/storages/test_in_memory_storage.py b/tests/storages/test_in_memory_storage.py index ade63d4..597af56 100644 --- a/tests/storages/test_in_memory_storage.py +++ b/tests/storages/test_in_memory_storage.py @@ -9,7 +9,11 @@ from starlette.requests import Request from starlette.responses import Response -from fast_cache_middleware.exceptions import NotFoundError, StorageError +from fast_cache_middleware.exceptions import ( + NotFoundStorageError, + StorageError, + TTLExpiredStorageError, +) from fast_cache_middleware.serializers import Metadata from fast_cache_middleware.storages import InMemoryStorage @@ -26,7 +30,7 @@ def test_initialization_params( max_size: int, ttl: float, expected_error: tp.Type[StorageError] | None ) -> None: - """Тестирует параметры инициализации InMemoryStorage.""" + """Tests InMemoryStorage initialization parameters.""" if expected_error is None: storage = InMemoryStorage(max_size=max_size, ttl=ttl) assert storage._max_size == max_size @@ -40,15 +44,15 @@ def test_initialization_params( @pytest.mark.parametrize( "ttl, wait_time, should_expire", [ - (0.1, 0.15, True), # Должен истечь - (1.0, 0.5, False), # Не должен истечь - (None, 1.0, False), # Без TTL не истекает + (0.1, 0.15, True), # Must expire + (1.0, 0.5, False), # Must not expire + (None, 1.0, False), # Does not expire without TTL ], ) async def test_store_and_retrieve_with_ttl( ttl: tp.Optional[float], wait_time: float, should_expire: bool ) -> None: - """Тестирует сохранение и получение с TTL.""" + """It tests saving and receiving with TTL.""" storage = InMemoryStorage(ttl=ttl) request = Request(scope={"type": "http", "method": "GET", "path": "/test"}) response = Response(content="test", status_code=200) @@ -59,7 +63,7 @@ async def test_store_and_retrieve_with_ttl( await asyncio.sleep(wait_time) if should_expire: - with pytest.raises(NotFoundError): + with pytest.raises(TTLExpiredStorageError): await storage.get("test_key") else: result = await storage.get("test_key") @@ -70,15 +74,15 @@ async def test_store_and_retrieve_with_ttl( @pytest.mark.parametrize( "ttl, cleanup_interval, wait_time, expected_cleanup_calls", [ - (0.1, 0.05, 0.15, 1), # Должен вызвать очистку - (1.0, 0.05, 0.15, 0), # Не должен вызывать очистку - (0.1, 0.2, 0.15, 1), # Интервал очистки больше времени ожидания + (0.1, 0.05, 0.15, 1), # Should trigger the cleanup + (1.0, 0.05, 0.15, 0), # Should not cause cleanup + (0.1, 0.2, 0.15, 1), # The cleaning interval is longer than the waiting time ], ) async def test_expired_items_cleanup( ttl: float, cleanup_interval: float, wait_time: float, expected_cleanup_calls: int ) -> None: - """Тестирует автоматическую очистку истёкших элементов.""" + """It tests the automatic cleaning of expired items.""" storage = InMemoryStorage(max_size=10, ttl=ttl) storage._expiry_check_interval = cleanup_interval @@ -86,37 +90,37 @@ async def test_expired_items_cleanup( response = Response(content="test", status_code=200) metadata = {"key": "value"} - # Добавляем элемент + # Adding an element await storage.set("test_key", response, request, metadata) - # Ждем + # Waiting await asyncio.sleep(wait_time) - # Добавляем еще один элемент, который может вызвать очистку + # Adding another element that can cause a cleanup await storage.set("test_key2", response, request, metadata) - # Проверяем результат + # Checking the result if expected_cleanup_calls > 0: - with pytest.raises(NotFoundError): + with pytest.raises(TTLExpiredStorageError, match="TTL expired"): result = await storage.get("test_key") - assert result is None # Элемент должен быть удален + assert result is None # The element must be deleted else: result = await storage.get("test_key") - assert result is not None # Элемент должен остаться + assert result is not None # The element must remain @pytest.mark.parametrize( "max_size, cleanup_batch_size, cleanup_threshold", [ - (3, 1, 4), # Меньшие значения - (100, 10, 105), # Стандартные значения - (1000, 100, 1050), # Большие значения + (3, 1, 4), # Lower values + (100, 10, 105), # Standard values + (1000, 100, 1050), # Large values ], ) def test_cleanup_parameters_calculation( max_size: int, cleanup_batch_size: int, cleanup_threshold: int ) -> None: - """Тестирует расчет параметров очистки.""" + """Tests the calculation of cleaning parameters.""" storage = InMemoryStorage(max_size=max_size, ttl=None) assert storage._cleanup_batch_size == cleanup_batch_size @@ -128,30 +132,34 @@ def test_cleanup_parameters_calculation( @pytest.mark.parametrize( "max_size, num_items, expected_final_size", [ - (5, 3, 3), # Просто сторит 3 элемента - (3, 5, 4), # Трешхолд 4, удаляем по 1 элементу, - # поэтому после вставки пятого очистка прошла но осталось 4 элемента - (100, 105, 105), # Трешхолд 105, еще не превышен - (100, 106, 100), # Вот теперь перешагнули и удалилось батчем 6 элементов + (5, 3, 3), # Just stores 3 items + (3, 5, 4), # Trash hold 4, we delete 1 element each,, + # therefore, after inserting the fifth, the cleaning went through, but there were 4 elements left. + (100, 105, 105), # Threshold 105, not exceeded yet + ( + 100, + 106, + 100, + ), # Now 6 elements have been stepped over and removed by the batch ], ) async def test_lru_eviction( max_size: int, num_items: int, expected_final_size: int ) -> None: - """Тестирует LRU выселение при превышении лимита.""" + """It tests LRU eviction when the limit is exceeded.""" storage = InMemoryStorage(max_size=max_size, ttl=None) request = Request(scope={"type": "http", "method": "GET", "path": "/test"}) response = Response(content="test", status_code=200) metadata = {"key": "value"} - # Добавляем элементы + # Adding elements for i in range(num_items): await storage.set(f"key_{i}", response, request, metadata) - # Проверяем размер + # Checking the size assert len(storage) == expected_final_size - # Проверяем, что последние добавленные элементы остались + # We check that the last added items remain. for i in range(max(0, num_items - max_size), num_items): result = await storage.get(f"key_{i}") assert result is not None @@ -161,10 +169,18 @@ async def test_lru_eviction( @pytest.mark.parametrize( "retrive_keys, expected_keys, expire_keys", [ - (["first"], ["first"], []), # Читали - остался - (["first"], ["first"], ["second"]), # Не читали - вытеснили - (["first", "second"], ["first", "second"], []), # Читали оба - остались оба - ([], [], ["first", "second"]), # Не читали - вылетели оба + (["first"], ["first"], []), # If you read it, you stayed + ( # If they didn 't read it , they were ousted. + ["first"], + ["first"], + ["second"], + ), + ( # We both read it, but we both stayed + ["first", "second"], + ["first", "second"], + [], + ), + ([], [], ["first", "second"]), # If they didn't read it, they both flew out ], ) async def test_retrieve_updates_lru_position( @@ -173,26 +189,26 @@ async def test_retrieve_updates_lru_position( expire_keys: tp.List[str], mock_store_data: tp.Tuple[Response, Request, Metadata], ) -> None: - """Тестирует обновление позиции LRU при получении элемента.""" + """It tests updating the LRU position when an item is received.""" keys = set(retrive_keys + expire_keys + expected_keys) storage = InMemoryStorage(max_size=len(keys)) for key in keys: await storage.set(key, *mock_store_data) - # Добавляем элементы чтобы заполнить хранилище и получаем то что должно остаться + # We add items to fill up the storage and get what should be left for i in range(storage._cleanup_threshold): await storage.set(f"key_{i}", *mock_store_data) for key in retrive_keys: await storage.get(key) - # Проверяем, какой элемент остался + # Checking which element is left for key in expected_keys: assert await storage.get(key) is not None for key in expire_keys: - with pytest.raises(NotFoundError): + with pytest.raises(NotFoundStorageError, match="Data not found"): await storage.get(key) @@ -200,9 +216,13 @@ async def test_retrieve_updates_lru_position( @pytest.mark.parametrize( "path_pattern, keys, expected_remaining", [ - (r"/api/.*", ["/api/users", "/api/posts", "/admin"], 1), # Удаляет /api/* - (r"/admin", ["/api/users", "/api/posts", "/admin"], 2), # Удаляет только /admin - (r"/nonexistent", ["/api/users", "/api/posts"], 2), # Ничего не удаляет + (r"/api/.*", ["/api/users", "/api/posts", "/admin"], 1), # Deletes /api/* + (r"/admin", ["/api/users", "/api/posts", "/admin"], 2), # Deletes only /admin + ( # It doesn't delete anything + r"/nonexistent", + ["/api/users", "/api/posts"], + 2, + ), ], ) async def test_remove_by_path_pattern( @@ -212,10 +232,10 @@ async def test_remove_by_path_pattern( mock_response: Response, mock_metadata: Metadata, ) -> None: - """Тестирует удаление по паттерну пути.""" + """It tests deletion based on the path pattern.""" storage = InMemoryStorage() - # Добавляем элементы с разными путями + # Adding elements with different paths for key in keys: request = Request( scope={ @@ -227,11 +247,11 @@ async def test_remove_by_path_pattern( ) await storage.set(key, mock_response, request, mock_metadata) - # Удаляем по паттерну + # Deleting according to the pattern pattern = re.compile(path_pattern) await storage.delete(pattern) - # Проверяем количество оставшихся элементов + # Checking the number of remaining elements assert len(storage) == expected_remaining @@ -246,7 +266,7 @@ async def test_remove_by_path_pattern( async def test_retrieve_nonexistent_key( key: str, should_exist: bool, mock_store_data: tp.Tuple[Response, Request, Metadata] ) -> None: - """Тестирует получение несуществующих ключей.""" + """It is testing the receipt of non-existent keys.""" storage = InMemoryStorage() if should_exist: @@ -259,7 +279,7 @@ async def test_retrieve_nonexistent_key( stored_response, stored_request, stored_metadata = result assert stored_response.body == mock_store_data[0].body else: - with pytest.raises(NotFoundError): + with pytest.raises(NotFoundStorageError, match="Data not found"): await storage.get(key) @@ -273,19 +293,19 @@ async def test_retrieve_nonexistent_key( ], ) async def test_close_storage(num_items: int, expected_size_after_close: int) -> None: - """Тестирует закрытие хранилища.""" + """It is testing the closure of the storage.""" storage = InMemoryStorage(max_size=20, ttl=None) request = Request(scope={"type": "http", "method": "GET", "path": "/test"}) response = Response(content="test", status_code=200) metadata = {"key": "value"} - # Добавляем элементы + # Adding elements for i in range(num_items): await storage.set(f"key_{i}", response, request, metadata) assert len(storage) == num_items - # Закрываем хранилище + # Closing the storage await storage.close() assert len(storage) == expected_size_after_close @@ -305,26 +325,26 @@ async def test_close_storage(num_items: int, expected_size_after_close: int) -> async def test_store_overwrite_existing_key( overwrite_key: bool, expected_metadata: Metadata ) -> None: - """Тестирует перезапись существующего ключа.""" + """It is testing overwriting of an existing key.""" storage = InMemoryStorage(max_size=10, ttl=None) request = Request(scope={"type": "http", "method": "GET", "path": "/test"}) response = Response(content="test", status_code=200) - # Добавляем исходный элемент + # Adding the original element original_metadata = {"original": "value"} await storage.set("test_key", response, request, original_metadata) if overwrite_key: - # Перезаписываем элемент + # Overwriting the element new_metadata = {"new": "value"} await storage.set("test_key", response, request, new_metadata) - # Получаем элемент + # Getting the element result = await storage.get("test_key") assert result is not None _, _, stored_metadata = result - # Проверяем метаданные (исключая write_time, так как он динамический) + # We check the metadata (excluding write_time, since it is dynamic) for key, value in expected_metadata.items(): if key != "write_time": assert stored_metadata[key] == value From 8b4b3269d289cddae112b852536e52e8bed941e3 Mon Sep 17 00:00:00 2001 From: Nikita Yakovlev Date: Sun, 27 Jul 2025 20:53:24 +0300 Subject: [PATCH 06/14] add exception for ttl --- fast_cache_middleware/exceptions.py | 9 ++++++--- 1 file changed, 6 insertions(+), 3 deletions(-) diff --git a/fast_cache_middleware/exceptions.py b/fast_cache_middleware/exceptions.py index 05de2f4..b9e9b85 100644 --- a/fast_cache_middleware/exceptions.py +++ b/fast_cache_middleware/exceptions.py @@ -6,8 +6,11 @@ class StorageError(FastCacheMiddlewareError): pass -class NotFoundError(StorageError): +class NotFoundStorageError(StorageError): def __init__(self, key: str, message: str = "Data not found") -> None: - self.key = key - self.message = message + super().__init__(f"{message}. Key: {key}.") + + +class TTLExpiredStorageError(StorageError): + def __init__(self, key: str, message: str = "TTL expired") -> None: super().__init__(f"{message}. Key: {key}.") From 341dfe7db981794dc09285dbf2264da6a8e51ef6 Mon Sep 17 00:00:00 2001 From: Nikita Yakovlev Date: Sun, 27 Jul 2025 20:54:11 +0300 Subject: [PATCH 07/14] update exception --- .../storages/in_memory_storage.py | 10 +++++++--- fast_cache_middleware/storages/redis_storage.py | 14 ++++++++++---- 2 files changed, 17 insertions(+), 7 deletions(-) diff --git a/fast_cache_middleware/storages/in_memory_storage.py b/fast_cache_middleware/storages/in_memory_storage.py index d26226c..23c7a7b 100644 --- a/fast_cache_middleware/storages/in_memory_storage.py +++ b/fast_cache_middleware/storages/in_memory_storage.py @@ -7,7 +7,11 @@ from starlette.requests import Request from starlette.responses import Response -from fast_cache_middleware.exceptions import NotFoundError, StorageError +from fast_cache_middleware.exceptions import ( + NotFoundStorageError, + StorageError, + TTLExpiredStorageError, +) from fast_cache_middleware.serializers import BaseSerializer, Metadata from .base_storage import BaseStorage, StoredResponse @@ -101,12 +105,12 @@ async def get(self, key: str) -> Optional[StoredResponse]: Tuple (response, request, metadata) if found and not expired, None if not found or expired """ if key not in self._storage: - raise NotFoundError(key, message="Key not found at storage") + raise NotFoundStorageError(key) # Lazy TTL check if self._is_expired(key): self._pop_item(key) - raise NotFoundError(key, message="Key removed from cache - TTL expired") + raise TTLExpiredStorageError(key) self._storage.move_to_end(key) diff --git a/fast_cache_middleware/storages/redis_storage.py b/fast_cache_middleware/storages/redis_storage.py index 570905e..3a8f30a 100644 --- a/fast_cache_middleware/storages/redis_storage.py +++ b/fast_cache_middleware/storages/redis_storage.py @@ -11,7 +11,11 @@ from starlette.requests import Request from starlette.responses import Response -from fast_cache_middleware.exceptions import NotFoundError, StorageError +from fast_cache_middleware.exceptions import ( + StorageError, + TTLExpiredStorageError, + NotFoundStorageError, +) from fast_cache_middleware.serializers import BaseSerializer, JSONSerializer, Metadata from .base_storage import BaseStorage, StoredResponse @@ -67,12 +71,14 @@ async def get(self, key: str) -> StoredResponse: Get response from Redis. If TTL expired returns None. """ full_key = self._full_key(key) + + if not self._storage.exists(full_key): + raise TTLExpiredStorageError(full_key) + raw_data = await self._storage.get(full_key) if raw_data is None: - raise NotFoundError( - full_key, message="Key will be removed from Redis - TTL expired" - ) + raise NotFoundStorageError(key) logger.debug(f"Takin data from Redis: %s", raw_data) From 54271defa85f9b956bcf7a589ce4553697beb79e Mon Sep 17 00:00:00 2001 From: Nikita Yakovlev Date: Sun, 27 Jul 2025 20:54:52 +0300 Subject: [PATCH 08/14] add except fot TTL --- fast_cache_middleware/controller.py | 8 +++++--- 1 file changed, 5 insertions(+), 3 deletions(-) diff --git a/fast_cache_middleware/controller.py b/fast_cache_middleware/controller.py index abfecc3..31fc857 100644 --- a/fast_cache_middleware/controller.py +++ b/fast_cache_middleware/controller.py @@ -7,7 +7,7 @@ from starlette.requests import Request from starlette.responses import Response -from .exceptions import NotFoundError +from .exceptions import NotFoundStorageError, TTLExpiredStorageError from .schemas import CacheConfiguration from .storages import BaseStorage @@ -195,9 +195,11 @@ async def get_cached_response( try: result = await storage.get(cache_key) - except NotFoundError as e: - logger.warning("No cache found: %s", e) + except NotFoundStorageError as e: + logger.exception(e) return None + except TTLExpiredStorageError as e: + logger.exception(e) if result is None: return None From 9c8f84856c3e2d8ae8111359b4f54b18b25d04c8 Mon Sep 17 00:00:00 2001 From: Nikita Yakovlev Date: Sun, 27 Jul 2025 21:30:01 +0300 Subject: [PATCH 09/14] union exception for storage --- fast_cache_middleware/controller.py | 6 ++---- 1 file changed, 2 insertions(+), 4 deletions(-) diff --git a/fast_cache_middleware/controller.py b/fast_cache_middleware/controller.py index 31fc857..1078c1d 100644 --- a/fast_cache_middleware/controller.py +++ b/fast_cache_middleware/controller.py @@ -195,11 +195,9 @@ async def get_cached_response( try: result = await storage.get(cache_key) - except NotFoundStorageError as e: - logger.exception(e) + except (NotFoundStorageError, TTLExpiredStorageError) as e: + logger.warning(e) return None - except TTLExpiredStorageError as e: - logger.exception(e) if result is None: return None From 403ad71ffde3b9a906aff4dae66f75109b180bef Mon Sep 17 00:00:00 2001 From: Nikita Yakovlev Date: Sun, 27 Jul 2025 21:30:04 +0300 Subject: [PATCH 10/14] add test for TTL in controller.py --- tests/test_controller.py | 27 ++++++++++++++++++++------- 1 file changed, 20 insertions(+), 7 deletions(-) diff --git a/tests/test_controller.py b/tests/test_controller.py index 534fb31..aabbaa2 100644 --- a/tests/test_controller.py +++ b/tests/test_controller.py @@ -9,7 +9,10 @@ from starlette.responses import Response from fast_cache_middleware.controller import Controller -from fast_cache_middleware.exceptions import NotFoundError +from fast_cache_middleware.exceptions import ( + NotFoundStorageError, + TTLExpiredStorageError, +) from fast_cache_middleware.schemas import CacheConfiguration, RouteInfo from fast_cache_middleware.storages import BaseStorage @@ -195,22 +198,32 @@ async def test_get_cached_response_expired( mock_storage.get.assert_called_once_with("test_key") @pytest.mark.asyncio - async def test_get_cached_deserialization_error( + @pytest.mark.parametrize( + "exception_cls, message", + [ + (NotFoundStorageError, "deserialization failed"), + (TTLExpiredStorageError, "ttl expired"), + ], + ) + async def test_get_cached_storage_errors( self, controller: Controller, mock_storage: MagicMock, caplog: pytest.LogCaptureFixture, + exception_cls: type[Exception], + message: str, ) -> None: - message = "deserialization failed" - mock_storage.get.side_effect = NotFoundError("test_key", message) + # Ensure caplog is active before the call + caplog.set_level("WARNING") + + # Mock error on get + mock_storage.get.side_effect = exception_cls("test_key", message) result = await controller.get_cached_response("test_key", mock_storage) assert result is None mock_storage.get.assert_awaited_once_with("test_key") - - with caplog.at_level("WARNING"): - assert message in caplog.text + assert message in caplog.text class TestCacheResponse: From 229e401a121ff2ee80f7b7da337e8759b4ad73a0 Mon Sep 17 00:00:00 2001 From: Nikita Yakovlev Date: Sun, 27 Jul 2025 21:30:19 +0300 Subject: [PATCH 11/14] change exception to new --- tests/storages/test_in_memory_storage.py | 24 +++++++--- tests/storages/test_redis_storage.py | 57 +++++++++++++++++------- 2 files changed, 58 insertions(+), 23 deletions(-) diff --git a/tests/storages/test_in_memory_storage.py b/tests/storages/test_in_memory_storage.py index 597af56..b66d541 100644 --- a/tests/storages/test_in_memory_storage.py +++ b/tests/storages/test_in_memory_storage.py @@ -72,15 +72,25 @@ async def test_store_and_retrieve_with_ttl( @pytest.mark.asyncio @pytest.mark.parametrize( - "ttl, cleanup_interval, wait_time, expected_cleanup_calls", + "ttl, cleanup_interval, wait_time, expected_cleanup_calls, expect_error", [ - (0.1, 0.05, 0.15, 1), # Should trigger the cleanup - (1.0, 0.05, 0.15, 0), # Should not cause cleanup - (0.1, 0.2, 0.15, 1), # The cleaning interval is longer than the waiting time + (0.1, 0.05, 0.15, 1, NotFoundStorageError), # Should trigger the cleanup + (1.0, 0.05, 0.15, 0, None), # Should not cause cleanup + ( # The cleaning interval is longer than the waiting time + 0.1, + 0.2, + 0.15, + 1, + TTLExpiredStorageError, + ), ], ) async def test_expired_items_cleanup( - ttl: float, cleanup_interval: float, wait_time: float, expected_cleanup_calls: int + ttl: float, + cleanup_interval: float, + wait_time: float, + expected_cleanup_calls: int, + expect_error: TTLExpiredStorageError | NotFoundStorageError | None, ) -> None: """It tests the automatic cleaning of expired items.""" storage = InMemoryStorage(max_size=10, ttl=ttl) @@ -100,8 +110,8 @@ async def test_expired_items_cleanup( await storage.set("test_key2", response, request, metadata) # Checking the result - if expected_cleanup_calls > 0: - with pytest.raises(TTLExpiredStorageError, match="TTL expired"): + if expected_cleanup_calls > 0 and expect_error is not None: + with pytest.raises(expect_error): result = await storage.get("test_key") assert result is None # The element must be deleted else: diff --git a/tests/storages/test_redis_storage.py b/tests/storages/test_redis_storage.py index 55802f0..db60883 100644 --- a/tests/storages/test_redis_storage.py +++ b/tests/storages/test_redis_storage.py @@ -6,7 +6,11 @@ from starlette.requests import Request from starlette.responses import Response -from fast_cache_middleware.exceptions import NotFoundError, StorageError +from fast_cache_middleware.exceptions import ( + NotFoundStorageError, + StorageError, + TTLExpiredStorageError, +) from fast_cache_middleware.serializers import JSONSerializer from fast_cache_middleware.storages import RedisStorage @@ -21,7 +25,9 @@ (0, StorageError), ], ) -async def test_redis_storage_init_validation(ttl, expect_error): +async def test_redis_storage_init_validation( + ttl: float, expect_error: StorageError | None +) -> None: mock_redis = AsyncMock() if expect_error: @@ -34,7 +40,7 @@ async def test_redis_storage_init_validation(ttl, expect_error): @pytest.mark.asyncio -async def test_store_and_retrieve_works(): +async def test_store_and_retrieve_works() -> None: mock_redis = AsyncMock() mock_serializer = MagicMock() @@ -62,7 +68,7 @@ async def test_store_and_retrieve_works(): @pytest.mark.asyncio -async def test_store_overwrites_existing_key(): +async def test_store_overwrites_existing_key() -> None: mock_redis = AsyncMock() mock_serializer = MagicMock() @@ -84,23 +90,41 @@ async def test_store_overwrites_existing_key(): @pytest.mark.asyncio -async def test_retrieve_returns_none_on_missing_key(): +async def test_retrieve_returns_none_on_missing_key() -> None: mock_redis = AsyncMock() storage = RedisStorage(redis_client=mock_redis) mock_redis.get.return_value = None - with pytest.raises( - NotFoundError, match="Key will be removed from Redis - TTL expired" - ): + with pytest.raises(NotFoundStorageError, match="Data not found"): await storage.get("missing") @pytest.mark.asyncio -async def test_retrieve_returns_none_on_deserialization_error(): +async def test_retrieve_returns_none_on_deserialization_error() -> None: mock_redis = AsyncMock() def raise_error(_): - raise NotFoundError("corrupt") + raise NotFoundStorageError("missing") + + mock_serializer = MagicMock() + mock_serializer.loads = raise_error + + mock_serializer.dumps = AsyncMock(return_value=b"serialized") + + storage = RedisStorage(redis_client=mock_redis, serializer=mock_serializer) + + mock_redis.get.return_value = b"invalid" + + with pytest.raises(NotFoundStorageError, match="Data not found"): + await storage.get("missing") + + +@pytest.mark.asyncio +async def test_retrieve_returns_none_if_ttl_expired() -> None: + mock_redis = AsyncMock() + + def raise_error(_) -> None: + raise TTLExpiredStorageError("corrupt") mock_serializer = MagicMock() mock_serializer.loads = raise_error @@ -111,12 +135,13 @@ def raise_error(_): mock_redis.get.return_value = b"invalid" - with pytest.raises(NotFoundError): - await storage.get("corrupt") + with pytest.raises(TTLExpiredStorageError, match="TTL expired"): + result = await storage.get("corrupt") + print(result) @pytest.mark.asyncio -async def test_remove_by_regex(): +async def test_remove_by_regex() -> None: mock_redis = AsyncMock() storage = RedisStorage(redis_client=mock_redis, namespace="myspace") @@ -131,7 +156,7 @@ async def test_remove_by_regex(): @pytest.mark.asyncio -async def test_remove_with_no_matches_logs_warning(): +async def test_remove_with_no_matches_logs_warning() -> None: mock_redis = AsyncMock() storage = RedisStorage(redis_client=mock_redis, namespace="myspace") @@ -143,7 +168,7 @@ async def test_remove_with_no_matches_logs_warning(): @pytest.mark.asyncio -async def test_close_flushes_database(): +async def test_close_flushes_database() -> None: mock_redis = AsyncMock() storage = RedisStorage(redis_client=mock_redis) @@ -151,7 +176,7 @@ async def test_close_flushes_database(): mock_redis.flushdb.assert_awaited_once() -def test_full_key(): +def test_full_key() -> None: mock_redis = AsyncMock() storage = RedisStorage(redis_client=mock_redis, namespace="custom") From ec9e93d25dbf5f17856586c4e6606f60b88b98cb Mon Sep 17 00:00:00 2001 From: Nikita Yakovlev Date: Sun, 27 Jul 2025 21:35:57 +0300 Subject: [PATCH 12/14] add await to exists redis methods --- fast_cache_middleware/storages/redis_storage.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/fast_cache_middleware/storages/redis_storage.py b/fast_cache_middleware/storages/redis_storage.py index 3a8f30a..4c6fd98 100644 --- a/fast_cache_middleware/storages/redis_storage.py +++ b/fast_cache_middleware/storages/redis_storage.py @@ -72,7 +72,7 @@ async def get(self, key: str) -> StoredResponse: """ full_key = self._full_key(key) - if not self._storage.exists(full_key): + if not await self._storage.exists(full_key): raise TTLExpiredStorageError(full_key) raw_data = await self._storage.get(full_key) From d0dd7a9caca14caa5d19788212cff8765dc253aa Mon Sep 17 00:00:00 2001 From: Nikita Yakovlev Date: Sun, 27 Jul 2025 21:36:06 +0300 Subject: [PATCH 13/14] fix test after changes --- tests/storages/test_redis_storage.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/tests/storages/test_redis_storage.py b/tests/storages/test_redis_storage.py index db60883..229f47e 100644 --- a/tests/storages/test_redis_storage.py +++ b/tests/storages/test_redis_storage.py @@ -56,7 +56,7 @@ async def test_store_and_retrieve_works() -> None: response = Response(content="hello", status_code=200) metadata: dict[str, str | int] = {} - mock_redis.exists.return_value = False + mock_redis.exists.return_value = True await storage.set("key1", response, request, metadata) mock_redis.set.assert_awaited_with("cache:key1", serialized_value, ex=1) From 34b80fc5dd9206a053fb16171c3e7f0c1b15a6a6 Mon Sep 17 00:00:00 2001 From: Nikita Yakovlev Date: Sun, 27 Jul 2025 21:40:32 +0300 Subject: [PATCH 14/14] fix lint --- fast_cache_middleware/storages/redis_storage.py | 2 +- tests/storages/test_in_memory_storage.py | 4 ++-- tests/storages/test_redis_storage.py | 4 ++-- 3 files changed, 5 insertions(+), 5 deletions(-) diff --git a/fast_cache_middleware/storages/redis_storage.py b/fast_cache_middleware/storages/redis_storage.py index 4c6fd98..b286d14 100644 --- a/fast_cache_middleware/storages/redis_storage.py +++ b/fast_cache_middleware/storages/redis_storage.py @@ -12,9 +12,9 @@ from starlette.responses import Response from fast_cache_middleware.exceptions import ( + NotFoundStorageError, StorageError, TTLExpiredStorageError, - NotFoundStorageError, ) from fast_cache_middleware.serializers import BaseSerializer, JSONSerializer, Metadata diff --git a/tests/storages/test_in_memory_storage.py b/tests/storages/test_in_memory_storage.py index b66d541..a388610 100644 --- a/tests/storages/test_in_memory_storage.py +++ b/tests/storages/test_in_memory_storage.py @@ -76,7 +76,7 @@ async def test_store_and_retrieve_with_ttl( [ (0.1, 0.05, 0.15, 1, NotFoundStorageError), # Should trigger the cleanup (1.0, 0.05, 0.15, 0, None), # Should not cause cleanup - ( # The cleaning interval is longer than the waiting time + ( # The cleaning interval is longer than the waiting time 0.1, 0.2, 0.15, @@ -90,7 +90,7 @@ async def test_expired_items_cleanup( cleanup_interval: float, wait_time: float, expected_cleanup_calls: int, - expect_error: TTLExpiredStorageError | NotFoundStorageError | None, + expect_error: tp.Type[BaseException] | None, ) -> None: """It tests the automatic cleaning of expired items.""" storage = InMemoryStorage(max_size=10, ttl=ttl) diff --git a/tests/storages/test_redis_storage.py b/tests/storages/test_redis_storage.py index 229f47e..bf5b3d2 100644 --- a/tests/storages/test_redis_storage.py +++ b/tests/storages/test_redis_storage.py @@ -1,5 +1,5 @@ import re -from typing import cast +from typing import Type from unittest.mock import AsyncMock, MagicMock import pytest @@ -26,7 +26,7 @@ ], ) async def test_redis_storage_init_validation( - ttl: float, expect_error: StorageError | None + ttl: float, expect_error: Type[BaseException] | None ) -> None: mock_redis = AsyncMock()