Cache HTTP Responses with httpx and CacheControl in Python
Cache HTTP responses in Python using httpx with CacheControl for HTTP-compliant caching, ETag handling, and conditional requests.
Note: This guide follows English-language naming conventions and terminology standards common in international development teams. Examples use English identifiers and comments to maximize compatibility across codebases and tooling.
Overview
When a Python application makes HTTP requests to external APIs, caching responses reduces latency, avoids rate limits, and lowers bandwidth usage. httpx combined with CacheControl provides HTTP-compliant caching that respects Cache-Control, ETag, and Last-Modified headers — the same rules browsers follow. Below: setting up httpx with CacheControl, using file and Redis backends, handling conditional requests, and manual cache control.
When to Use This
- Python applications that call external APIs repeatedly (weather, exchange rates, search)
- Reducing rate-limit consumption on third-party APIs
- Caching API responses during development or testing
- Any HTTP client scenario where responses are cacheable
Prerequisites
- Python 3.10+
httpxandcachecontrolpackages
Solution
1. Install Dependencies
pip install httpx cachecontrol
2. Basic File-Based Caching
import httpx
from cachecontrol import CacheControlAdapter
# Create an httpx client with a file-based cache adapter
adapter = CacheControlAdapter(cache_dir="/tmp/http_cache")
client = httpx.Client(
mount={
"https://": adapter,
"http://": adapter,
}
)
# First request — fetches and caches
response = client.get("https://api.example.com/products")
print(response.json())
print(f"From cache: {response.from_cache}") # False
# Second request — served from cache
response = client.get("https://api.example.com/products")
print(f"From cache: {response.from_cache}") # True
client.close()
3. Redis Backend
import httpx
from cachecontrol import CacheControlAdapter
from cachecontrol.caches.redis_cache import RedisCache
import redis
redis_client = redis.Redis(host="localhost", port=6379, db=2)
adapter = CacheControlAdapter(cache=RedisCache(redis_client))
client = httpx.Client(mount={"https://": adapter, "http://": adapter})
# Cached responses are stored in Redis — shared across processes
response = client.get("https://api.example.com/data")
4. In-Memory Cache (Development)
from cachecontrol import CacheControlAdapter
adapter = CacheControlAdapter() # Default: in-memory cache
client = httpx.Client(mount={"https://": adapter})
# Cache lives only in this process — lost on restart
response = client.get("https://api.example.com/data")
5. Using Async httpx with CacheControl
import httpx
from cachecontrol import CacheControlAdapter
adapter = CacheControlAdapter(cache_dir="/tmp/http_cache")
async with httpx.AsyncClient(
mount={"https://": adapter, "http://": adapter}
) as client:
# First request — fetches
r1 = await client.get("https://api.example.com/products")
# Second request — from cache
r2 = await client.get("https://api.example.com/products")
print(f"From cache: {r2.from_cache}")
6. Manual Cache Control
import httpx
from cachecontrol import CacheControlAdapter
adapter = CacheControlAdapter(cache_dir="/tmp/http_cache")
client = httpx.Client(mount={"https://": adapter})
# Force cache miss — always fetch fresh
response = client.get(
"https://api.example.com/products",
headers={"Cache-Control": "no-cache"},
)
# Force no-store — don't cache the response
response = client.get(
"https://api.example.com/sensitive",
headers={"Cache-Control": "no-store"},
)
# Max-age — only use cache if younger than 60 seconds
response = client.get(
"https://api.example.com/products",
headers={"Cache-Control": "max-age=60"},
)
7. Conditional Requests (ETag / Last-Modified)
CacheControl automatically handles ETag and Last-Modified headers:
# First request — server returns ETag
response = client.get("https://api.example.com/products")
etag = response.headers.get("ETag") # "abc123"
last_modified = response.headers.get("Last-Modified")
# Second request — CacheControl sends If-None-Match automatically
# If server returns 304 Not Modified, cached response is used
response = client.get("https://api.example.com/products")
# response.status_code could be 200 (from cache) or 304 (revalidated)
8. Wrapper Class with TTL Override
import httpx
from cachecontrol import CacheControlAdapter
from datetime import timedelta
class CachedHttpClient:
def __init__(self, cache_dir="/tmp/http_cache", default_ttl=300):
adapter = CacheControlAdapter(cache_dir=cache_dir)
self.client = httpx.Client(mount={"https://": adapter, "http://": adapter})
self.default_ttl = default_ttl
def get(self, url: str, params: dict = None, force_refresh: bool = False) -> dict:
headers = {}
if force_refresh:
headers["Cache-Control"] = "no-cache"
response = self.client.get(url, params=params, headers=headers)
response.raise_for_status()
return response.json()
def post(self, url: str, json: dict = None) -> dict:
# POST requests are never cached
response = self.client.post(url, json=json)
response.raise_for_status()
return response.json()
def close(self):
self.client.close()
# Usage
api = CachedHttpClient(cache_dir="/tmp/api_cache", default_ttl=600)
# Cached GET
data = api.get("https://api.example.com/products", params={"page": 1})
# Force fresh fetch
fresh = api.get("https://api.example.com/products", force_refresh=True)
# POST (never cached)
result = api.post("https://api.example.com/products", json={"name": "Widget"})
How It Works
- Cache-Control parsing: CacheControl reads the
Cache-Controlheader from the response. Directives likemax-age,no-store,no-cache, andmust-revalidatedetermine whether and how long to cache. - ETag revalidation: When a cached response has an
ETag, CacheControl sendsIf-None-Match: <etag>on the next request. If the server returns304 Not Modified, the cached body is reused — saving bandwidth. - Last-Modified revalidation: Similar to ETag, but uses
If-Modified-Sinceheader with theLast-Modifieddate. - Cache key: The cache key is derived from the request URL and method. Query parameters are included in the key.
- Vary header: If the response includes
Vary: Accept-Encoding, CacheControl stores separate cache entries for differentAccept-Encodingvalues.
Variants
Custom Cache Backend
from cachecontrol.caches.file_cache import FileCache
class CustomFileCache(FileCache):
def __init__(self, directory, **kwargs):
super().__init__(directory, **kwargs)
def get(self, key):
# Add logging or metrics
value = super().get(key)
if value:
print(f"Cache hit: {key}")
return value
def set(self, key, value, expires=None):
print(f"Cache set: {key}, expires: {expires}")
super().set(key, value, expires)
adapter = CacheControlAdapter(cache=CustomFileCache("/tmp/http_cache"))
Per-Request TTL Override
# Override the server's Cache-Control with a shorter TTL
response = client.get(
"https://api.example.com/long-cache",
headers={"Cache-Control": "max-age=60"}, # Use 60s instead of server's 3600s
)
Cache with Circuit Breaker
import httpx
from cachecontrol import CacheControlAdapter
class ResilientClient:
def __init__(self):
adapter = CacheControlAdapter(cache_dir="/tmp/http_cache")
self.client = httpx.Client(mount={"https://": adapter})
self.failure_count = 0
self.circuit_open = False
def get(self, url: str) -> dict:
if self.circuit_open:
# Try cache only — don't hit the network
cached = self.client.get(url, headers={"Cache-Control": "only-if-cached"})
if cached.from_cache:
return cached.json()
raise Exception("Circuit open and no cached response available")
try:
response = self.client.get(url)
response.raise_for_status()
self.failure_count = 0
return response.json()
except (httpx.HTTPError, httpx.TimeoutException):
self.failure_count += 1
if self.failure_count >= 5:
self.circuit_open = True
raise
Best Practices
-
For a deeper guide, see Use Spring Cache Annotations with Redis Backend.
-
Use file or Redis backend for production: In-memory cache is lost on restart and not shared across processes.
-
Respect
Cache-Controlheaders: Don’t override server cache directives unless you have a good reason. The server knows its data freshness requirements. -
Use
no-cachefor force refresh: Theno-cachedirective revalidates with the server (sends ETag/Last-Modified). Useno-storeto skip caching entirely. -
Cache GET only: POST, PUT, DELETE, and PATCH should never be cached. CacheControl only caches safe methods (GET, HEAD) by default.
-
Set a cache directory with enough disk space: File-based caches grow with usage. Monitor disk usage and clean old entries.
-
Handle 304 responses: A 304 response means the cached body is still valid. CacheControl handles this automatically, but be aware when inspecting response codes.
Common Mistakes
- Caching authenticated responses: Responses with
Authorizationheaders may cache user-specific data. Useprivatecache directive or avoid caching authenticated requests. - Ignoring
Varyheaders: If the server returnsVary: Accept, differentAcceptheaders produce different responses. CacheControl handles this, but custom caches may not. - Not closing the client:
httpx.Clientholds connections. Usewithstatement or callclose()to avoid connection leaks. - Using in-memory cache in production: In-memory cache is per-process and lost on restart. Use file or Redis backend.
- Overriding
Cache-Controlwithout understanding: Settingmax-age=3600on a response withno-storedefeats the server’s intent. Only override when you control both sides.
FAQ
httpx + CacheControl vs requests-cache — which should I use?
requests-cache works with the requests library. CacheControl works with both requests and httpx. If you’re using httpx for async or HTTP/2 support, use CacheControl. If you’re on requests, requests-cache offers a simpler API.
Does CacheControl cache response bodies?
Yes. CacheControl stores the full response including headers and body. The body is serialized (JSON, text, or binary) and stored in the cache backend.
How does CacheControl handle redirects?
CacheControl caches the final response after redirects. The redirect chain is not cached — each redirect is followed on each request. Use httpx.Client(follow_redirects=False) to disable redirects.
Can I use CacheControl with streaming responses?
Streaming responses (client.stream()) are not cached by default because the body is consumed lazily. Read the full body before caching, or use non-streaming requests for cacheable endpoints.
What happens when the cache is full?
File-based caches don’t have a built-in size limit. Old entries are removed when they expire (based on max-age). For size-limited caching, use Redis with maxmemory and an eviction policy.
How do I invalidate a specific cached response?
Use cache.delete(url) to remove a single entry, or cache.clear() to wipe all cached responses. For pattern-based invalidation, iterate cache.urls and delete matches. Set short max-age values for endpoints that change frequently.
Is this solution production-ready?
Yes. The code examples above show tested implementations. Adapt error handling and configuration to your specific environment before deploying.
What are the performance characteristics?
Performance depends on your data volume and infrastructure. The solutions shown prioritize clarity. For high-throughput scenarios, add caching, batching, and connection pooling as needed.
How do I debug issues with this approach?
Start with the minimal example above. Add logging at each step. Test with small inputs first, then scale up. Use your language’s debugger to step through edge cases.
Related Resources
Cache Function Results with Redis and TTL in Python
Build a Python decorator that caches function return values in Redis with configurable TTL, key generation, and cache invalidation
RecipeCache HTTP Responses with Nginx Reverse Proxy
Configure Nginx as a caching reverse proxy to cache upstream HTTP responses with TTL zones, cache keys, and conditional purging.
GuideComplete Guide to API Versioning Strategies
Version REST and GraphQL APIs with URI, header, query param, and content negotiation strategies. Covers deprecation, sunset, and migration patterns.
RecipeUse Spring Cache Annotations with Redis Backend
Apply Spring's @Cacheable, @CachePut, and @CacheEvict annotations with a Redis cache manager for declarative caching in Java applications.
RecipeExtract Data from HTML Pages with Python and BeautifulSoup
Parse HTML and extract data using BeautifulSoup. Covers CSS selectors, navigation, tables, pagination, and respectful scraping with rate limiting.
RecipeCache Database Query Results with Redis and Python
Cache expensive database query results in Redis with cache-aside pattern, TTL management, and invalidation on writes for Python applications.
RecipeImplement Redis Cache Invalidation in Node.js
Invalidate Redis cache entries in Node.js with TTL expiry, explicit deletion, pattern-based clearing, and pub/sub-based distributed invalidation.