Why Your S3 Cache Never Hits: Debugging boto3 NoSuchBucket
If an S3-backed cache returns a miss on every request, check the bucket name before anything else. When the code points at a bucket that does not exist, every get_object raises NoSuchBucket, a broad except turns it into a cache miss, and every put_object fails quietly. Here is how to find the error with boto3 and stop it from hiding again.
The symptom: X-Cache is always "miss"
A Flask service stores LLM answers in S3, one object per question. A repeated question should come from the cache, but even two identical requests in a row both miss:
curl -si -X POST localhost:8000/ask -H 'Content-Type: application/json' \
-d '{"query":"hi"}' | grep -i x-cache
# X-Cache: miss
curl -si -X POST localhost:8000/ask -H 'Content-Type: application/json' \
-d '{"query":"hi"}' | grep -i x-cache
# X-Cache: miss
The service still answers, so nothing looks broken. But every request pays for a full model call, and the cache does nothing.
Where the error hides
Here is the cache code:
BUCKET = "cache"
def cache_get(query: str) -> str | None:
try:
obj = s3.get_object(Bucket=BUCKET, Key=_key(query))
return obj["Body"].read().decode()
except (s3.exceptions.NoSuchKey, botocore.exceptions.ClientError):
return None
def cache_put(query: str, answer: str) -> None:
try:
s3.put_object(Bucket=BUCKET, Key=_key(query), Body=answer.encode())
except botocore.exceptions.ClientError as e:
app.logger.warning(f"cache put failed: {e}")
Catching NoSuchKey is correct - a missing key is a normal cache miss. But
ClientError is the base class for almost every S3 error: NoSuchBucket,
AccessDenied, an expired token, a wrong region. Catching it in cache_get turns all
of those into "miss". On the write side the error is only a warning in the log.
Find the real error
Compare the bucket that exists with the bucket the code uses, and read the log:
aws s3 ls
# 2026-10-26 10:00:00 llm-cache
grep -n BUCKET llm_cache.py
# 11:BUCKET = "cache"
tail -n 5 /tmp/app.log
# cache put failed: An error occurred (NoSuchBucket) when calling the
# PutObject operation: The specified bucket does not exist
The code writes to cache, but the bucket is called llm-cache. Point the code at the
bucket that exists:
BUCKET = "llm-cache"
Do not create a new bucket to match the code. The real bucket usually has the right policy, encryption, and lifecycle rules, and a second bucket splits your data.
Confirm the cache works
Send a new question twice. The first request should miss and write an object, and the second should hit:
curl -si -X POST localhost:8000/ask -H 'Content-Type: application/json' \
-d '{"query":"hello"}' | grep -i x-cache # miss
curl -si -X POST localhost:8000/ask -H 'Content-Type: application/json' \
-d '{"query":"hello"}' | grep -i x-cache # hit
aws s3 ls s3://llm-cache/
Only catch the errors that mean "miss"
To make sure a configuration error cannot hide again, check the error code and only treat a missing key as a miss:
from botocore.exceptions import ClientError
def cache_get(query: str) -> str | None:
try:
obj = s3.get_object(Bucket=BUCKET, Key=_key(query))
return obj["Body"].read().decode()
except ClientError as e:
code = e.response["Error"]["Code"]
if code in ("NoSuchKey", "404"):
return None
app.logger.error("cache read failed (%s): %s", code, e)
return None # still serve the request, but log loudly
The request still works when S3 has a problem - a cache should never take the service down - but the logs now show a different message for "not cached yet" and "the cache is broken".
Check the bucket at startup
A cheap way to catch a wrong name early is to call head_bucket when the app starts:
try:
s3.head_bucket(Bucket=BUCKET)
except ClientError as e:
raise SystemExit(f"cache bucket {BUCKET!r} is not usable: {e}")
You can also read the name from an environment variable (BUCKET =
os.environ["CACHE_BUCKET"]) set by the same Terraform or deploy config that creates the
bucket, so the two names cannot drift apart.
How the cache key works
The service keys each object on a hash of the model name and the question:
def _key(query: str) -> str:
return hashlib.sha256(f"{MODEL}::{query}".encode()).hexdigest()
Including the model name means a model change does not return answers from the old model. Add an S3 lifecycle rule to expire old objects, so the cache does not grow forever. Track the hit rate (hits divided by total requests) as a metric - a hit rate of zero is the clearest sign of the problem on this page.
Do this project yourself. It's ready in a live cloud workspace in your browser - when you finish, it lands on your portfolio.
Start this project - freeWhat you'll practice
- Finding a NoSuchBucket error hidden behind a broad ClientError except
- Pointing the cache code at the S3 bucket that really exists
- Catching only NoSuchKey as a cache miss and logging every other S3 error
FAQ
What does the boto3 NoSuchBucket error mean?
The bucket name in the request does not exist in that account and region, or the name has a typo. Run aws s3 ls to list the buckets that exist and compare with the name in your code or config.
Why does my S3 cache always return a miss?
Often the cache read catches botocore ClientError, which also covers NoSuchBucket and AccessDenied. A configuration error then looks the same as a normal miss. Check the logs for failed writes and only treat NoSuchKey as a miss.
Should a cache error crash the request?
No. A cache is an optimization, so the request should still be served when the cache fails. But the failure should be logged as an error, and a missing bucket is better caught at startup with head_bucket.
Is S3 a good cache for LLM responses?
It works well for answers that are large, slow to produce, and requested again over hours or days, and it costs little to store. For very low latency or very high request rates, an in-memory store such as Redis is faster.
Keep learning
Do this project yourself. It's ready in a live cloud workspace in your browser - when you finish, it lands on your portfolio.
Start this project - free