Build an Orbital API with FastAPI

Cache satellite TLE data in SQLite and serve it via a FastAPI backend.

Introduction

30 Second Summary

Every time you reload your satellite tracker, it fires off requests to an external data source. That source has strict rate limits. Reload too many times and your app goes dark.

In this project, you will build a Python backend API using FastAPI that caches satellite data locally in SQLite. Your frontend will load thousands of satellites instantly from cache instead of waiting on external network requests.

What You'll Build

You'll run both your backend and frontend together, reload the page as many times as you want, and watch thousands of satellites appear in milliseconds with zero rate-limit errors.

By the end of this project, you'll have:

  • A frontend that loads thousands of satellites in milliseconds from local cache instead of waiting on external network requests.
  • A background scheduler that automatically refreshes satellite data every 2 hours, matching CelesTrak's update cadence.
  • A status endpoint that shows when data was last refreshed, when the next refresh is scheduled, and whether the cache is stale.
  • Secret Mission: Add rate limiting to your own API with slowapi so it can defend itself from being overwhelmed.

Are there any prerequisites?

This project builds on OrbitX Projects 1 and 2. You should have a working OrbitX frontend with satellite groups, instanced rendering, and direct CelesTrak fetching before starting.

You also need Python 3.11 or higher installed on your machine.

Before We Start

Before we dive in, let's lock in what you're about to build and why it matters.

Set Up the Backend

Your OrbitX frontend currently fetches satellite data directly from CelesTrak on every page load. CelesTrak enforces a one-download-per-update policy, meaning the first request works fine but every subsequent request within the same 2-hour update window returns HTTP 403.

In this step, you will experience that rate-limit problem firsthand, then set up a Python backend with FastAPI that will eventually sit between the frontend and CelesTrak as a caching layer.

In this step, get ready to:
  • Experience the CelesTrak rate-limit problem in your frontend.
  • Create the backend project structure with a virtual environment and dependencies.
  • Run a hello-world FastAPI server and confirm it responds.
Experience the rate-limit problem

Before building anything new, you should see why the current approach breaks. CelesTrak only allows one download per satellite group per 2-hour update window.

  • Make sure your OrbitX frontend is running with all satellite groups enabled.
  • Open your browser's DevTools by pressing Cmd+Option+I (macOS) or Ctrl+Shift+I (Windows).
  • Click the Network tab.

Before you reload, think about what you expect to happen. Will CelesTrak happily serve the same satellite data again immediately?

  • Reload the page two or three times quickly.
  • Watch the Network tab for requests to celestrak.org.

You will see multiple requests returning a 403 status code. The frontend breaks because CelesTrak refuses to serve data it already provided within this update window.

Why does CelesTrak return 403?

CelesTrak updates its GP data once every 2 hours. After your first successful download in a given window, any repeated request returns HTTP 403 until the data updates again.

Continued excessive requests can result in your IP address being sent to CelesTrak's firewall. The solution is to download once and cache locally, which is exactly what you will build in this project.

Create the backend project structure

Now that you have seen the problem, the fix is a local backend that downloads satellite data once and serves it from cache. You will create a backend/ folder alongside your existing frontend code.

  • Open a terminal in your project root (the folder that contains your frontend code).
  • Create the backend folder and navigate into it by running these commands:
mkdir backend
cd backend
  • Create a Python virtual environment by running:
python -m venv venv
  • Confirm the venv/ folder was created by running:
ls

You should see a venv folder listed in the output.

  • Activate the virtual environment:

macOS

  • Run this command to activate:
source venv/bin/activate

Windows

  • Run this command to activate:
venv\Scripts\activate

Your terminal prompt now shows (venv) at the beginning, confirming the virtual environment is active.

Don't see (venv) in your prompt?

On Windows, if you get an execution policy error, run Set-ExecutionPolicy -Scope CurrentUser RemoteSigned in PowerShell first, then try activating again.

On macOS, make sure you used source before the path. help me activate my Python virtual environment

Now you will create a requirements.txt file to pin the project's dependencies.

  • Create a new file called requirements.txt inside the backend/ folder with this content:
fastapi[standard]==0.141.1
httpx==0.28.1
APScheduler==3.11.3

What are these packages?

  • fastapi[standard] is the web framework for building your API. The [standard] extra includes uvicorn (the server) and other production essentials.
  • httpx is an async HTTP client your backend will use to fetch data from CelesTrak.
  • APScheduler handles scheduled background tasks, like refreshing cached data every 2 hours.
  • Install all dependencies by running:
pip install -r requirements.txt

You will see pip downloading and installing each package. The output ends with a success message listing all installed packages.

Seeing install errors?

Make sure your (venv) prefix is visible in your terminal prompt. Installing without an active virtual environment can cause permission errors.

help me fix pip install errors in my virtual environment

  • Create the app folder and its files by running:

macOS

mkdir -p app
touch app/__init__.py
touch app/main.py

Windows

mkdir app
New-Item app\__init__.py
New-Item app\main.py
  • Confirm the structure by running:
ls app

You should see __init__.py and main.py listed.

Create the hello-world server

With the folder structure in place, you will write a minimal FastAPI app that proves the server runs. This gives you a foundation to build on in the next steps.

  • Open backend/app/main.py in your editor and add the following code:
from fastapi import FastAPI

# Create the FastAPI application instance
app = FastAPI(title="OrbitX API")

# Health check endpoint to confirm the server is running
@app.get("/health")
async def health():
    return {"status": "ok"}

What does this code do?

  • FastAPI(title="OrbitX API") creates your application instance with a name that appears in the auto-generated docs.
  • @app.get("/health") registers a GET endpoint at the /health path.
  • The async keyword makes the handler non-blocking, which matters when you add database and network calls later.
  • Save main.py.
  • Leave app/__init__.py empty. It tells Python that app/ is a package.

