Prerequisites: the APIs stage — especially Pagination, Rate Limits & Retries — plus Packaging, Envs, Logging & CLIs. This is the first of three SkyWatch posts: a real-time flight dashboard built on two free public APIs.

A customer wants a live dashboard over data you don't own, from an API with a quota you can't negotiate. This post builds the ingestion pipeline: bounding-box polling, a quota budget, dedup by icao24, and the nulls that are perfectly normal. The one principle for this post: you don't control the source — design for its constraints.

Priya (Tuesday, 10:15 AM): "I run a regional aviation newsletter. Every week readers email me: 'what was that plane over my house at 6 PM?' I want a page on the site — 'what's overhead right now' — live flights near our city, with the basics: callsign, altitude, speed. Nothing fancy."

Dev: "Live data from where?"

Priya: "There's this free thing — OpenSky Network. Community receivers, live positions. And I need it cheap. Like, free-tier cheap."

Dev: "Then our first design document isn't the dashboard. It's the quota. Free APIs have opinions about how often you may ask."

The customer problem

Priya's readers want to know what's overhead right now. "Right now" is doing heavy lifting in that sentence: it rules out yesterday's schedule data, it rules out a static map, and it means whatever we build is only as live as its slowest dependency. The data exists — OpenSky Network publishes live aircraft state vectors from a community receiver network, free, no API key for anonymous access. The catch is the one every free API has: you are a guest on someone else's infrastructure.

Clarify the ask

Before any code, the questions that shape the whole design:

  • Which sky? "Near our city" becomes a bounding box — say, the metro area plus approach corridors. The box size directly sets the API cost, so this is a budget question disguised as a geography question.
  • How live is "live"? A reader checking "what was that plane" tolerates a 3–5 minute delay. An air-traffic display would not. Our tolerance sets the poll cadence — and the cadence must fit inside the quota.
  • What does a flight mean here? A state vector is a momentary observation of a transponder, not a flight record. Same aircraft, new poll, new vector — dedup is our job.

The quota, stated first

Per OpenSky's documented credit system (verify current docs before you build — limits change): anonymous access gets roughly 400 API credits per day, rate-limited to about 1 request per 10 seconds. A /states/all call costs 1–4 credits depending on bounding-box area: small boxes (0–25 square degrees) cost 1 credit; larger boxes cost more. A free authenticated account raises the budget to roughly 4,000 credits/day. These are documented figures, not measurements — treat them as the design envelope and re-check them.

The minimum concept

One poll. One bounding box. Parse the response, print the callsigns. The entire pipeline in its smallest shippable form:

import requests

BBOX = {"lamin": 37.0, "lamax": 38.0, "lomin": -123.0, "lomax": -122.0}  # Bay Area, ~1 sq deg

r = requests.get("https://opensky-network.org/api/states/all",
                 params=BBOX, timeout=15)
r.raise_for_status()
body = r.json()

print(f"API time: {body['time']}, vectors: {len(body['states'] or [])}")
for v in (body["states"] or [])[:5]:
    icao24, callsign = v[0], (v[1] or "").strip()
    print(f"{icao24}  {callsign or '(no callsign)'}  {v[6]:.2f},{v[5]:.2f}")

A real response looks like this (trimmed from a live call): ["a14b1a","UAL301 ","United States",1792124736,1792124744,-122.3801,37.616,null,true,0.06,208.12,...]. Eleven-plus positional fields, no names. That positional array is the state vector — everything downstream starts here.

flowchart LR A[Poll OpenSky
bbox query] --> B[Parse state vectors] B --> C[Dedup by icao24] C --> D[Store snapshot
with observed_at] D --> E[Dashboard reads
latest snapshot]

Build: the poller

The poller has four jobs: ask politely, parse defensively, dedup, and stamp every snapshot with when we saw it.

Anatomy of a state vector

flowchart TB subgraph SV[One state vector — positional, nullable] direction LR A[icao24
stable identity] --- B[callsign
often blank] B --- C[time_position vs last_contact
two different clocks] C --- D[lon / lat / baro_altitude
altitude null on ground] D --- E[velocity / true_track
null when stationary] end

