FastAPI URL shortener with metrics

FastAPI + async SQLAlchemy + Pydantic + Docker, with rate limits, tests, and Prometheus metrics.

🛠 Intermediate

Goal

Build a production-grade async URL shortener service: REST API, Postgres-backed, with rate limiting, metrics, JWT auth, Dockerised, deployable to Fly.io / Cloud Run.

This is the most "resume-worthy" project — recruiters know URL shorteners and can evaluate code quality immediately.

Why this project

It hits every topic recruiters care about: REST, validation, auth, async DB, Redis cache, observability, testing, Docker, CI/CD. Small enough to finish, real enough to ship.

User experience

bash
# Create short URL
curl -X POST https://shorty.example.com/api/v1/links \
  -H "Authorization: Bearer $TOKEN" \
  -H "Content-Type: application/json" \
  -d '{"url": "https://example.com/very/long/path", "alias": "fav"}'
# → {"id": "abc123", "alias": "fav", "url": "https://example.com/...", "short_url": "https://shorty.example.com/fav"}

# Visit redirect
curl -i https://shorty.example.com/fav
# → 302 Location: https://example.com/very/long/path

# Stats
curl https://shorty.example.com/api/v1/links/fav/stats
# → {"clicks": 42, "last_clicked": "...", "referrers": {"google.com": 18, ...}}

Tech stack

  • FastAPI + uvicorn[standard].
  • SQLAlchemy 2.x async + asyncpg + Postgres 16.
  • Alembic migrations.
  • Redis (cache + rate limiter).
  • Pydantic v2 models.
  • PyJWT (auth).
  • prometheus_client (metrics) + structlog (logs).
  • pytest + pytest-asyncio + respx + testcontainers (tests).
  • uv + Docker + GitHub Actions + Fly.io.

Architecture

src/shorty/
├── __init__.py
├── main.py                  # FastAPI app
├── config.py                # pydantic-settings (env vars)
├── deps.py                  # Depends(): db_session, redis, current_user
├── db/
│   ├── __init__.py
│   ├── engine.py            # async engine + session_maker
│   └── models.py            # User, Link, ClickEvent
├── repos/
│   ├── link_repo.py
│   └── click_repo.py
├── services/
│   ├── shortener.py         # alias generation, collision retry
│   ├── click_tracker.py     # async background tracking
│   └── rate_limit.py        # Redis sliding-window
├── api/
│   ├── auth.py              # JWT issue/verify
│   ├── links.py             # POST /links, GET /links/{alias}
│   └── stats.py             # GET /links/{alias}/stats
├── middleware/
│   ├── request_id.py
│   ├── logging.py
│   └── metrics.py
└── observability/
    ├── logging.py           # structlog config
    └── metrics.py           # prometheus collectors
alembic/
├── env.py
└── versions/
tests/
├── conftest.py              # fixtures: app, db, redis (testcontainers)
├── test_create_link.py
├── test_redirect.py
├── test_stats.py
├── test_rate_limit.py
└── test_load.py             # locust / k6 sample

Spec

Data model

python
class User(Base):
    id: Mapped[UUID] = mapped_column(primary_key=True, default=uuid4)
    email: Mapped[str] = mapped_column(String(255), unique=True)
    hashed_password: Mapped[str]
    created_at: Mapped[datetime] = mapped_column(server_default=func.now())

class Link(Base):
    id: Mapped[int] = mapped_column(primary_key=True)
    alias: Mapped[str] = mapped_column(String(32), unique=True, index=True)
    url: Mapped[str] = mapped_column(Text)
    user_id: Mapped[UUID] = mapped_column(ForeignKey("user.id"))
    created_at: Mapped[datetime] = mapped_column(server_default=func.now())
    expires_at: Mapped[datetime | None]

class ClickEvent(Base):
    id: Mapped[int] = mapped_column(primary_key=True)
    link_id: Mapped[int] = mapped_column(ForeignKey("link.id"), index=True)
    ts: Mapped[datetime] = mapped_column(server_default=func.now(), index=True)
    referrer: Mapped[str | None]
    user_agent: Mapped[str | None]
    country: Mapped[str | None]

Endpoints

  • POST /auth/signup → create user.
  • POST /auth/login → JWT.
  • POST /api/v1/links (auth) → create.
  • GET /api/v1/links (auth) → list user's links.
  • DELETE /api/v1/links/{alias} (auth).
  • GET /api/v1/links/{alias}/stats (auth or public toggle).
  • GET /{alias} → 302 redirect (public, async-track click).
  • GET /metrics (prometheus).
  • GET /health, GET /ready.