Before you start the server, what do you think will happen when you visit /health in your browser?

  • Start the development server by running this command from inside the backend/ folder:
uvicorn app.main:app --reload

What does this command do?

  • uvicorn is the ASGI server that runs your FastAPI app. It was installed as part of the fastapi[standard] extras.
  • app.main:app points to the app variable inside app/main.py.
  • --reload restarts the server whenever you change a file, so you do not need to restart manually during development.

You should see output ending with a line like Uvicorn running on http://127.0.0.1:8000.

  • Open your browser and navigate to http://127.0.0.1:8000/health.

You should see a JSON response: {"status":"ok"}. Your FastAPI server is running and responding to requests.

Seeing "Address already in use"?

Another process is using port 8000. You can either stop that process or start uvicorn on a different port with --port 8001.

If you see ModuleNotFoundError, make sure your virtual environment is active ((venv) in your prompt) and that you ran pip install -r requirements.txt successfully. help me fix my uvicorn startup error

✔️ Awesome, I've got everything!

Great. Double check you have saved main.py and that uvicorn is still running in your terminal.

ⓧ I'd like to double check the full code

Here is the complete backend/app/main.py:

from fastapi import FastAPI

app = FastAPI(title="OrbitX API")

@app.get("/health")
async def health():
    return {"status": "ok"}

And your backend/requirements.txt:

fastapi[standard]==0.141.1
httpx==0.28.1
APScheduler==3.11.3

The backend/app/__init__.py file should be empty.

Your backend is alive and responding. Next up, you will build the TLE cache that fetches satellite data from CelesTrak once and stores it in a local SQLite database for instant access.

Build the TLE Cache

Your backend is running and responding to health checks. That confirms the server works, but it's not doing anything useful yet.

In this step, you'll build the core caching layer. A SQLite database stores satellite TLE data locally, and a scheduled job fetches fresh data from CelesTrak every 2 hours. This means your frontend will never need to call CelesTrak directly.

In this step, get ready to:
  • Build a TLE cache module that fetches, parses, and stores satellite data in SQLite.
  • Create a scheduler that refreshes the cache every 2 hours automatically.
  • Wire the scheduler into the FastAPI server lifecycle and verify the cache seeds on startup.
Create the TLE cache module

The cache module handles everything related to storing and retrieving satellite data. It needs to create the database, fetch TLE data from CelesTrak, parse the raw text format, and provide query functions for other parts of the app.

  • Create a new file called tle_cache.py inside your backend/app/ folder.
  • Add the imports, configuration constants, and database initialization function by pasting this code:
import sqlite3
import os
from datetime import datetime, timezone

import httpx

# Path to the SQLite database file
DB_PATH = os.path.join(os.path.dirname(__file__), "..", "db", "tles.db")

# Satellite groups to fetch from CelesTrak
GROUPS = ["stations", "gps-ops", "weather", "resource"]

# Base URL for CelesTrak GP data API
CELESTRAK_BASE = "https://celestrak.org/NORAD/elements/gp.php"


def init_db():
    os.makedirs(os.path.dirname(DB_PATH), exist_ok=True)
    conn = sqlite3.connect(DB_PATH)
    conn.execute("""
        CREATE TABLE IF NOT EXISTS tles (
            norad_id INTEGER,
            name TEXT,
            line1 TEXT,
            line2 TEXT,
            group_id TEXT,
            updated_at TEXT,
            PRIMARY KEY (norad_id, group_id)
        )
    """)
    conn.commit()
    conn.close()

What does this code do?

DB_PATH points to backend/db/tles.db. The os.path.join builds the path relative to the module file, so it works regardless of where you run the server from.

init_db() creates both the db/ folder and the tles table if they don't already exist. The composite primary key (norad_id, group_id) lets the same satellite appear in multiple groups without duplicating its data.

  • Save tle_cache.py.
  • Verify the database gets created by running this from the backend/ directory:
python -c "from app.tle_cache import init_db; init_db(); print('DB created')"
  • Confirm the database file exists by running:
ls db/

You should see tles.db listed in the output.

Don't see tles.db?

Make sure you're running the command from the backend/ directory, not the project root. The import path app.tle_cache only resolves correctly from inside backend/.

help me debug why init_db isn't creating the database file

  • Add the TLE text parser below init_db() in tle_cache.py:
def parse_tle_text(text: str) -> list[dict]:
    # Split into non-empty lines
    lines = [line for line in text.strip().splitlines() if line.strip()]
    satellites = []
    i = 0
    while i + 2 < len(lines):
        name = lines[i].strip()
        line1 = lines[i + 1].strip()
        line2 = lines[i + 2].strip()
        # TLE line 1 starts with "1 " and line 2 with "2 "
        if line1.startswith("1 ") and line2.startswith("2 "):
            norad_id = int(line1[2:7].strip())
            satellites.append({
                "norad_id": norad_id,
                "name": name,
                "line1": line1,
                "line2": line2,
            })
            i += 3
        else:
            i += 1
    return satellites

What is TLE format?

CelesTrak returns satellite data in Three-Line Element (TLE) format. Each satellite takes three lines: a name line, then two data lines starting with 1 and 2 . The parser walks through the text in groups of three, extracting the NORAD catalog number from characters 2-7 of line 1.

  • Add the refresh_group() function below parse_tle_text(). This fetches TLE data from CelesTrak and stores it in the database:
