Skip to content

Commit d941755

Browse files
committed
feat: add ResponseCache utility class
1 parent afb24b4 commit d941755

File tree

3 files changed

+162
-0
lines changed

3 files changed

+162
-0
lines changed

src/gradient/_utils/__init__.py

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -32,6 +32,7 @@
3232
validate_api_key as validate_api_key,
3333
validate_client_credentials as validate_client_credentials,
3434
validate_client_instance as validate_client_instance,
35+
ResponseCache as ResponseCache,
3536
)
3637
from ._compat import (
3738
get_args as get_args,

src/gradient/_utils/_utils.py

Lines changed: 78 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -421,6 +421,84 @@ def json_safe(data: object) -> object:
421421
return data
422422

423423

424+
# Response Caching Classes
425+
class ResponseCache:
426+
"""Simple in-memory response cache with TTL support."""
427+
428+
def __init__(self, max_size: int = 100, default_ttl: int = 300) -> None:
429+
"""Initialize the cache.
430+
431+
Args:
432+
max_size: Maximum number of cached responses
433+
default_ttl: Default time-to-live in seconds
434+
"""
435+
self.max_size: int = max_size
436+
self.default_ttl: int = default_ttl
437+
self._cache: dict[str, tuple[Any, float]] = {}
438+
self._access_order: list[str] = []
439+
440+
def _make_key(self, method: str, url: str, params: dict[str, Any] | None = None, data: Any = None) -> str:
441+
"""Generate a cache key from request details."""
442+
import hashlib
443+
import json
444+
445+
key_data = {
446+
"method": method.upper(),
447+
"url": url,
448+
"params": params or {},
449+
"data": json.dumps(data, sort_keys=True) if data else None
450+
}
451+
key_str = json.dumps(key_data, sort_keys=True)
452+
return hashlib.md5(key_str.encode()).hexdigest()
453+
454+
def get(self, method: str, url: str, params: dict[str, Any] | None = None, data: Any = None) -> Any | None:
455+
"""Get a cached response if available and not expired."""
456+
import time
457+
458+
key = self._make_key(method, url, params, data)
459+
if key in self._cache:
460+
response, expiry = self._cache[key]
461+
if time.time() < expiry:
462+
# Move to end (most recently used)
463+
self._access_order.remove(key)
464+
self._access_order.append(key)
465+
return response
466+
else:
467+
# Expired, remove it
468+
del self._cache[key]
469+
self._access_order.remove(key)
470+
return None
471+
472+
def set(self, method: str, url: str, response: Any, ttl: int | None = None,
473+
params: dict[str, Any] | None = None, data: Any = None) -> None:
474+
"""Cache a response with optional TTL."""
475+
import time
476+
477+
key = self._make_key(method, url, params, data)
478+
expiry = time.time() + (ttl or self.default_ttl)
479+
480+
# Remove if already exists
481+
if key in self._cache:
482+
self._access_order.remove(key)
483+
484+
# Evict least recently used if at capacity
485+
if len(self._cache) >= self.max_size:
486+
lru_key = self._access_order.pop(0)
487+
del self._cache[lru_key]
488+
489+
self._cache[key] = (response, expiry)
490+
self._access_order.append(key)
491+
492+
def clear(self) -> None:
493+
"""Clear all cached responses."""
494+
self._cache.clear()
495+
self._access_order.clear()
496+
497+
def size(self) -> int:
498+
"""Get current cache size."""
499+
return len(self._cache)
500+
501+
424502
# API Key Validation Functions
425503
def validate_api_key(api_key: str | None) -> bool:
426504
"""Validate an API key format.

tests/test_response_cache.py

Lines changed: 83 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,83 @@
1+
"""Tests for response caching functionality."""
2+
3+
import time
4+
import pytest
5+
from gradient._utils import ResponseCache
6+
7+
8+
class TestResponseCache:
9+
"""Test response caching functionality."""
10+
11+
def test_cache_basic_operations(self):
12+
"""Test basic cache operations."""
13+
cache = ResponseCache(max_size=3, default_ttl=1)
14+
15+
# Test set and get
16+
cache.set("GET", "/api/test", {"data": "value"})
17+
result = cache.get("GET", "/api/test")
18+
assert result == {"data": "value"}
19+
20+
# Test cache miss
21+
result = cache.get("GET", "/api/missing")
22+
assert result is None
23+
24+
def test_cache_with_params(self):
25+
"""Test caching with query parameters."""
26+
cache = ResponseCache()
27+
28+
# Set with params
29+
cache.set("GET", "/api/search", {"results": []}, params={"q": "test"})
30+
31+
# Get with same params should hit
32+
result = cache.get("GET", "/api/search", params={"q": "test"})
33+
assert result == {"results": []}
34+
35+
# Get with different params should miss
36+
result = cache.get("GET", "/api/search", params={"q": "other"})
37+
assert result is None
38+
39+
def test_cache_ttl(self):
40+
"""Test cache TTL functionality."""
41+
cache = ResponseCache(default_ttl=0.1) # Very short TTL
42+
43+
cache.set("GET", "/api/test", {"data": "value"})
44+
45+
# Should hit immediately
46+
result = cache.get("GET", "/api/test")
47+
assert result == {"data": "value"}
48+
49+
# Wait for expiry
50+
time.sleep(0.2)
51+
52+
# Should miss after expiry
53+
result = cache.get("GET", "/api/test")
54+
assert result is None
55+
56+
def test_cache_max_size(self):
57+
"""Test cache size limits with LRU eviction."""
58+
cache = ResponseCache(max_size=2)
59+
60+
# Fill cache
61+
cache.set("GET", "/api/1", "data1")
62+
cache.set("GET", "/api/2", "data2")
63+
assert cache.size() == 2
64+
65+
# Add third item (should evict first)
66+
cache.set("GET", "/api/3", "data3")
67+
assert cache.size() == 2
68+
69+
# First item should be gone
70+
assert cache.get("GET", "/api/1") is None
71+
assert cache.get("GET", "/api/2") == "data2"
72+
assert cache.get("GET", "/api/3") == "data3"
73+
74+
def test_cache_clear(self):
75+
"""Test cache clearing."""
76+
cache = ResponseCache()
77+
78+
cache.set("GET", "/api/test", {"data": "value"})
79+
assert cache.size() == 1
80+
81+
cache.clear()
82+
assert cache.size() == 0
83+
assert cache.get("GET", "/api/test") is None

0 commit comments

Comments
 (0)