The fields that matter and their traps:

FieldWhat it isThe trap
icao2424-bit transponder address, hexStable per aircraft — this is your dedup key, not the callsign
callsignFlight number, e.g. UAL301Often blank or padded with spaces; same aircraft can change it
time_positionUnix time of the last position updateCan be null — no recent position fix
last_contactUnix time of last transponder contactDifferent clock from time_position; a vector can be "contacted recently, positioned long ago"
baro_altitudeBarometric altitude, metersNull is normal — on-ground aircraft report no altitude
velocityGround speed, m/sNull when stationary; 0.06 m/s is a parked plane, not a bug
on_groundBooleanThe only field that tells you whether nulls are expected
The trap: treating nulls as errors. A junior version of this pipeline logs warnings for every null altitude. At a busy airport, half the vectors are on-ground aircraft — the log fills with "errors" that are actually the API telling the truth. Null altitude with on_ground=true is not missing data. It's a parked plane. Parse nulls as information, not failure.

Dedup and the snapshot store

def parse_vector(v):
    """One state vector -> one dict. Nulls preserved, never coerced."""
    return {
        "icao24": v[0],
        "callsign": (v[1] or "").strip() or None,
        "origin_country": v[2],
        "time_position": v[3],      # may be None
        "last_contact": v[4],
        "longitude": v[5],
        "latitude": v[6],
        "baro_altitude_m": v[7],    # None on ground — normal
        "on_ground": v[8],
        "velocity_ms": v[9],        # None when stationary — normal
        "true_track": v[10],
        "observed_at": time.time(), # OUR clock: when we saw it
    }

def merge_snapshot(prev, vectors):
    """Dedup by icao24; keep the freshest vector per aircraft."""
    merged = dict(prev)
    for v in vectors:
        p = parse_vector(v)
        old = merged.get(p["icao24"])
        # Freshness = last_contact; fall back to our observation time
        new_ts = p["last_contact"] or p["observed_at"]
        old_ts = (old["last_contact"] or old["observed_at"]) if old else -1
        if new_ts >= old_ts:
            merged[p["icao24"]] = p
    return merged

Note observed_at: the API tells you when it saw the aircraft. Your pipeline must record when you saw the API's answer. When the dashboard later says "3 minutes old," that number must come from your clock, not the API's — the difference is the first thing you'll need when debugging staleness in monitoring.

Break: the quota does the designing

Here's where the free tier stops being a footnote and starts being the architecture. The arithmetic, from documented figures:

flowchart TD A[Anonymous quota
400 credits / day] --> B{Bounding box size} B -->|Small box: 1 credit / poll| C[400 polls / day
≈ 1 poll every 3.6 minutes] B -->|Medium box: 2 credits / poll| D[200 polls / day
≈ 1 poll every 7.2 minutes] C --> E{Reader tolerance?} D --> E E -->|“What was that plane” — yes| F[Design fits] E -->|Second-by-second tracking| G[Does not fit — needs auth tier
or a smaller promise]

Three consequences fall out of that diagram:

  1. Poll cadence is a budget decision, not a preference. With a 1-credit box, anonymous quota buys a poll every ~3.6 minutes. Priya's readers tolerate that. If she'd asked for per-second tracking, the honest answer is "not on this quota" — the rate-limits lesson's clarify-before-calculate rule, applied to someone else's API.
  2. The 10-second floor matters too. Even with credits to spare, anonymous requests are throttled to ~1 per 10 seconds. A retry loop that fires immediately on failure will eat 429s. Backoff with jitter, always.
  3. 429s are a normal operating condition. Quota exhaustion isn't an exception — it's the predictable end of every day if your cadence is wrong. The poller treats 429 like weather: expected, handled, logged.
