Falling Back to Cached Tiles When a Breaker Opens
When a breaker opens, the pipeline has three honest options: fail the tile, serve the last good tile, or emit a placeholder that says “not available”. Serving the cached tile is usually right for a basemap someone is looking at now, and usually wrong for an analytical product where old pixels silently corrupt a result. Whichever you choose, the non-negotiable part is that the output carries its own provenance — an age, a source and a flag — so that nothing downstream mistakes a two-day-old tile for a fresh one.
When to Use This Pattern
- The output is consumed visually — a slippy map, a printed atlas, a preview — where a slightly stale tile is far better than a grey square.
- A tile cache already exists with reliable timestamps, whether that is S3 object metadata, a
mbtilesfile or a filesystem tree. - Staleness has a defensible budget. Imagery from last week is fine for a road basemap and unacceptable for a flood-extent layer; the budget is a product decision, not an engineering one.
- The consumer can see the provenance. If nothing downstream can distinguish fresh from stale, do not do this — take the failure instead.
The pattern is wrong for analytical pipelines that compute over the pixels. A zonal statistic that silently mixes yesterday’s and today’s imagery produces a number nobody can reproduce, and the failure is invisible because the output looks completely normal.
Complete Working Example
The fallback below reads from an S3-backed tile cache, enforces a staleness budget, and returns the tile together with its provenance so the writer can record it.
from __future__ import annotations
from dataclasses import dataclass
from datetime import datetime, timedelta, timezone
from typing import Optional
import boto3
from botocore.exceptions import ClientError
@dataclass(frozen=True)
class TileResult:
"""A tile plus the truth about where it came from."""
data: bytes
source: str # "live" | "cache" | "placeholder"
generated_at: datetime
age: timedelta
@property
def is_stale(self) -> bool:
return self.source != "live"
# One budget per layer, because staleness tolerance is a property of the data,
# not of the pipeline. Flood extents go stale in minutes; road networks do not.
STALENESS_BUDGET: dict[str, timedelta] = {
"ortho": timedelta(days=30),
"roads": timedelta(days=7),
"flood_extent": timedelta(hours=2),
"traffic": timedelta(minutes=10),
}
PLACEHOLDER_PNG = b"\x89PNG\r\n\x1a\n..." # a 256x256 "unavailable" tile
def cached_tile(
s3, bucket: str, layer: str, z: int, x: int, y: int
) -> Optional[TileResult]:
"""The last good tile for this coordinate, if it is inside the budget."""
key = f"{layer}/{z}/{x}/{y}.png"
try:
obj = s3.get_object(Bucket=bucket, Key=key)
except ClientError as exc:
if exc.response["Error"]["Code"] in ("NoSuchKey", "404"):
return None
raise
generated_at = obj["LastModified"]
if generated_at.tzinfo is None:
generated_at = generated_at.replace(tzinfo=timezone.utc)
age = datetime.now(timezone.utc) - generated_at
budget = STALENESS_BUDGET.get(layer, timedelta(hours=24))
if age > budget:
# Outside the budget the cached tile is not a fallback, it is misinformation.
return None
return TileResult(data=obj["Body"].read(), source="cache",
generated_at=generated_at, age=age)
def fetch_tile(breaker, client, s3, bucket, layer: str, z: int, x: int, y: int) -> TileResult:
"""Live tile when the breaker allows it, else the best honest alternative."""
try:
data = breaker.call(lambda: render_tile(client, layer, z, x, y))
return TileResult(data=data, source="live",
generated_at=datetime.now(timezone.utc), age=timedelta(0))
except BreakerOpen:
pass # expected: fall through to the cache
fallback = cached_tile(s3, bucket, layer, z, x, y)
if fallback is not None:
return fallback
return TileResult(data=PLACEHOLDER_PNG, source="placeholder",
generated_at=datetime.now(timezone.utc), age=timedelta(0))
Whatever writes the tile onward must carry the provenance with it. For an HTTP tile service that means response headers; for a pipeline writing to object storage it means object metadata, which is what lets a later audit answer “which tiles in this mosaic were stale?” without re-deriving anything:
def put_tile(s3, bucket: str, key: str, result: TileResult) -> None:
s3.put_object(
Bucket=bucket, Key=key, Body=result.data, ContentType="image/png",
Metadata={
"tile-source": result.source, # live | cache | placeholder
"tile-generated-at": result.generated_at.isoformat(),
"tile-age-seconds": str(int(result.age.total_seconds())),
},
# A stale tile must not be cached downstream as though it were fresh.
CacheControl="max-age=60" if result.is_stale else "max-age=86400",
)
The staleness budget deserves more thought than it usually gets, because it is the only part of this design that encodes a judgement about the world rather than about software. A useful way to arrive at a number is to ask what decision the tile supports and how quickly that decision would change: a road basemap supports navigation over a network that changes on the scale of months, so a week is generous and safe; a flood extent supports evacuation decisions over a phenomenon that changes hourly, so two hours is already at the edge. Written that way, the budget is defensible to whoever asks, and it survives the reorganisation that loses the original author.
Parameter & Option Reference
| Parameter | Type | Default | Spatial notes |
|---|---|---|---|
STALENESS_BUDGET |
per layer | varies | A property of the phenomenon. Traffic goes stale in minutes; orthophotos in months. One global budget is always wrong for something. |
| default budget | 24 h |
— | For layers not listed. Deliberately short, so an unconfigured layer fails safe rather than serving last year’s tile. |
tile-source metadata |
str |
required | The single most important field. Without it “which tiles are stale?” is unanswerable. |
CacheControl on stale |
max-age=60 |
— | Prevents a CDN pinning a stale tile for a day after the outage ends. |
| placeholder tile | 256×256 PNG | — | Visibly different from both a real tile and a transparent one, so a gap reads as a gap. |
| cache lookup timeout | 2 s | — | The fallback path must be fast. A slow cache turns an open breaker into a slow pipeline instead of a fast one. |
Verification & Testing
Test the budget boundary and the provenance, not the happy path.
def test_stale_beyond_budget_is_not_served(s3_stub, frozen_clock) -> None:
s3_stub.put("flood_extent/12/2100/1300.png", b"old", age=timedelta(hours=3))
# The flood budget is 2 h, so a 3 h tile must be refused outright.
assert cached_tile(s3_stub, "tiles", "flood_extent", 12, 2100, 1300) is None
def test_fallback_is_labelled(s3_stub, open_breaker, frozen_clock) -> None:
s3_stub.put("roads/12/2100/1300.png", b"cached", age=timedelta(hours=6))
result = fetch_tile(open_breaker, None, s3_stub, "tiles", "roads", 12, 2100, 1300)
assert result.source == "cache"
assert result.is_stale
assert timedelta(hours=5) < result.age < timedelta(hours=7)
def test_placeholder_when_nothing_cached(s3_stub, open_breaker) -> None:
result = fetch_tile(open_breaker, None, s3_stub, "tiles", "roads", 12, 9, 9)
assert result.source == "placeholder"
After an incident, the audit query is what makes the fallback defensible. With provenance in object metadata, listing the affected tiles is a scan rather than an investigation:
# Which tiles in this layer were served from cache during the outage window?
aws s3api list-objects-v2 --bucket tiles --prefix roads/12/ \
--query 'Contents[].Key' --output text |
xargs -n1 -P8 -I{} sh -c \
'aws s3api head-object --bucket tiles --key {} \
--query "Metadata.\"tile-source\"" --output text | grep -q cache && echo {}'
Common Pitfalls
- No staleness budget. “Serve whatever is in the cache” eventually serves a tile from two years ago, and nobody notices because a tile is a tile. The budget is what makes the fallback bounded.
- Losing the provenance at the next hop. The pipeline records
tile-source: cacheand the publishing step copies the bytes without the metadata. Everything downstream then believes the tile is fresh. Provenance has to survive every copy, which usually means asserting it in the publishing step. - Caching the stale tile downstream with a long TTL. A CDN that picks up a fallback tile with
max-age=86400will keep serving it for a day after the endpoint recovered. ShortenCacheControlfor stale responses. - Falling back on the analytical path. A statistic computed over silently mixed dates is unreproducible and looks perfectly normal. Analytical tasks should fail and land in the dead-letter queue instead.
- A slow cache lookup. If reading the fallback takes as long as the live request would have, the breaker has saved the upstream but not your throughput. Keep the fallback path fast and bounded, and treat a cache timeout as a placeholder.
Frequently Asked Questions
Where should the staleness budget live?
Next to the layer definition, not in the fallback code. It is a statement about the data — how fast the phenomenon changes — and the person who knows the answer is whoever owns the layer. Putting it in a config file the data owner can read and edit is worth more than any amount of cleverness in the code path.
Should the placeholder be transparent?
Usually not. A transparent tile is indistinguishable from a genuinely empty area, so a gap in coverage reads as ocean. A subtle diagonal hatch or a light grey square with “unavailable” is legible at a glance and photographs badly enough that nobody publishes it by accident.
Does the fallback count as a success for the breaker?
No, and this matters. The live call failed, and the breaker must record that failure regardless of what the fallback produced. Counting a fallback as a success is how a breaker stays closed through an outage — the pipeline “succeeds” on every tile while serving nothing but cache.
How does this interact with tile-cache invalidation?
Carefully. A tile that was served from cache during an outage must be re-rendered once the endpoint recovers, or the stale pixels persist indefinitely. Emit the affected coordinates into the same invalidation path described in invalidating tile caches after a source update, and treat the outage as an update event.
Related
- Circuit breakers for external WMS services — the breaker whose open state triggers this path
- Half-open recovery for tile servers — how the fallback period ends
- Caching strategies for spatial tasks — the cache this reads from
- Invalidating tile caches after a source update — re-rendering what was served stale