Fastapi Fixtures Playground
A FastAPI service that fronts the rate-limited football-data.org API with a Redis cache, turning a 10-requests-per-minute upstream budget into unlimited reads. The interesting problem is the cache: TTL by data volatility, stampede control, stale-while-revalidate, and explicit failure behaviour, all observable from curl via an X-Cache header.
#fastapi-fixtures-playground
A FastAPI service that fronts the rate-limited football-data.org API with a Redis cache, turning a 10-requests-per-minute upstream budget into unlimited reads.
#What this demonstrates
The interesting problem here is not "how do I write an endpoint". It is: the upstream free tier allows 10 requests per minute, and this service has to serve fixtures, results and standings with no rate limit of its own. Every design decision falls out of that constraint.
The cache is where the work is:
- TTL by data volatility, not one global number. A finished result never
changes, so it is cached for 30 days. A live score is stale in 30 seconds. The
TTL is a pure function of the parsed match (
app/services/ttl.py), and it is the most heavily tested unit in the repo. - Normalised cache keys.
?from=A&to=Band?to=B&from=Aare the same request. Query parameters are sorted and canonicalised before hashing, and the key prefix is versioned (fixtures:v1:) so a model change does not mean flushing Redis by hand. - Stampede control. Ten cold requests for the same key at once must not
become ten upstream calls. One caller wins a
SET NX PXlock and fetches; the others wait briefly and read the filled cache. With a 10/min budget, a stampede is an outage, not a slow request. - Upstream budget tracking. A per-minute counter in Redis, reconciled
against the real
x-requests-available-minuteheader on every response. The service refuses to exceed the budget itself rather than waiting for a 429. - Stale-while-revalidate. Each entry carries a soft expiry and a hard
expiry. Past the soft expiry the stale value is returned immediately and a
refresh runs on
BackgroundTasks. Past the hard expiry the caller blocks and fetches. - An explicit failure policy (see Architecture notes).
Every response carries an X-Cache: HIT | STALE | MISS header, and
GET /_cache/stats reports the hit ratio and current-minute budget, so the
cache behaviour is visible from curl without a metrics stack.
Coming from a framework like Laravel, the FastAPI-specific lessons it forces: async request handling and the ways a sync call blocks the event loop; Pydantic as a load-bearing validation and serialisation layer; dependency injection driven by function signatures rather than a service container; and the fact that uvicorn workers do not share memory, which is why the cache is Redis and not a module-level dict.
#Tech stack
| Piece | Choice |
|---|---|
| Language | Python 3.13 |
| Web framework | FastAPI 0.141, Pydantic v2 |
| Upstream client | httpx (async), lifespan-managed singleton |
| Cache | Redis 8 via redis-py async client |
| Config | pydantic-settings |
| Tests | pytest, pytest-asyncio, respx (mocks httpx at the transport layer), fakeredis |
| Dependency management | uv |
| Lint / format | ruff |
| Container runtime | Docker Compose (Redis only) |
There is no database. Redis is the only state. There is no auth and no frontend.
#Getting started
#Prerequisites
uv(curl -LsSf https://astral.sh/uv/install.sh | sh). It manages the Python 3.13 toolchain itself.- Docker with Compose.
- A free football-data.org API token: register at https://www.football-data.org/client/register.
- Node and
npmif you want the git hooks: runnpm installonce to activate Husky. pip-auditruns viauvin the pre-commit hook; no separate install needed.
#Setup
-
Copy the environment file and add your token:
cp .env.example .env # edit .env, set FOOTBALL_DATA_API_TOKEN
-
Install dependencies:
uv sync
-
Start Redis:
make up
Redis is exposed on host port 6380, because 6379 was already in use on the development machine.
REDIS_URLin.env.examplealready matches. -
Run the API with autoreload:
make dev
The API listens on http://localhost:8001. Port 8000 was in use on the development machine, so 8001 is the default; override with
make dev API_PORT=8002. -
Open the interactive docs at http://localhost:8001/docs, or step through
request.httpfrom the project root.
#Makefile commands
| Command | Does |
|---|---|
make up |
Start Redis (docker compose up -d) |
make down |
Stop Redis |
make build |
Pull the compose images |
make shell |
Open a redis-cli shell in the container |
make dev |
Run the API with autoreload on API_PORT (default 8001) |
make test |
Run the test suite (uv run pytest) |
make lint |
ruff check and ruff format --check |
make logs |
Tail the Redis container logs |
make fresh |
Flush Redis and restart the container |
#Environment variables
| Key | Description | Default |
|---|---|---|
FOOTBALL_DATA_API_TOKEN |
football-data.org v4 API token. Required. | none |
REDIS_URL |
Redis connection URL. Matches docker-compose.yml. |
redis://localhost:6380/0 |
CACHE_DELETE_ENABLED |
Guards DELETE /_cache/{prefix}. Keep false outside local development. |
false |
#Endpoints
request.http at the project root exercises every one of these and works with
the VS Code REST Client and the JetBrains HTTP Client.
| Method | Path | Notes |
|---|---|---|
GET |
/competitions |
Codes fetched from the upstream on first use, cached 24h |
GET |
/competitions/{code}/fixtures?from=&to= |
code case-insensitive, e.g. PL; dates are ISO, order-independent |
GET |
/competitions/{code}/standings |
The TOTAL league table, cached 1h |
GET |
/teams/{id}/fixtures?limit= |
A team's matches across competitions; limit 1-100 |
GET |
/matches/{id} |
A single match |
GET |
/matches/{id}/live |
Server-sent events: score events then a final end. Many subscribers share one 30s-cached upstream call |
GET |
/_cache/stats |
Teaching endpoint: hit ratio, upstream calls this minute |
DELETE |
/_cache/{prefix} |
Dev only, 404 unless CACHE_DELETE_ENABLED=true |
GET |
/health |
Liveness |
Every cached endpoint sets X-Cache: HIT | STALE | MISS and can return 503
with Retry-After (see below).
#Architecture notes
#Three layers, kept strictly separate
app/routes/ FastAPI routers. HTTP concerns only: paths, params, status
codes, the X-Cache header. No httpx, no Redis, no upstream
types.
app/services/ Cache-aside logic, TTL policy, stampede control, SWR, the
failure policy. Maps upstream models to API models.
app/providers/ football-data.org client. Owns the upstream contract: base
URL, auth header, budget check, non-200 translation.
app/models/
upstream.py Pydantic models matching what football-data.org returns.
api.py Pydantic models describing what THIS API returns.
models/upstream.py and models/api.py never merge. An upstream shape change
breaks one mapping function in app/services, not the public contract.
#One place for the cache mechanics
Cache-aside, the stampede lock, stale-while-revalidate and the failure policy
all live in app/services/cached_read.py (CachedRead). Each endpoint's
service supplies three callbacks: fetch (call the upstream), to_payload
(value to a JSON-able dict) and ttl_of (value to a TTL). Adding an endpoint is
a thin service plus a router, not new cache logic.
#TTL policy
| Data | TTL | Why |
|---|---|---|
| Finished / cancelled / awarded match | 30 days | The result does not change |
| In-play or paused match | 30 seconds | The point of a live score |
| Kickoff within the next 24h | 15 minutes | Late changes matter |
| Kickoff more than 24h away | 6 hours | Kickoff times and postponements move slowly |
| Standings | 1 hour | Only moves when a match in that competition finishes |
| Competition list | 24 hours | Changes rarely |
A fixture list takes the shortest TTL of any match in it: a list is only as
fresh as its most volatile member. The soft expiry is half the TTL
(SOFT_TTL_RATIO = 0.5): served fresh for the first half, stale-while-revalidate
for the second half, then hard-expired.
#Failure policy
Decided explicitly, and enforced in CachedRead:
| Situation | Response |
|---|---|
| Upstream 429/5xx or budget spent, and a cached entry survives | 200, X-Cache: STALE, the surviving data |
| Upstream 429/5xx or budget spent, and nothing is cached | 503 with Retry-After: 60 |
| Any success | Real data |
Never an empty 200. A transient upstream failure is common with a 10/min
budget, and a stale football fixture is almost always more useful than an error,
but a genuine no-data situation must be a visible 503.
#Lifespan-managed clients
The httpx.AsyncClient and the Redis client are created once in the FastAPI
lifespan and injected via Depends, not built per request. A client per request
throws away connection reuse, and with a 10/min upstream that waste is not free.
#Ports
| Service | Host port | Why not the default |
|---|---|---|
| API | 8001 | 8000 was in use on the development machine |
| Redis | 6380 | 6379 was in use on the development machine |
Both are overridable: make dev API_PORT=... for the API, the ports: mapping
in docker-compose.yml plus REDIS_URL for Redis.