sequenceDiagram participant P as Poller participant O as OpenSky API P->>O: GET /states/all?bbox=… O-->>P: 200 + state vectors P->>P: wait cadence (e.g. 210s) P->>O: GET /states/all?bbox=… O-->>P: 429 + Retry-After P->>P: sleep Retry-After + jitter P->>O: GET /states/all?bbox=… O-->>P: 200 + state vectors
The trap: polling faster "to be safe." A 60-second cadence feels more "live" — and burns 1,440 credits a day against a 400-credit quota. You'd be dark by 7 AM. Every poll cadence decision starts from the quota and works backward. The API's limits are not obstacles to route around; they're the requirements document.

Productionize: the poll loop

def poll_forever(bbox, credits_per_poll=1, daily_budget=400):
    """Poll on a cadence the quota can sustain. 429s handled, not feared."""
    interval = 86400 / (daily_budget // credits_per_poll)  # seconds between polls
    interval = max(interval, 11)  # respect the ~10s anonymous floor
    snapshot = {}
    while True:
        try:
            r = requests.get(API, params=bbox, timeout=15)
            if r.status_code == 429:
                wait = int(r.headers.get("Retry-After", 60))
                log(f"429: backing off {wait}s (+jitter)")
                time.sleep(wait + random.uniform(0, 10))
                continue
            r.raise_for_status()
            snapshot = merge_snapshot(snapshot, r.json()["states"] or [])
            store_snapshot(snapshot)  # sqlite, one row per icao24
        except requests.RequestException as e:
            log(f"poll failed: {e}; keeping last good snapshot")
        time.sleep(interval)

Packaging follows the packaging lesson: a real package, a requirements.txt, structured logs with the poll timestamp, credit spend, vector count, and HTTP status on every cycle. The credit spend per day is a metric from day one — Post 3 will alert on it.

Communicate: what "live" means, in writing

Priya's site gets a one-line honesty label under the dashboard, decided now, not later: "Positions update every few minutes from OpenSky Network's community receivers. Coverage and freshness vary." That sentence is a contract with the reader. It prevents the support email that starts "your site said the plane was HERE but it was THERE." The dashboard doesn't exist yet — but its truthfulness policy already does.

Field check

  1. Your bounding box is 60 square degrees. How many credits does one /states/all poll cost, and what poll cadence can the anonymous quota sustain?
  2. A state vector arrives with baro_altitude=null, velocity=null, on_ground=true. Bug or normal? What about the same nulls with on_ground=false?
  3. Two polls return vectors for the same icao24. The first has last_contact=1792124800, the second last_contact=1792124700. Which do you keep, and why?
  4. The poller gets a 429 at 6 PM. The dashboard keeps showing the 5:30 PM snapshot. Is that a failure? What does the reader see?
  5. Why does the pipeline stamp observed_at with its own clock instead of trusting the API's timestamps alone?
Answers

1. 60 sq deg is a medium box: 2 credits per poll. 400 ÷ 2 = 200 polls/day → one poll every 7.2 minutes. If the reader tolerance needs better than that, the answer is a smaller box or the authenticated tier — not a faster loop on the same quota.

2. Normal: a parked plane reports no altitude and no velocity. With on_ground=false and null altitude/velocity, it's suspicious — likely a receiver gap or a transponder reporting partial data. Quarantine the vector for display (show position, flag altitude as unknown) rather than dropping it or inventing zeros.

3. Keep the first. Dedup by icao24 keeps the freshest last_contact; the second poll returned a staler vector (out-of-order delivery happens). Never assume poll order equals freshness order.

4. Not a failure — it's the designed degradation. The reader sees the last good snapshot with its age ("updated 32 min ago"). A 429 is a normal operating condition on a quota'd API; the failure would be showing nothing, or showing the stale snapshot without its age.

5. Because "how old is this data" is measured from when you received it. The API's time_position tells you when the receiver saw the plane; your observed_at tells you when you saw the API. The gap between them is network + processing latency — invisible if you only keep the API's clock, and it's the first number you need when staleness alerts fire.

Next: SkyWatch #2 — flights meet weather. Two live sources, two different freshness guarantees, one dashboard that has to be honest about both.