async def refresh_group(group_id: str) -> int:
    url = f"{CELESTRAK_BASE}?GROUP={group_id}&FORMAT=tle"
    async with httpx.AsyncClient(timeout=30.0) as client:
        response = await client.get(url)
        response.raise_for_status()

    satellites = parse_tle_text(response.text)
    now = datetime.now(timezone.utc).isoformat()

    conn = sqlite3.connect(DB_PATH)
    for sat in satellites:
        conn.execute(
            """
            INSERT OR REPLACE INTO tles (norad_id, name, line1, line2, group_id, updated_at)
            VALUES (?, ?, ?, ?, ?, ?)
            """,
            (sat["norad_id"], sat["name"], sat["line1"], sat["line2"], group_id, now),
        )
    conn.commit()
    conn.close()
    return len(satellites)

What does refresh_group do?

It builds the CelesTrak URL for a satellite group (like stations or gps-ops), fetches the raw TLE text with httpx, parses it into individual satellites, and upserts each one into SQLite. The INSERT OR REPLACE means re-running the function updates existing records rather than creating duplicates.

  • Save tle_cache.py and test the fetch by running this from the backend/ directory:
python -c "import asyncio; from app.tle_cache import refresh_group; print(asyncio.run(refresh_group('stations')))"

You should see a number printed (around 500 or more). This is the count of station satellites now stored in your database.

Seeing a connection error or timeout?

This command makes a live request to CelesTrak. Make sure you have an internet connection. If CelesTrak returns a 403, you may have already downloaded this group's data within the current 2-hour window. Wait a few minutes and try again, or try a different group like gps-ops.

help me debug why refresh_group is failing to fetch from CelesTrak

  • Now add the query functions below refresh_group(). These retrieve cached data from the database:
def get_group(group_id: str) -> list[dict]:
    conn = sqlite3.connect(DB_PATH)
    conn.row_factory = sqlite3.Row
    rows = conn.execute(
        "SELECT norad_id, name, line1, line2 FROM tles WHERE group_id = ?",
        (group_id,),
    ).fetchall()
    conn.close()
    return [dict(row) for row in rows]


def get_satellite(norad_id: int) -> dict | None:
    conn = sqlite3.connect(DB_PATH)
    conn.row_factory = sqlite3.Row
    row = conn.execute(
        "SELECT norad_id, name, line1, line2, group_id FROM tles WHERE norad_id = ?",
        (norad_id,),
    ).fetchone()
    conn.close()
    return dict(row) if row else None


def search_satellites(query: str) -> list[dict]:
    conn = sqlite3.connect(DB_PATH)
    conn.row_factory = sqlite3.Row
    rows = conn.execute(
        "SELECT DISTINCT norad_id, name, line1, line2, group_id FROM tles WHERE name LIKE ?",
        (f"%{query}%",),
    ).fetchall()
    conn.close()
    return [dict(row) for row in rows]

What do these query functions do?

  • get_group() returns all TLEs for a single satellite group (e.g. all GPS satellites).
  • get_satellite() looks up one satellite by its NORAD catalog number.
  • search_satellites() performs a case-insensitive name search using SQL LIKE.
  • Add the final two status functions at the bottom of tle_cache.py:
def get_status() -> dict:
    conn = sqlite3.connect(DB_PATH)
    result = conn.execute("SELECT COUNT(*), MAX(updated_at) FROM tles").fetchone()
    conn.close()
    return {
        "satellite_count": result[0],
        "last_refreshed": result[1],
    }


def get_group_counts() -> list[dict]:
    conn = sqlite3.connect(DB_PATH)
    rows = conn.execute(
        "SELECT group_id, COUNT(*) as count FROM tles GROUP BY group_id"
    ).fetchall()
    conn.close()
    return [{"group_id": row[0], "count": row[1]} for row in rows]

What do these status functions do?

get_status() returns the total satellite count and the most recent update timestamp. get_group_counts() breaks the count down by group. Both will power the status endpoint you add shortly.

  • Save tle_cache.py and verify the status query works with your seeded data:
python -c "from app.tle_cache import get_status; print(get_status())"

You should see output like {'satellite_count': 500, 'last_refreshed': '2026-...'} showing the stations you fetched earlier.

✔️ Awesome, I've got everything!

Great. Make sure you've saved tle_cache.py with all the functions in place.

ⓧ I'd like to double check the full code

Your tle_cache.py should contain these sections in order. Scroll up through the chunks you built and confirm each is present:

  • Imports, DB_PATH, GROUPS, CELESTRAK_BASE constants.
  • init_db() function creating the tles table.
  • parse_tle_text() function parsing raw TLE text.
  • refresh_group() async function fetching from CelesTrak and upserting.
  • get_group(), get_satellite(), search_satellites() query functions.
  • get_status() and get_group_counts() status functions.
Create the scheduler and wire the lifespan

The cache module can fetch and store data, but nothing triggers it automatically. You need a scheduler that calls refresh_group() for every configured group on a 2-hour interval. Then you wire it into FastAPI's lifespan so the cache seeds immediately on server startup.

  • Create a new file called scheduler.py inside backend/app/ and paste this code:
import logging

from apscheduler.schedulers.asyncio import AsyncIOScheduler

from app.tle_cache import refresh_group, GROUPS

logger = logging.getLogger(__name__)

# Shared scheduler instance used by the lifespan
scheduler = AsyncIOScheduler()


async def refresh_all_groups():
    total = 0
    for group_id in GROUPS:
        try:
            count = await refresh_group(group_id)
            total += count
            logger.info(f"Refreshed {group_id}: {count} satellites")
        except Exception as e:
            logger.warning(f"Failed to refresh {group_id}: {e}")
    logger.info(f"Total satellites refreshed: {total}")

What does this code do?

AsyncIOScheduler from APScheduler runs scheduled jobs inside Python's async event loop. This is essential because refresh_group() is an async function.

