Skip to content

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.

uv add "zapros[caching]"                    # sync clients
uv add "zapros[caching]" "hishel[async]"    # Async* clients
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:

await s3.put_object("bucket", "big.bin", body=data, cache_control="no-cache")

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 GET and HEAD responses are cached. Every other operation goes straight to S3.
  • The cache key is the URL. Range requests (range=) bypass the cache.
  • A 304 is 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: a 304 has no S3 error body, so the operation raises UnknownServiceError.