A production-shaped Flask URL shortener: app-factory pattern, Blueprints, Flask-SQLAlchemy, and a real Redis cache-aside layer in front of Postgres, with per-link click analytics.
Nobody has started this yet — be first.
base62 slugs longer than one digit don't decode back to the same id
Every auto-generated short link's slug is `base62.encode(row.id)`. Redirects still "work" today because `GET /<slug>` looks up `ShortURL.query.filter_by(slug=slug)` using the string slug directly, never decoding it back to an id. The real damage shows up anywhere the id needs to be recovered from the slug (a future admin tool, a migration script, a support engineer manually computing "what id does /3d7 correspond to?"), and in the fact that two different ids can currently encode to visually-confusable strings. This is the kind of bug that looks fine in a demo (single-digit ids: 0-61) and quietly produces garbage the moment the id counter crosses 62 -- exactly when a project has enough traffic to matter.
About this project
A small, production-shaped Flask app: shorten URLs, redirect on them, and see click analytics per link. The point of this project is not scale — it is doing the app factory pattern, Blueprints, Flask-SQLAlchemy, and a real Redis cache-aside layer the way a production Flask app would from day one. instantiates , , and a Redis wrapper without an app attached — only (in ) binds them, via etc., so multiple independent app instances (a test app, a CLI-only app, a worker) never share state. Routes are split by concern into Blueprints (, ), neither of which imports a bare object — they only see and the shared / extensions. The hot path, , is a textbook cache-aside: check Redis for first, fall back to Postgres on a miss, cache the result with a TTL, and always insert a on a successful redirect (hit or miss) since the cache only ever short-circuits the slug lookup, never the analytics write. Deletion and expiry both have explicit invalidation rules — a deleted link 404s immediately rather than waiting out a TTL, and an embedded in the cached JSON is re-checked on every hit so a link that is set to expire in 30 seconds cannot keep serving stale for the rest of an hour-long cache TTL. Click analytics hash visitor IPs () rather than storing them raw, so the app can tell "one visitor clicked 5 times" from "5 different visitors" without keeping anything that maps back to a real IP.
custom_slug boundary lengths (exactly 3 or 32 chars) are wrongly rejected
SLUG_MIN_LEN = 3 and SLUG_MAX_LEN = 32 are the documented, advertised bounds for a custom slug. A user who picks a slug at exactly one of those boundaries -- a short, memorable 3-letter brand tag like "ibm", or a slug padded out to exactly the max length -- gets a 400 telling them their perfectly valid input is invalid. This is a classic "off-by-one that generates support tickets": nothing crashes, nothing corrupts data, but every user who happens to land exactly on a boundary the API itself claims to allow gets bounced, and will not understand why.
app/extensions.pydb = SQLAlchemy()migrate = Migrate()create_app()app/__init__.pydb.init_app(app)app/blueprints/urlsapp/blueprints/analyticsappcurrent_appdbredis_clientGET /<slug>shorturl:slug:<slug>ClickEventexpires_atsha256(secret_key + ":" + ip)Twenty practice tickets are injected into an otherwise-working codebase: twelve are real, minimal, mostly single-mistake bugs (a base62 encoder that never reverses its digit order, a boundary check using strict instead of inclusive comparisons, a salt argument silently ignored by a hash function, an analytics query missing the one .filter() its neighbors all have, an off-by-one day in a rolling time window, a cache invalidation call that is simply never made, a DB fallback query missing an is_active filter, a TTL calculation with no floor that crashes Redis on a link expiring within a second, datetime.now() used where every other timestamp in the codebase uses datetime.utcnow(), a check-then-act race in slug-collision handling with no exception safety net around the commit, and a Redis extension that is a shared singleton instead of being scoped per Flask app instance), and eight are half-built enhancements — a listing endpoint, an update endpoint, a rate limiter, a custom analytics date range, a denormalized click-count column that already exists but is never incremented, a per-link cache TTL override, a bulk-create endpoint, and graceful degradation when Redis is unreachable — each currently a stub, a 501/NotImplementedError, or simply missing wiring, never a disguised version of the real fix. Ordered easy to hard, each ticket has its own currently-failing pytest test that goes green when (and only when) that ticket is fixed; no solutions are given anywhere in the ticket docs.
is_activedatetime.now()datetime.utcnow()NotImplementedErrorPOSTGRES_HOST_PORTREDIS_HOST_PORT.envdbredisdocker compose -f docker-compose.prod.yml up --buildGET /healthzYou do not need Docker running at all to work the tickets — the test suite uses TestingConfig (SQLite in-memory via StaticPool) and fakeredis instead of a real Postgres/Redis, so everything runs against a plain virtualenv:
python3 -m venv .venv && .venv/bin/pip install -r requirements-dev.txt
.venv/bin/pytest practice_tickets/tests/test_ticket_01_base62_roundtrip.py -v # a single ticket
./practice_tickets/run_tickets.sh # all 20, clean pass/fail summary
./practice_tickets/run_tickets.sh 05 13 16 # just a few, by number
All 20 tests fail at the start; as you fix each ticket in app/, its test (and only its test) should flip to passing. .venv/bin/pytest tests/ (via make test) runs the project's own main suite — some ticket bugs (TICKET-05, 07, 08, 09, 14 among others) also break assertions in that suite, since they are real bugs in shared code paths; fixing the ticket fixes those too, not a separate task.
How to run it locally, step by step.