refresh_all_groups() loops through all four groups (stations, gps-ops, weather, resource) and fetches each one. The try/except ensures that if one group fails, the others still get refreshed.

  • Save scheduler.py.
  • Now replace the entire contents of backend/app/main.py with the lifespan-wired version. Start with the imports and lifespan function:
import logging
from contextlib import asynccontextmanager

from fastapi import FastAPI
from fastapi.middleware.cors import CORSMiddleware

from app.tle_cache import init_db
from app.scheduler import scheduler, refresh_all_groups

logging.basicConfig(level=logging.INFO)
logger = logging.getLogger(__name__)


@asynccontextmanager
async def lifespan(app: FastAPI):
    # Startup: seed cache and start scheduler
    init_db()
    await refresh_all_groups()
    scheduler.add_job(
        refresh_all_groups,
        "interval",
        hours=2,
        id="refresh_tles",
        replace_existing=True,
    )
    scheduler.start()
    logger.info("Scheduler started - refreshing every 2 hours")
    yield
    # Shutdown: stop scheduler
    scheduler.shutdown()
    logger.info("Scheduler shut down")

What is a lifespan context manager?

FastAPI uses this pattern to run setup code before the server accepts requests and cleanup code when it shuts down. Everything before yield runs on startup. Everything after yield runs on shutdown.

On startup, the lifespan initializes the database, fetches all satellite groups from CelesTrak (seeding the cache), then starts the scheduler for automatic 2-hour refreshes.

  • Add the app creation, CORS middleware, and health endpoint below the lifespan function in the same file:
app = FastAPI(title="OrbitX API", lifespan=lifespan)

# Allow the Vite frontend to make requests to this backend
app.add_middleware(
    CORSMiddleware,
    allow_origins=["http://localhost:5173"],
    allow_credentials=True,
    allow_methods=["*"],
    allow_headers=["*"],
)


@app.get("/health")
async def health():
    return {"status": "ok"}

Why add CORS now?

CORS (Cross-Origin Resource Sharing) controls which websites can call your API. Without this middleware, your Vite frontend at http://localhost:5173 would be blocked from making requests to the backend at http://localhost:8000. Adding it now means the API is ready for the frontend the moment you wire it up.

  • Save main.py. Because you started uvicorn with --reload, the server restarts automatically.

Watch the terminal where uvicorn is running. You should see log lines showing each group being refreshed. This takes 10-30 seconds as the server fetches all four groups from CelesTrak.

What should I see?

Your terminal will show lines like:

INFO:     Refreshed stations: 583 satellites
INFO:     Refreshed gps-ops: 79 satellites
INFO:     Refreshed weather: 213 satellites
INFO:     Refreshed resource: 182 satellites
INFO:     Total satellites refreshed: 1057
INFO:     Scheduler started - refreshing every 2 hours

The exact numbers will vary. The important thing is that all four groups succeed and the total is above 1000.

Server crashing on restart?

If you see ModuleNotFoundError: No module named 'app', make sure uvicorn is running from the backend/ directory. Stop the server with Ctrl+C, run cd backend, and restart with uvicorn app.main:app --reload.

If you see an ImportError about refresh_group or GROUPS, double-check that scheduler.py imports from app.tle_cache import refresh_group, GROUPS exactly.

help me debug why my FastAPI server won't start after adding the lifespan

Add a status endpoint and verify

The cache is seeding and the scheduler is running. Now add an endpoint that exposes this information so you (and later the frontend) can check whether the data is fresh.

  • In backend/app/main.py, find this import line near the top:
from app.tle_cache import init_db
  • Update it to also import get_status, so it reads:
from app.tle_cache import init_db, get_status
  • Add the status endpoint below the /health endpoint at the bottom of main.py:
@app.get("/api/status")
async def status():
    info = get_status()
    job = scheduler.get_job("refresh_tles")
    next_run = job.next_run_time.isoformat() if job and job.next_run_time else None
    stale = False
    if info["last_refreshed"]:
        from datetime import datetime, timezone, timedelta
        last = datetime.fromisoformat(info["last_refreshed"])
        stale = datetime.now(timezone.utc) - last > timedelta(hours=3)
    return {
        "satellite_count": info["satellite_count"],
        "last_refreshed": info["last_refreshed"],
        "next_refresh": next_run,
        "stale": stale,
    }

What does the status endpoint return?

  • satellite_count: total number of cached satellites across all groups.
  • last_refreshed: UTC timestamp of the most recent successful refresh.
  • next_refresh: when the scheduler will next run (2 hours from last refresh).
  • stale: true if data is older than 3 hours, meaning a refresh has failed at least once.
  • Save main.py. The server will restart automatically.

Before you check the endpoint, what do you expect the satellite_count value to be? Think about how many groups you configured and roughly how many satellites each has.

  • Open http://127.0.0.1:8000/api/status in your browser.

You should see a JSON response with satellite_count above 1000, a last_refreshed timestamp, a next_refresh timestamp roughly 2 hours in the future, and stale set to false.

Status showing satellite_count of 0?

If the count is 0, the cache didn't seed on startup. Check the terminal for error messages during the refresh. A common cause is CelesTrak returning 403 if you already downloaded the data during your earlier tests. Stop the server, delete backend/db/tles.db, and restart. The fresh startup will re-fetch all groups.

help me debug why my /api/status endpoint shows zero satellites

✔️ Awesome, I've got everything!

Great. Confirm you've saved both main.py and scheduler.py, and that the status endpoint returns a healthy response.

ⓧ I'd like to double check the full code

Your main.py should contain these sections in order. Scroll up and confirm each is present:

  • Imports (logging, asynccontextmanager, FastAPI, CORSMiddleware, init_db, get_status, scheduler, refresh_all_groups).
  • Logger setup with logging.basicConfig(level=logging.INFO).
  • lifespan async context manager with init, refresh, scheduler start, yield, shutdown.
  • app = FastAPI(title="OrbitX API", lifespan=lifespan) with CORS middleware.
  • /health and /api/status endpoints.

