Caching
Caching¶
Every download from S3 is billed as data transfer out, about 0.09 USD per GB, so a 5 GB object costs about 0.45 USD each time it is fetched. S3 supports HTTP conditional requests: send the ETag you already have as If-None-Match, and if the object has not changed S3 answers 304 Not Modified with no body and no transfer charge. With the zapros cache middleware the SDK does this for you: the first download is stored locally, every later download is a cheap revalidation, and the body is transferred again only when the object changed.
from hishel import AsyncSqliteStorage, CacheOptions, SpecificationPolicy
from zapros import AsyncStdNetworkHandler, CacheMiddleware
from capo_s3 import AsyncS3Client
cache = CacheMiddleware(
AsyncStdNetworkHandler(),
# S3 requests carry an Authorization header, which a shared cache must not store: use a private one
policy=SpecificationPolicy(CacheOptions(shared=False)),
# defaults to hishel_cache.db in the working directory
storage=AsyncSqliteStorage(database_path="s3-cache.db"),
)
async with AsyncS3Client(http_handler=cache) as s3:
async with s3.get_object("bucket", "big.bin") as response: # 200: downloaded and stored
async for chunk in response["body"]:
...
async with s3.get_object("bucket", "big.bin") as response: # 304: body served from the cache
async for chunk in response["body"]:
...
On the wire:
| Download | Request | Response | Transfer billed |
|---|---|---|---|
| first | GET /big.bin |
200, 5 GB body |
5 GB |
| later, object unchanged | GET /big.bin + If-None-Match: "<etag>" |
304, no body |
none |
| later, object changed | GET /big.bin + If-None-Match: "<etag>" |
200, new body, stored |
new size |
Sync:
from hishel import CacheOptions, SpecificationPolicy, SyncSqliteStorage
from zapros import CacheMiddleware, StdNetworkHandler
from capo_s3 import S3Client
cache = CacheMiddleware(
StdNetworkHandler(),
policy=SpecificationPolicy(CacheOptions(shared=False)),
storage=SyncSqliteStorage(database_path="s3-cache.db"),
)
with S3Client(http_handler=cache) as s3:
with s3.get_object("bucket", "big.bin") as response:
for chunk in response["body"]:
...
Make sure the cache asks S3 on every download. Without a Cache-Control header on the object, the cache may serve a stored copy without checking. Set the header on the object:
See what the cache did for each operation:
async def cache_log(request, next):
response = await next(request)
print(response.response.context["caching"]) # {'from_cache': True, 'revalidated': True, 'stored': False, ...}
return response
async with AsyncS3Client(http_handler=cache, operation_interceptors=[cache_log]) as s3:
...
Notes:
- Only
GETandHEADresponses are cached. Every other operation goes straight to S3. - The cache key is the URL. Range requests (
range=) bypass the cache. - A
304is still a GET request, billed per thousand requests. Only the transfer is free. - Entries stay until evicted.
AsyncSqliteStorage(default_ttl=86400)drops them after that many seconds. - Passing
if_none_match=yourself is not a substitute: a304has no S3 error body, so the operation raisesUnknownServiceError.