This document explains how response caching works in the BTAA Geospatial API backend and how to verify it end-to-end.
The caching system is designed to be:
- Fast: serve hot endpoints from Redis.
- Correct: cache keys include request representation inputs; invalidation is tag-based.
- Resilient: protects against stampedes and supports stale serving on upstream errors.
Caching is implemented as a decorator on selected endpoints:
/api/v1/search(GET/POST),/api/v1/search/facets/{facet_name},/api/v1/suggest/api/v1/resources/{id},/api/v1/resources/,/api/v1/resources/{id}/distributions- selected gazetteer endpoints
The decorator lives in:
backend/app/services/cache_service.py(cached_endpoint)
Cached endpoint responses use Redis as the hot L1 cache and Postgres
generated_api_responses / generated_api_response_tags as a durable L2 cache.
On a Redis miss, the app can rehydrate Redis from Postgres before recomputing an
expensive endpoint response.
/api/v1/search also has a semantic response-core cache below the endpoint
response cache. It stores the expensive request-independent core (meta, search
data, and optional included) keyed by actual search inputs such as query,
page, sort, fields, facets, legacy fq, include/exclude filters, and advanced
query clauses. It intentionally excludes request URL noise such as cache-buster
params, callback, and pagination-link host details, then rebuilds request-specific
links for every caller. These entries are tagged as search, so the existing
search-cache clear workflow invalidates them.
Caching can be turned on/off globally via env.
Set these in .env or local container env:
ENDPOINT_CACHE=true
REDIS_HOST=redis
REDIS_PORT=6379
REDIS_PASSWORD=optional_password
REDIS_DB=0
If you run the API via Docker Compose, ensure the api service passes ENDPOINT_CACHE through from .env (rather than hard-coding it), so local toggles like ENDPOINT_CACHE=false take effect after a container recreate.
# Adds X-Cache headers (MISS/HIT/STALE/WAIT_HIT) so you can verify behavior quickly.
CACHE_DEBUG_HEADERS=true
# Logs structured-ish cache events to the app logger.
CACHE_LOG_EVENTS=true
# Optional: version the cache namespace (safe rollouts without manual purges).
CACHE_VERSION=v2
CACHE_APP_VERSION=2026-01-07
# Durable L2 response cache.
API_RESPONSE_DURABLE_CACHE_STORE=database
# Semantic search-result core cache below the endpoint cache.
SEARCH_RESULT_CACHE=true
SEARCH_RESULT_CACHE_TTL=3600
SEARCH_RESULT_CACHE_VERSION=v2
SEARCH_AGGREGATION_CACHE_VERSION=v2
# Live search diagnostics; not stored in endpoint response-cache records.
SEARCH_TIMING_HEADERS=true
Cache keys are built from:
- HTTP method
- URL path
- Normalized query string (sorted, preserves duplicates and bracket notation; uses
request.scope["query_string"]) - Selected request headers that affect representation:
AcceptAccept-Encoding
- Endpoint parameters (function args excluding the
requestobject)
This prevents collisions like:
?a=1&b=2vs?b=2&a=1- gzip vs identity encoding
- different
Acceptvalues
For public cached GET endpoints, the serving path is:
- Try Redis L1.
- If Redis misses, try durable Postgres L2.
- If Postgres L2 hits, rehydrate Redis and return that response.
- If both miss, compute the endpoint response, store Redis L1, store Postgres L2, and tag both.
The durable L2 rows use the same hard expiry as the Redis response records. They are a rebuildable cache warehouse, not an archive.
Create or repair the durable response cache tables with:
make api-response-cache-initPrune expired durable response rows with:
make api-response-cache-pruneDeployed pruning schedules and remote cache procedures are restricted operations material.
We cache the full response body bytes plus minimal metadata:
status(only 200 responses are cached)headers(filtered subset; excludes hop-by-hop and runtime headers)etag(weak ETag derived from body bytes)soft_expandhard_exptimestamps
This avoids fragile “serialize a Response object” behavior.
Each cached record has:
- Soft TTL (the endpoint’s configured TTL)
- before soft TTL: HIT
- after soft TTL: STALE (still served, but triggers refresh in background)
- Hard TTL (
CACHE_HARD_TTL_SECONDS, default =2 * ttl)- after hard TTL: treated as a miss
On a miss, the system attempts to acquire a per-key Redis lock:
- If lock is acquired, it recomputes once and stores the result.
- If lock is not acquired, it briefly waits (
CACHE_LOCK_WAIT_SECONDS) for another worker to fill the cache, then serves that cached value if available.
If refresh/recompute fails, the system can extend the hard expiry by STALE_IF_ERROR_SECONDS (bounded by MAX_STALE_EXTENSION_SECONDS) so clients still get responses during upstream outages.
Cached endpoints include:
ETag: W/"<sha256>"Cache-Control: public, max-age=0, s-maxage=<ttl>, stale-while-revalidate=<...>, stale-if-error=<...>Vary: Accept-Encoding, Accept
If the client sends If-None-Match: <etag>, the API will return 304 Not Modified (when served from cache).
This repo has two image asset families that are backed by Redis object caches and durable database storage:
- Thumbnails:
GET /api/v1/thumbnails/{resource_id} - Static maps:
GET /api/v1/static-maps/{resource_id}
Generated thumbnail/static-map bytes are persisted in generated_visual_assets;
resource-to-asset mappings are persisted in generated_visual_asset_links.
On a Redis miss, the API can recover the resource alias, serve the durable
asset, and rehydrate Redis. Redis is the hot serving layer; the database is the
durable fallback across cache churn, restarts, and deploys.
VISUAL_ASSET_CACHE_TTL_SECONDS=0 stores these hot Redis keys without expiry.
Deployed TTL, memory, and eviction settings are restricted operations material.
-
Revalidation-style caching on the image endpoints:
ETag+If-None-Match→304 Not ModifiedCache-Control: public, max-age=0, s-maxage=...with SWR / stale-if-error- This allows CDNs to cache aggressively, while ensuring clients don’t get pinned to stale/broken images when the Redis object TTL expires.
-
Priming for hot paths:
make prime-thumbnail-cache,make prime-static-map-cache, andmake prime-visual-cachesgenerate assets ahead of user traffic.- Run
make resource-aux-initbefore first local priming sogenerated_visual_assetsandgenerated_visual_asset_linksexist. - After a Redis reset, priming first tries to rehydrate from durable visual storage before regenerating remote thumbnails or static maps.
- Full static-map priming defaults to durable assets/links plus small Redis aliases only. Use
PRIME_STATIC_MAP_HYDRATE_ASSETS=1only for small hotsets when Redis DB 1 should hold the PNG bodies. Full-corpus static-map body hydration is refused unlessPRIME_ALLOW_FULL_HYDRATION=1is also set. - Static-map and resource-class-icon priming also writes a latest-asset alias, letting hot gallery redirects skip the database and jump straight to the immutable
/static-map-assets/{hash}URL. - Resource/search JSON only needs those immutable asset URLs, so resource representation generation rehydrates aliases from durable storage without pulling full static-map PNG bodies back into Redis.
- Priming scripts default
VISUAL_ASSET_REDIS_LOADING_MAX_WAIT_SECONDS=900, so if Redis restarts and reportsRedis is loading the dataset in memory, the job waits and retries instead of counting thousands of transient cache operations as failed resources. - For large local catch-up runs on disk-constrained laptops, temporarily start Redis in in-memory mode with
REDIS_APPENDONLY=noandREDIS_SAVE="". The durable bytes and resource-to-asset links still land in Postgres, while Redis avoids building giant AOF/RDB files during the warm-up. - Deployed visual asset import/export procedures are restricted operations material.
- Priming logs individual broken upstream assets without exiting nonzero, so a handful of bad provider images do not block the rest of the warming run. Use
python scripts/prime_thumbnail_cache.py --strict-failuresorpython scripts/prime_static_map_cache.py --strict-failureswhen a diagnostic run should fail on any asset error.
-
Non-cacheable placeholders for “not ready” states:
GET /api/v1/resources/{id}/static-mapalways returns an image (SVG placeholder while a background job generates the PNG), withCache-Control: no-store.- Placeholders must be
no-storeso intermediate caches don’t pin “processing” responses.
Each cached response is indexed under one or more tags in Redis and durable Postgres L2, for example:
search(all search-related cached responses)suggestresource(all resource endpoints)resource:<id>(specific resource)gazetteerfacet:<facet_name>ns:<python_namespace_of_handler>(fine-grained namespace tag)
There is an admin endpoint to purge caches:
POST /api/v1/admin/cache/purge
Examples:
# Purge all search caches
curl -u "$ADMIN_USERNAME:$ADMIN_PASSWORD" \
-X POST "http://localhost:8000/api/v1/admin/cache/purge" \
-H "Content-Type: application/json" \
-d '{"tags":["search"]}'
# Purge a specific resource
curl -u "$ADMIN_USERNAME:$ADMIN_PASSWORD" \
-X POST "http://localhost:8000/api/v1/admin/cache/purge" \
-H "Content-Type: application/json" \
-d '{"tags":["resource:123-abc"]}'
# Emergency nuke (flush the Redis DB used by the app)
curl -u "$ADMIN_USERNAME:$ADMIN_PASSWORD" \
-X POST "http://localhost:8000/api/v1/admin/cache/purge" \
-H "Content-Type: application/json" \
-d '{"flush_all":true}'When documents/resources are updated (or background tasks modify a resource), the code invalidates:
resource:<id>search(coarse, because resource updates affect search results)
See:
backend/app/events/document_events.pybackend/app/services/admin_service.py
Ensure:
ENDPOINT_CACHE=true
CACHE_DEBUG_HEADERS=true
Then hit a cacheable endpoint twice:
curl -sD - -o /dev/null "http://localhost:8000/api/v1/search?q=test" | egrep -i 'x-cache|etag|cache-control|vary'
curl -sD - -o /dev/null "http://localhost:8000/api/v1/search?q=test" | egrep -i 'x-cache|etag|cache-control|vary'Expected:
- First request:
X-Cache: MISS - Second request:
X-Cache: HIT(orSTALEif you’re past the soft TTL)
curl -sD - -o /dev/null "http://localhost:8000/api/v1/search?a=1&b=2" | egrep -i 'x-cache'
curl -sD - -o /dev/null "http://localhost:8000/api/v1/search?b=2&a=1" | egrep -i 'x-cache'The second request should be a HIT (same canonical key).
ETAG=$(curl -sD - -o /dev/null "http://localhost:8000/api/v1/search?q=test" | awk -F': ' 'tolower($1)=="etag"{print $2}' | tr -d '\r')
curl -sD - -o /dev/null -H "If-None-Match: $ETAG" "http://localhost:8000/api/v1/search?q=test" | headExpected: HTTP/1.1 304 Not Modified
Because the cache varies on Accept-Encoding, gzip and identity responses are keyed separately.
curl -sD - -o /dev/null -H "Accept-Encoding: gzip" "http://localhost:8000/api/v1/search?q=test" | egrep -i 'content-encoding|x-cache|etag|vary'
curl -sD - -o /dev/null -H "Accept-Encoding: gzip" "http://localhost:8000/api/v1/search?q=test" | egrep -i 'content-encoding|x-cache|etag|vary'Expected:
Content-Encoding: gzip(if gzip middleware is enabled)X-Cache: MISSthenHIT
- Turn on caching only for public endpoints (already the case via decorators).
- Start with low TTLs on
/search(e.g., 30–120s) and higher TTLs for stable resources.
If you change the cache record format or want a clean slate:
- bump
CACHE_APP_VERSION(recommended), or - bump
CACHE_VERSION(bigger hammer)
This avoids needing a live flush_all.
If search query semantics change, such as publication or suppression filters,
bump SEARCH_RESULT_CACHE_VERSION and SEARCH_AGGREGATION_CACHE_VERSION so
cached search totals and facet aggregations are not reused across the deploy.
For map/H3 semantics, also bump the frontend MAP_H3_REQUEST_VERSION and clear
the map cache tag if any older responses have already been cached.
- No cache hits:
- confirm
ENDPOINT_CACHE=true - verify Redis connectivity (
REDIS_HOST/PORT/PASSWORD/DB) - set
CACHE_DEBUG_HEADERS=trueand watchX-Cache
- confirm
- Unexpected misses:
- check
Varyinputs:Accept,Accept-Encoding - confirm query strings are identical after normalization
- check
- Redis latency:
- tune
REDIS_TIMEOUT_SECONDS - keep Redis close to the API network-wise
- tune