Your scheduler.py should have the AsyncIOScheduler instance and refresh_all_groups() function that loops through all four groups.

Your backend now has a fully functional caching layer. The database seeds on startup, the scheduler keeps it fresh, and a status endpoint confirms everything is working. Next up, you'll add the REST endpoints that serve this cached data to the frontend.

Create API Endpoints

Your backend has a working cache full of satellite data, but the frontend has no way to access it yet.

In this step, you'll build the REST endpoints that serve satellite data as clean JSON. These replace the direct CelesTrak fetches with instant responses from the local cache.

In this step, get ready to:
  • Build endpoints for listing groups, fetching TLEs, looking up individual satellites, and searching by name.
  • Confirm CORS is configured so the frontend can call your API.
  • Test all endpoints using the browser and auto-generated Swagger docs.
Build the group and TLE endpoints

The frontend needs four things from your API: a list of available groups, all TLEs for a specific group, a single satellite by ID, and a name search. Each of these maps to a function you already wrote in tle_cache.py.

  • In backend/app/main.py, find the two import lines near the top of the file:
from fastapi import FastAPI

from app.tle_cache import init_db, get_status
  • Replace those two lines with the following expanded imports:
from fastapi import FastAPI, HTTPException, Query

from app.tle_cache import (
    init_db,
    get_group,
    get_satellite,
    search_satellites,
    get_status,
    get_group_counts,
)

What are these new imports?

You're adding HTTPException (to return proper 404 errors when a satellite isn't found) and Query (to validate search queries).

The expanded tle_cache imports pull in the four data-access functions your new endpoints will call.

  • Scroll to the bottom of main.py, after the existing /api/status endpoint.
  • Add the group-listing and group-detail endpoints by pasting this code below the status function:
@app.get("/api/groups")
async def list_groups():
    return get_group_counts()


@app.get("/api/groups/{group_id}/tles")
async def group_tles(group_id: str):
    tles = get_group(group_id)
    if not tles:
        raise HTTPException(status_code=404, detail=f"Group '{group_id}' not found or empty")
    return tles

What do these endpoints do?

  • GET /api/groups returns a list of all satellite groups with their satellite counts. The frontend uses this to know what's available.
  • GET /api/groups/{group_id}/tles returns every TLE in a specific group as a JSON array. If the group doesn't exist, it returns a 404 error instead of an empty response.
  • Save main.py. Your server auto-reloads thanks to the --reload flag.
  • Open http://127.0.0.1:8000/api/groups in your browser.

You should see a JSON array listing each group and its satellite count, something like:

[{"group_id": "stations", "count": 523}, {"group_id": "gps-ops", "count": 31}, ...]

Seeing a server error or empty response?

  • Check your terminal for a traceback. A NameError means the import section is incomplete. Confirm get_group_counts is in the import block.
  • If the server didn't restart, check for a syntax error in the new code. The terminal shows the exact line number.
  • help me debug my /api/groups endpoint
  • Now add the individual satellite lookup and search endpoints below the group_tles function:
@app.get("/api/satellites/{norad_id}")
async def satellite_by_id(norad_id: int):
    sat = get_satellite(norad_id)
    if not sat:
        raise HTTPException(status_code=404, detail=f"Satellite {norad_id} not found")
    return sat


@app.get("/api/search")
async def search(q: str = Query(min_length=2)):
    results = search_satellites(q)
    return results

What do these endpoints do?

  • GET /api/satellites/{norad_id} looks up a single satellite by its NORAD catalog number. FastAPI automatically validates that norad_id is an integer.
  • GET /api/search?q={query} searches satellite names with a case-insensitive match. The Query(min_length=2) parameter rejects single-character searches that would return too many results.
  • Save main.py.
  • Test the search endpoint by opening http://127.0.0.1:8000/api/search?q=ISS in your browser.

You should see a JSON array containing satellites with "ISS" in their name.

Search returning an empty array?

  • Try a broader term like http://127.0.0.1:8000/api/search?q=NOAA. If that also returns [], the database may not have seeded properly. Check the /api/status endpoint to confirm the satellite count is above 0.
  • If you get a 422 validation error, make sure your query has at least 2 characters (the min_length=2 constraint rejects single letters).
  • help me debug my satellite search endpoint
Confirm CORS is configured for the frontend

Your new endpoints are accessible in the browser, but when the frontend tries to call them from JavaScript, the browser enforces a security policy called CORS. Without explicit permission, cross-origin requests are blocked.

  • In main.py, find the CORSMiddleware block you added in the previous step:
app.add_middleware(
    CORSMiddleware,
    allow_origins=["http://localhost:5173"],
    allow_credentials=True,
    allow_methods=["*"],
    allow_headers=["*"],
)

Why does this matter now?

Your frontend runs on http://localhost:5173 (Vite's dev server). Your backend runs on http://127.0.0.1:8000. These are different origins, so the browser blocks JavaScript from calling across them by default.

The allow_origins setting explicitly permits requests from Vite's address. Without it, every fetch() call from your frontend would fail silently in the browser console.

This is already in place from the previous step. No changes needed here.

Test all endpoints with the Swagger docs

FastAPI auto-generates interactive API documentation from your endpoint definitions. This is a quick way to test every endpoint without writing any client code.

  • Open http://127.0.0.1:8000/docs in your browser.

You should see a Swagger UI page listing all six endpoints: /health, /api/status, /api/groups, /api/groups/{group_id}/tles, /api/satellites/{norad_id}, and /api/search.

  • Click the GET /api/search row to expand it.
  • Click Try it out.
  • Enter GPS in the q field.
  • Click Execute.