Alias generation

  • User-supplied alias: validate ^[a-zA-Z0-9_-]{3,32}$, check uniqueness.
  • Auto-generated: 7-char base62 (secrets.choice); retry on collision (max 3).

Rate limiting

  • 100 redirects/min per IP (sliding window via Redis).
  • 60 creates/hour per user.

Click tracking

  • Don't block redirect: enqueue click event, return 302 immediately.
  • Worker (asyncio.Queue consumer task) batches inserts every 1s or 1k events.
  • GeoIP lookup async via httpx to MaxMind GeoLite2 / ipinfo.

Cache

  • Cache alias → url in Redis for hot links (LRU via Redis TTL).
  • Invalidate on update/delete.

Observability

  • Structured JSON logs with request_id, user_id, alias.
  • Prometheus counters: requests_total, redirects_total, errors_total.
  • Histograms: request latency by route.

Acceptance criteria

  1. docker compose up brings full stack (api, postgres, redis) in < 30 s.
  2. pytest -ra passes; coverage > 85 %.
  3. Load test: 5000 redirects/sec sustained on a 2-vCPU machine.
  4. Median redirect latency < 5 ms (cached) / < 20 ms (cold).
  5. mypy --strict passes (with sqlalchemy[mypy]).
  6. CI builds Docker image, pushes to ghcr.io.
  7. Deployed to Fly.io with a custom domain (shorty.example.com).

Stretch goals

  • Bulk import via CSV upload (background task with progress polling).
  • Custom domains per user.
  • QR code generation (PNG/SVG) endpoint.
  • Webhook on click.
  • Admin dashboard (Vue/Svelte SPA or HTMX).
  • Soft-delete with audit log.
  • Multi-region deploy with read replicas.
  • WebSocket live click feed for the dashboard.

Key implementation hints

Async DB session

python
from sqlalchemy.ext.asyncio import async_sessionmaker, create_async_engine, AsyncSession

engine = create_async_engine(settings.database_url, pool_size=20, max_overflow=0)
SessionLocal = async_sessionmaker(engine, expire_on_commit=False)

async def get_db() -> AsyncIterator[AsyncSession]:
    async with SessionLocal() as session:
        yield session

Redirect endpoint

python
@app.get("/{alias}", include_in_schema=False)
async def redirect(
    alias: str,
    request: Request,
    bg: BackgroundTasks,
    redis: Redis = Depends(get_redis),
    db: AsyncSession = Depends(get_db),
) -> RedirectResponse:
    url = await redis.get(f"alias:{alias}")
    if url is None:
        link = await link_repo.get_by_alias(db, alias)
        if not link or (link.expires_at and link.expires_at < datetime.now()):
            raise HTTPException(404, "not found")
        url = link.url
        await redis.set(f"alias:{alias}", url, ex=3600)
    bg.add_task(enqueue_click, alias, request)
    return RedirectResponse(url, status_code=302)

Rate limiter (sliding window)

python
async def rate_limit(redis: Redis, key: str, *, limit: int, window: int) -> bool:
    now = int(time.time() * 1000)
    cutoff = now - window * 1000
    async with redis.pipeline() as pipe:
        pipe.zremrangebyscore(key, 0, cutoff)
        pipe.zcard(key)
        pipe.zadd(key, {str(now): now})
        pipe.expire(key, window)
        _, count, *_ = await pipe.execute()
    return count < limit

Tests with testcontainers

python
@pytest.fixture(scope="session")
async def app():
    with PostgresContainer("postgres:16") as pg, \
         RedisContainer("redis:7") as redis_c:
        settings.database_url = pg.get_connection_url(driver="asyncpg")
        settings.redis_url = redis_c.get_connection_url()
        await run_migrations()
        yield create_app()

Deployment

toml
# fly.toml
app = "shorty"
[build]
  dockerfile = "Dockerfile"
[env]
  PORT = "8000"
[http_service]
  internal_port = 8000
  force_https = true
[[vm]]
  cpu_kind = "shared"
  cpus = 2
  memory_mb = 1024
powershell
fly launch
fly postgres create
fly redis create
fly deploy

Deliverables

  • GitHub repo with CI/CD badges (build, tests, coverage, image size).
  • Live demo URL (shorty.example.com or xx.fly.dev).
  • Architecture doc (docs/ARCHITECTURE.md) with sequence diagrams.
  • 10-min recorded walkthrough.
  • (Optional) Hacker News / Reddit launch post.

Lessons exercised

  • All of 02_oop, 03_advanced, 04_asyncio, 05_databases.
  • 06_testing (heavy).
  • 08_web_and_apis (all 4 lessons).
  • 09_packaging (Docker primarily).

Time estimate: 25–40 hours for core spec; 60–100 hours with stretch + polish + docs.