A production-grade weather proxy over Open-Meteo. It exposes a small REST API that resolves a city to coordinates, fetches forecast and air-quality data, normalizes it into typed models, and serves it with a Redis cache in front and MongoDB behind for durable favorites and search history. Every upstream call is wrapped in retry plus a circuit breaker, every request is structured-logged and correlated by request id, and the whole stack comes up with a single command.
A polished dark editorial dashboard (Next.js + TypeScript + Tailwind v4) ships alongside it (see Frontend).
Weather data by Open-Meteo.com, licensed CC BY 4.0. Open-Meteo requires no API key.
- Architecture
- Request flow
- API
- Quick start (Docker)
- Local development (uv)
- Environment variables
- Design decisions
- Assumptions
- Testing and coverage
- What I would improve with more time
- Attribution
The backend is strictly layered. Dependencies point downward only:
routers -> services -> clients (Open-Meteo, resilience)
-> repositories (Redis cache, MongoDB)
-> domain/models (WMO map, units, normalization, schemas)
- Routers do no business logic; they validate input and delegate.
- Services orchestrate the flow (cache, upstreams, normalization, persistence).
- Clients own HTTP to upstreams and the retry plus circuit-breaker policy.
- Repositories own persistence: Redis is an ephemeral cache, MongoDB is the system of record. The two datastores are never mixed.
- Domain holds pure, I/O-free logic (the single-source WMO mapping, unit mapping and conversions, cache-key derivation, payload normalization).
GET /weather?city=London&units=metric
flowchart TD
client["Client"] --> router["weather router"]
router --> svc["WeatherService"]
svc --> geo["geocode (retry + breaker)"]
geo -->|"no result"| nf["404 city_not_found"]
geo -->|"lat/lon"| key["cache key (rounded coords + units)"]
key --> cg["Redis GET"]
cg -->|"hit"| h1["record history"] --> rhit["return (cache=true)"]
cg -->|"miss or Redis down"| up["forecast + air quality (concurrent, retry + breaker)"]
up --> norm["normalize -> Pydantic + WMO category"]
norm --> cs["Redis SET (best effort)"]
cs --> h2["record history"] --> rmiss["return (cache=false)"]
up -->|"breaker open / upstream 5xx"| err["502 / 503 structured error"]
Notes:
- Redis failures never produce a 500. On a cache error the API logs a warning,
bypasses the cache, and serves a live response. The
cacheflag reflects the outcome. - Air quality is best-effort: a forecast failure fails the request, but an air-quality failure degrades to a response without the AQI panel.
- Transient upstream failures (timeouts, connection errors, 5xx) are retried with exponential backoff and jitter; 4xx is never retried. After repeated failures the per-upstream breaker opens and calls fail fast as a 503.
| Method | Path | Description |
|---|---|---|
| GET | /weather?city={city}&units={metric|imperial} |
Resolve a city, return normalized weather + air quality, record history. |
| GET | /weather?lat={lat}&lon={lon}&name={label}&units={...} |
Same, by coordinates (browser geolocation, or a picked search result); skips geocoding. Optional name labels the location and history entry. |
| GET | /geocode?q={query}&count={1-10} |
Candidate locations for search typeahead. Returns a list (empty when none match). |
| GET | /health |
Per-dependency health (Redis, Mongo, upstream) and breaker states. 200 healthy, 503 when a critical dependency is down. |
| GET | /favorites |
List saved cities for the client. |
| POST | /favorites |
Save a city (idempotent per resolved location). |
| DELETE | /favorites/{id} |
Remove a saved city. |
| GET | /history |
Recent searches for the client, deduplicated and capped. |
- Interactive OpenAPI docs are served at
/docs. - Favorites and history are scoped by an
X-Client-Idheader (a UUID). Requests without a valid client id get a clean400. - Errors use a single envelope:
{ "error": { "code": ..., "message": ... } }. - Every response includes an
X-Request-Idheader for log correlation.
Prerequisites: Docker and Docker Compose v2.
docker compose up --buildThis starts MongoDB, Redis, the backend (with health-gated startup ordering), and the Next.js frontend. Once up:
- Dashboard: http://localhost:3000
- API: http://localhost:8000
- Docs: http://localhost:8000/docs
- Health: http://localhost:8000/health
curl "http://localhost:8000/weather?city=London"Prerequisites: uv (the target machine may not have
it; install with curl -LsSf https://astral.sh/uv/install.sh | sh). uv manages
the Python 3.13 toolchain and a locked virtual environment for you.
cd backend
uv sync # creates .venv from uv.lock and installs deps
uv run uvicorn app.main:app --reloadRedis and MongoDB are optional locally: with neither running, the cache simply
reports misses and history/favorites writes fail gracefully. To run them
quickly: docker compose up redis mongo.
Prerequisites: Node.js 22+.
cd frontend
npm install
cp .env.example .env.local # point NEXT_PUBLIC_API_BASE_URL at the backend
npm run dev # http://localhost:3000npm run lint, npm run typecheck, and npm run build mirror the CI checks.
All configuration is environment-driven (see .env.example). Defaults are safe
for local use; docker-compose.yml wires the in-network hostnames.
| Variable | Default | Description |
|---|---|---|
APP_ENV |
dev |
dev (console logs) or prod (JSON logs). |
LOG_LEVEL |
INFO |
Log level. |
CORS_ORIGINS |
http://localhost:3000 |
Comma-separated allowed origins. |
REDIS_URL |
redis://localhost:6379/0 |
Redis connection URL. |
CACHE_TTL_SECONDS |
600 |
Cache TTL for weather responses. |
CACHE_COORD_PRECISION |
2 |
Decimal places coordinates are rounded to for cache keys. |
MONGO_URL |
mongodb://localhost:27017 |
MongoDB connection URL. |
MONGO_DB_NAME |
weather |
Database name. |
HISTORY_MAX_ITEMS |
20 |
Max history entries kept per client. |
GEOCODING_BASE_URL |
Open-Meteo geocoding | Geocoding endpoint. |
FORECAST_BASE_URL |
Open-Meteo forecast | Forecast endpoint. |
AIR_QUALITY_BASE_URL |
Open-Meteo air quality | Air-quality endpoint. |
HTTP_CONNECT_TIMEOUT |
3.0 |
httpx connect timeout (s). |
HTTP_READ_TIMEOUT |
8.0 |
httpx read timeout (s). |
RETRY_MAX_ATTEMPTS |
3 |
Max attempts per upstream call. |
RETRY_INITIAL_BACKOFF |
0.2 |
Initial retry backoff (s). |
RETRY_MAX_BACKOFF |
2.0 |
Max retry backoff (s). |
BREAKER_FAIL_MAX |
5 |
Consecutive failures before a breaker opens. |
BREAKER_RESET_TIMEOUT |
30 |
Seconds before a breaker probes half-open. |
HEALTH_UPSTREAM_TIMEOUT |
2.0 |
Timeout for the cheap upstream health probe. |
Frontend (Next.js, inlined at build time):
| Variable | Default | Description |
|---|---|---|
NEXT_PUBLIC_API_BASE_URL |
http://localhost:8000 |
Backend base URL the browser calls. |
NEXT_PUBLIC_DEFAULT_CITY |
Tel Aviv |
City loaded when geolocation is denied or unavailable. |
- FastAPI + Pydantic v2. Async-native, first-class OpenAPI, and typed request/response validation at the boundary. No untyped dicts cross layers.
- Redis as cache, MongoDB as system of record. Two cleanly separated datastores. Redis is purely an optimization and is allowed to be absent; MongoDB durably owns favorites and history. Durable state is never put in Redis and Mongo is never used as a cache.
- Cache is optional by design. The cache layer swallows Redis errors and reports a miss so a Redis outage degrades latency, not availability.
- Retry and circuit breaker per upstream. tenacity handles transient
retries with jittered backoff (no retry on 4xx); pybreaker isolates each
upstream (geocoding, forecast, air quality) so one failing dependency fails
fast without dragging down the others. pybreaker's synchronous
calling()context manager is used (itscall_asyncrequires tornado). - Geocoding then forecast. The assignment endpoint is city-based but
Open-Meteo is coordinate-based, which deliberately gives three upstreams to
make resilient. The resolved location is always surfaced so the UI can show
exactly what matched (for example London GB vs London CA).
/weatheralso acceptslat/londirectly so browser geolocation works without a keyless reverse-geocoder (Open-Meteo offers none); that path skips geocoding and labels the location "Current location" unless an optionalnameis supplied (the dashboard passes it when a search result, favorite, or history item is picked, so the exact place is preserved without re-geocoding an ambiguous name such as Paris FR vs Paris TX). - Single-source WMO mapping. The weather-code to condition-category map
lives in exactly one place (
app/domain/wmo.py) and is surfaced in the response so the frontend selects its background from the same category rather than re-deriving it. - Auth-free
X-Client-Idscoping. There is no authentication. Favorites and history are scoped by a client-generated UUID sent asX-Client-Id. This is a deliberate stand-in; real auth (sessions/JWT) is the documented production next step. - Structured logging with correlation. structlog emits JSON in production
and readable logs in dev. A middleware assigns/accepts a request id, binds it
to every log line, and returns it as
X-Request-Id. Per request we log route, method, status, total duration, upstream duration and status, and cache hit/miss; breaker transitions and retries are logged too. - Config is fully environment-driven. No hardcoded hosts, TTLs, or limits.
unitsdefaults tometricwhen omitted.- Search history is de-duplicated by resolved coordinates (a repeat lookup moves
to the top) and capped at
HISTORY_MAX_ITEMSper client. - Favorites are unique per
(client_id, latitude, longitude); saving the same resolved location twice is idempotent. - The frontend supplies the resolved location when saving a favorite (it already has it from a prior weather lookup), avoiding a second geocode.
- Mongo and the upstream are treated as critical for
/health; Redis is non-critical and only marks the servicedegraded. - Browser geolocation queries
/weatherby coordinates. Because no keyless reverse-geocoder is available, a pure geolocation result is labeled "Current location". When a place is chosen from search/favorites/history the known name is passed through, so it is labeled and stored correctly.
Tests never contact the live Open-Meteo API; it is mocked with respx. Redis is faked with fakeredis and MongoDB with mongomock-motor, so the suite needs no running services.
cd backend
uv run pytest # runs unit + integration with the 85% gate
uv run pytest --cov-report=html # writes htmlcov/index.htmlCoverage is currently ~95% and the suite fails under 85% (enforced in CI). Tests cover the WMO mapping, unit mapping/conversion, cache-key derivation, normalization, retry-then-open-circuit behavior, and the endpoints (happy path, city-not-found, upstream 5xx-then-recovery, breaker-open fast-fail, cache hit/miss, Redis-down graceful degradation, and favorites/history CRUD scoped by client id).
Quality gates run locally and in CI:
uv run ruff check .
uv run ruff format --check .
uv run mypy .- Real authentication (sessions or JWT) replacing the
X-Client-Idstand-in. - Rate limiting and per-client quotas.
- Distributed tracing (OpenTelemetry) and a Prometheus
/metricsendpoint. - Request coalescing / single-flight to avoid cache stampedes on popular cities.
- ETags / conditional requests for client-side caching.
- Frontend tests (React Testing Library for components, Playwright for an end-to-end smoke flow) and a frontend coverage gate in CI.
- Kubernetes manifests and a Helm chart alongside compose.
- Internationalization of labels and condition descriptions.
A dark editorial dashboard built with Next.js (App Router) + TypeScript +
Tailwind CSS v4. It renders current conditions over a full-bleed,
weather-driven background, a 7-day strip, a 24-hour strip, minimal amber/gold
trend charts (temperature and precipitation), an air-quality panel, a unit
toggle (°C/°F), a light/dark theme toggle (dark default), a debounced
type-ahead city search (backed by /geocode, with keyboard navigation),
recent searches, and favorites. An honest service-status dot polls /health.
On first load it requests browser geolocation and shows weather for the current
location, falling back to NEXT_PUBLIC_DEFAULT_CITY (Tel Aviv) when permission
is denied or unavailable.
Stack and conventions:
- Next.js standalone output for a slim, non-root production container
(
node server.js), served on port 3000. - Tailwind v4 with CSS-based config. All theme tokens, the color palette
(CSS variables, dark default), and the project's custom max-width breakpoints
live in an
@themeblock inapp/globals.css. There is notailwind.config.js. Layout is flexbox only. - Typed API client (
lib/api.ts) readsNEXT_PUBLIC_API_BASE_URL; the backend host is never hardcoded in components. - Auth-free client id. A UUID is generated once and stored in localStorage,
then sent as
X-Client-Idon favorites and history requests. - Single-source WMO mapping. The backend owns the weather-code to category
map and returns the category; the frontend only maps that category to a
committed background image (with a per-category gradient fallback) in
lib/wmo.ts, so the mapping is never duplicated.
Layout:
frontend/
app/ layout, global stylesheet (@theme), page
components/ dashboard, sidebar, hero, forecasts, charts, air quality, toggles
hooks/ useWeatherApp (state), useHealth, useTheme
lib/ api client, types, client id, wmo visuals, formatting
public/backgrounds/ one dark image per condition category (gradient fallback)
Weather background images are static and committed (one per WMO condition
category) and served from public/backgrounds/. Until images are added the UI
falls back to a per-category gradient, so it works out of the box. See
frontend/public/backgrounds/README.md for the expected filenames.
- Weather and air-quality data by Open-Meteo.com, licensed CC BY 4.0.
- Weather background images are credited here per image once added to
frontend/public/backgrounds/.