The response body shows a JSON array of satellites with "GPS" in their name, confirming the search works.

What is Swagger UI?

Swagger UI is an interactive API explorer that FastAPI generates automatically from your route decorators and type hints. Every parameter type, response model, and path variable you define becomes a testable form field.

You can use it to test any endpoint without writing a single line of client code. It stays available at /docs as long as the server is running.

Before you test the final endpoint, what do you expect to see when you open the stations group TLE endpoint?

  • Open http://127.0.0.1:8000/api/groups/stations/tles in your browser.

You should see a JSON array of station TLEs, each with norad_id, name, line1, and line2 fields. Hundreds of entries load instantly from your local cache.

Seeing a 404 or empty array?

  • A 404 means the group name doesn't match. Confirm you're using stations (lowercase, no spaces) in the URL.
  • If the server crashed on startup, the cache may be empty. Check your terminal for error messages from the initial refresh_all_groups() call.
  • help me debug my stations TLE endpoint

✔️ Awesome, I've got everything!

Great. Confirm you've saved main.py and all six endpoints respond correctly.

ⓧ I'd like to double check the full code

Scroll up and confirm your main.py contains each of these sections in order:

  • The expanded import block with FastAPI, HTTPException, Query and six functions from tle_cache.
  • The lifespan context manager (unchanged from the previous step).
  • The app declaration and CORSMiddleware (unchanged).
  • The /health and /api/status endpoints (unchanged).
  • The new /api/groups and /api/groups/{group_id}/tles endpoints.
  • The new /api/satellites/{norad_id} and /api/search endpoints.

Your API now serves satellite data as clean JSON from a local cache. Next up, you'll rewire the frontend to fetch from these endpoints instead of CelesTrak directly.

Rewire the Frontend

Your backend is serving cached satellite data as clean JSON. But the frontend still fetches directly from CelesTrak on every page load, triggering the same HTTP 403 errors you saw in Step 1.

In this step, you'll rewire the frontend to fetch from your own backend instead. The backend responds instantly from its SQLite cache, so the frontend loads thousands of satellites in milliseconds with zero rate-limit risk.

In this step, get ready to:
  • Move the frontend into a frontend/ subfolder at the project root.
  • Rewrite the data-fetching module to use your backend API endpoints.
  • Run both servers simultaneously and confirm the rate-limit problem is gone.
Restructure the project

A clean project layout keeps your backend and frontend code separate. If your existing frontend files are already inside a frontend/ folder at the project root, skip ahead to the next substep.

  • If your frontend files are at the project root (alongside the backend/ folder), move them into a new frontend/ subfolder. Your project root should contain only backend/ and frontend/ at the top level.
  • Verify the frontend still runs from its new path by running these commands:
cd frontend
npm run dev

You should see Vite start up and serve the app at http://localhost:5173.

  • Stop the dev server with Ctrl+C once you confirm it starts successfully.

Frontend won't start?

Make sure you ran npm run dev from inside the frontend/ directory, not the project root. The package.json must be in the folder where you run the command.

Help me fix my frontend project structure.

Update catalog.ts to use the backend API

The current catalog.ts file fetches raw TLE text from CelesTrak and parses it in the browser. Your backend already does this work and serves clean JSON. You will replace the entire file with a thin client that calls your API.

  • Open frontend/src/utils/catalog.ts in your editor.
  • Delete all the existing contents of this file.
  • Paste the following code that defines the API base URL and TypeScript interfaces:
const API_BASE_URL = "http://localhost:8000";

export interface TLERecord {
  norad_id: number;
  name: string;
  line1: string;
  line2: string;
}

export interface GroupInfo {
  group_id: string;
  count: number;
}

export interface ApiStatus {
  satellite_count: number;
  last_refreshed: string | null;
  next_refresh: string | null;
  stale: boolean;
}

What does this code do?

  • API_BASE_URL points at your local backend. Every fetch function uses this as its root, so switching to a deployed URL later means changing one line.
  • TLERecord matches the JSON shape your backend returns for each satellite (NORAD ID, name, and the two TLE lines).
  • GroupInfo and ApiStatus type the responses from /api/groups and /api/status respectively.
  • Below the interfaces, paste the fetch functions that call each backend endpoint:
export async function fetchGroups(): Promise<GroupInfo[]> {
  const response = await fetch(`${API_BASE_URL}/api/groups`);
  if (!response.ok) throw new Error(`Failed to fetch groups: ${response.status}`);
  return response.json();
}

export async function fetchGroupTLEs(groupId: string): Promise<TLERecord[]> {
  const response = await fetch(`${API_BASE_URL}/api/groups/${groupId}/tles`);
  if (!response.ok) throw new Error(`Failed to fetch TLEs for ${groupId}: ${response.status}`);
  return response.json();
}

export async function fetchSatellite(noradId: number): Promise<TLERecord> {
  const response = await fetch(`${API_BASE_URL}/api/satellites/${noradId}`);
  if (!response.ok) throw new Error(`Satellite ${noradId} not found`);
  return response.json();
}

export async function searchSatellites(query: string): Promise<TLERecord[]> {
  const response = await fetch(`${API_BASE_URL}/api/search?q=${encodeURIComponent(query)}`);
  if (!response.ok) throw new Error(`Search failed: ${response.status}`);
  return response.json();
}

export async function fetchStatus(): Promise<ApiStatus> {
  const response = await fetch(`${API_BASE_URL}/api/status`);
  if (!response.ok) throw new Error(`Status check failed: ${response.status}`);
  return response.json();
}

What does this code do?

  • Each function calls one of your backend's REST endpoints and returns typed JSON.
  • fetchGroupTLEs() replaces the old direct-CelesTrak fetch. The backend returns pre-parsed JSON objects, so there is no more raw TLE text parsing in the browser.
  • Every function throws on a non-OK response, so calling code can catch and display errors to the user.
  • Save frontend/src/utils/catalog.ts.

