FastAPI URL shortener with metrics
FastAPI + async SQLAlchemy + Pydantic + Docker, with rate limits, tests, and Prometheus metrics.
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 sampleSpec
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.Queueconsumer task) batches inserts every 1s or 1k events. - GeoIP lookup async via
httpxto MaxMind GeoLite2 / ipinfo.
Cache
- Cache
alias → urlin 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
docker compose upbrings full stack (api, postgres, redis) in < 30 s.pytest -rapasses; coverage > 85 %.- Load test: 5000 redirects/sec sustained on a 2-vCPU machine.
- Median redirect latency < 5 ms (cached) / < 20 ms (cold).
mypy --strictpasses (withsqlalchemy[mypy]).- CI builds Docker image, pushes to ghcr.io.
- 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 sessionRedirect 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 < limitTests 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 = 1024powershell
fly launch
fly postgres create
fly redis create
fly deployDeliverables
- GitHub repo with CI/CD badges (build, tests, coverage, image size).
- Live demo URL (
shorty.example.comorxx.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.