Skip to content
← Back to projects

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

Screenshot 2026-09-07 102744

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=B and ?to=B&from=A are 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 PX lock 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-minute header 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 npm if you want the git hooks: run npm install once to activate Husky.
  • pip-audit runs via uv in the pre-commit hook; no separate install needed.

#Setup

  1. Copy the environment file and add your token:

    cp .env.example .env
    # edit .env, set FOOTBALL_DATA_API_TOKEN
    
  2. Install dependencies:

    uv sync
    
  3. Start Redis:

    make up
    

    Redis is exposed on host port 6380, because 6379 was already in use on the development machine. REDIS_URL in .env.example already matches.

  4. 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.

  5. Open the interactive docs at http://localhost:8001/docs, or step through request.http from 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.

New version available.