\u2714\ufe0f Awesome, I've got everything!

Great. Double check you have saved the file, then move on to testing the full stack.

\u24e7 I'd like to double check the full code

Your complete frontend/src/utils/catalog.ts should contain the two chunks above in order. Scroll up and confirm the API_BASE_URL constant and three interfaces appear at the top, followed by the five fetch functions below them.

TypeScript errors in your editor?

If your editor shows type errors about the old imports that no longer exist (such as a removed parseTLEText function), you will need to update any other files that imported from the old catalog.ts. Replace those imports with the new exported functions like fetchGroupTLEs.

Help me fix TypeScript import errors after rewriting catalog.ts.

Test the full stack

You need both servers running at the same time. The backend serves data on port 8000 and the frontend dev server runs on port 5173.

  • Open a terminal and start the backend by running:
cd backend
uvicorn app.main:app --reload
  • Wait until you see the scheduler log messages confirming the cache is seeded.
  • Open a second terminal and start the frontend by running:
cd frontend
npm run dev
  • Open the frontend in your browser at http://localhost:5173.

Before you check the Network tab, predict what you expect to see. Will the satellite data requests go to celestrak.org or to localhost:8000?

  • Open DevTools with Cmd+Option+I (macOS) or Ctrl+Shift+I (Windows).
  • Click the Network tab.
  • Reload the page.

All satellite data requests now go to localhost:8000. No requests go to celestrak.org. The rate-limit problem from Step 1 is gone.

  • Reload the page rapidly 5-6 times to confirm no HTTP 403 errors appear.

Why is it so fast now?

Your backend responds from the SQLite cache in a few milliseconds. Previously, each page load waited for CelesTrak's response over the network. Now the data is local, and you can reload as many times as you want without ever hitting an external rate limit.

Still seeing requests to celestrak.org?

Check that you deleted all the old CelesTrak URLs from catalog.ts. If another file in your frontend still imports a function that fetches CelesTrak directly, update that import to use the new fetchGroupTLEs function instead.

Also confirm the API_BASE_URL is set to http://localhost:8000 (no trailing slash).

Help me debug why my frontend still hits CelesTrak.

Your frontend now loads satellite data instantly from your own cached API. Next up, you will add resilience so the system handles outages gracefully.

Add Refresh Status and Error Handling

Your backend fetches fresh data from CelesTrak every 2 hours. But what happens when CelesTrak is down, or when your own backend is unreachable from the frontend?

Right now, a CelesTrak outage crashes the refresh cycle and a backend outage leaves the frontend silently broken. In this step, you will make the backend resilient to upstream failures and give the frontend the ability to detect outages and recover automatically.

In this step, get ready to:
  • Make the backend preserve cached data when CelesTrak is unreachable.
  • Add a status indicator showing when satellite data was last refreshed.
  • Add error handling so the frontend detects and recovers from backend outages.
Make the backend resilient

If CelesTrak returns an error or times out during a scheduled refresh, the current code raises an exception and aborts. The cached data in tles.db is still perfectly valid. You want to keep serving it.

  • In backend/app/tle_cache.py, find the refresh_group() function.
  • Find these lines at the top of the function:
async def refresh_group(group_id: str) -> int:
    url = f"{CELESTRAK_BASE}?GROUP={group_id}&FORMAT=tle"
    async with httpx.AsyncClient(timeout=30.0) as client:
        response = await client.get(url)
        response.raise_for_status()

    satellites = parse_tle_text(response.text)
  • Replace the httpx block with a try/except that catches any exception, logs a warning, and returns 0 (preserving whatever is already in the cache):
async def refresh_group(group_id: str) -> int:
    url = f"{CELESTRAK_BASE}?GROUP={group_id}&FORMAT=tle"
    try:
        async with httpx.AsyncClient(timeout=30.0) as client:
            response = await client.get(url)
            response.raise_for_status()
    except Exception as e:
        import logging
        logging.getLogger(__name__).warning(f"CelesTrak fetch failed for {group_id}: {e}. Keeping existing cache.")
        return 0

    satellites = parse_tle_text(response.text)

What does this change do?

The try/except wraps the entire network call. If CelesTrak returns HTTP 403, times out, or the connection fails for any reason, the function logs a warning and returns 0 without touching the database.

The existing cached TLEs remain available for the API to serve. On the next scheduled run (2 hours later), the function tries again.

  • Save tle_cache.py.
  • The server auto-reloads. Check the terminal for any import errors.

Your /api/status endpoint already returns next_refresh (from the scheduler's next run time) and a stale boolean (true if data is older than 3 hours). The frontend can use these to show a status indicator.

  • Confirm the status endpoint works by opening this URL in your browser:
http://127.0.0.1:8000/api/status

You should see JSON with satellite_count, last_refreshed, next_refresh, and stale fields. The stale field should be false since you just refreshed.

Seeing an import error or crash?

  • Make sure the try: line is indented one level inside the function, and the async with block is indented inside the try.
  • Check that except Exception as e: aligns with the try: at the same indentation level.
  • The satellites = parse_tle_text(response.text) line must stay OUTSIDE and AFTER the try/except block.

Why is my refresh_group function crashing after the try/except change?

Add a status indicator to the frontend

Your frontend already has a fetchStatus() function in catalog.ts that returns the ApiStatus data. Now you need a visible status badge so users know whether data is current.

  • Create a small status badge component in your frontend that calls fetchStatus() on load.
  • Display "Data fresh as of: [time]" when the stale field is false, using the last_refreshed timestamp.
  • Display "Data stale - last updated [time]" when stale is true (meaning data is older than 3 hours).
  • Mount the status badge somewhere visible in your app layout.

What fields does fetchStatus() return?

The ApiStatus interface in catalog.ts gives you four fields to work with:

  • satellite_count tells you how many satellites are cached.
  • last_refreshed is the ISO timestamp of the most recent successful fetch.
  • next_refresh is when the scheduler will next attempt a refresh.
  • stale is true when last_refreshed is more than 3 hours ago.
  • Save your frontend changes and refresh the browser.
  • You should see a status badge displaying "Data fresh as of:" followed by a recent timestamp.

Status badge not appearing?

  • Check the browser console for CORS errors. The backend must be running for the status fetch to succeed.
  • Verify that your component is calling fetchStatus() from catalog.ts and not a hardcoded URL.

Help me debug my status badge component that calls fetchStatus()

Add frontend error handling

When the backend is unreachable, the frontend should tell the user what is happening instead of silently failing. It should also keep trying to reconnect automatically.

  • Add an error banner component that displays "Backend offline - retrying..." when any fetch to the backend fails with a network error.
  • Set up a retry interval of 10 seconds. Every 10 seconds while the banner is visible, attempt to reach the backend (for example, call fetchStatus()).
  • When the backend responds successfully, dismiss the error banner and reload satellite data.

Why 10 seconds between retries?

Ten seconds gives the backend enough time to restart without flooding it with requests the instant it comes back up. It also keeps the recovery time short enough that the user sees data within seconds of the backend returning.

Now test the full resilience loop. Before you run this test, what do you think will happen in the frontend when you stop the backend?

  • Switch to the terminal running your backend.
  • Press Ctrl+C to stop the backend server.
  • Switch to the browser showing your frontend.
  • You should see the error banner appear: "Backend offline - retrying...".
  • Restart the backend by running this command from your backend/ directory:
uvicorn app.main:app --reload
  • Wait up to 10 seconds. The frontend should automatically dismiss the error banner and reload satellite data.
  • Confirm the status badge now shows "Data fresh as of:" with a recent timestamp.

Banner not appearing or not dismissing?

  • Open the browser console and look for uncaught promise rejections. Your error handler needs to catch network errors (not just HTTP error codes).
  • Make sure your retry logic clears the interval timer when the backend responds, otherwise it keeps retrying forever.
  • Check that the banner state resets on a successful fetchStatus() response.

Help me debug my frontend error banner and retry logic

✔️ Awesome, I've got everything!

Great. Make sure you have saved all your files across both the backend and frontend.

ⓧ I'd like to double check the full code

Here is the complete backend/app/tle_cache.py with the resilience change. Scroll through and confirm your version matches:

The file has these sections in order: imports and constants, init_db(), parse_tle_text(), refresh_group() (with the try/except), get_group(), get_satellite(), search_satellites(), get_status(), and get_group_counts().

The key change is in refresh_group(). Confirm the httpx call is wrapped in try/except Exception as e, the except block logs a warning and returns 0, and the rest of the function (parsing + database insert) only runs after a successful fetch.

Your app now handles failures gracefully on both sides. The backend preserves cached data through upstream outages, and the frontend keeps the user informed and recovers automatically. This is the resilience pattern used in production systems everywhere.

Secret mission

Rate-Limit Your Own API

You experienced CelesTrak's rate limiting from the client side. Now flip the perspective and implement rate limiting on your own API using slowapi. You'll protect your backend from being overwhelmed and teach the frontend to handle 429 responses gracefully with exponential backoff.

Clean Up Your Resources

Clean Up Your Resources

Decide whether to keep your resources running, pause them to come back later, or delete them entirely. This project runs entirely locally, so there are no ongoing costs.

Resources you used:

  • Python virtual environment at backend/venv/.
  • SQLite database at backend/db/tles.db.
  • Running uvicorn process (manual start, not a background service).

Keep everything running

No action needed. Choose this if you are still actively building on the OrbitX project.

  • The backend only runs when you start it manually with uvicorn. There is no background service consuming resources.
  • The SQLite database takes up a few megabytes on disk.
  • The virtual environment stays in backend/venv/ and is only active when you explicitly activate it.

Pause - I'll come back to this later

Shut down the running server to free up the terminal. Your files and database stay on disk for when you return.

  • If your uvicorn server is still running, press Ctrl+C in the terminal to stop it.
  • Deactivate the virtual environment by running:
deactivate

When you come back, reactivate with venv\Scripts\activate (Windows) or source venv/bin/activate (macOS/Linux) from the backend/ folder.

Delete - I don't want to use this again

Remove all backend project resources and start fresh. The frontend will revert to needing direct CelesTrak fetching (with rate-limit issues).

  • If your uvicorn server is still running, press Ctrl+C in the terminal to stop it.
  • Remove the entire backend/ folder (includes the virtual environment, database, and all app code) by running:

macOS

cd ~/Desktop
rm -rf backend/

Windows

cd ~\Desktop
rmdir /s /q backend

This removes the Python virtual environment, the SQLite database, and all backend application code.

Nice Work!

Nice Work!

You just built a full caching backend that sits between your OrbitX frontend and CelesTrak, serving thousands of satellites instantly from a local database.

You've learned how to:

  • Build a FastAPI backend that caches satellite TLE data in SQLite, shielding an upstream data source from repeated requests and serving responses in milliseconds.
  • Implement scheduled background refreshes using APScheduler tied to FastAPI's lifespan events, keeping cached data fresh without manual intervention.
  • Design REST endpoints with CORS middleware and rewire a frontend to consume your own API instead of fetching external data directly.
  • Secret Mission: Implement rate limiting on your own API with proper HTTP 429 responses and client-side retry logic using exponential backoff.

Ready to quiz yourself?