Nobody has started this yet — be first.
Business impact
Anyone can currently script thousands of POST /api/urls calls per second from a single IP with zero pushback -- filling the short_urls table, driving up Postgres storage/IOPS, and (worse) using this service as a disposable-link generator for spam/phishing campaigns, since the service happily mints links as fast as it can accept connections. This is the single most common abuse vector for any public URL shortener, and there is currently no mitigation at all.
Problem
create_short_url validates its input, checks for slug collisions, and inserts -- there is no per-client request counter anywhere in the path.
Current behavior
21 back-to-back POST /api/urls requests from the same IP within 60 seconds all return 201; nothing ever returns 429.
Expected behavior
POST /api/urls enforces a limit of 20 creates per rolling 60-second window per client IP (hash the IP the same way _record_click already does, for consistency with the rest of the app's privacy stance). A client that exceeds the limit gets 429 Too Many Requests with a clear error body; the limit resets on a rolling basis, not a fixed calendar window.
Steps to reproduce
Why this matters
Redis is already in this project specifically for exactly this kind of counter -- a fixed- window or sliding-window rate limiter is a classic Redis use case (INCR + EXPIRE, or a sorted set for a true sliding window). This is a good opportunity to use Redis for something other than the read-through slug cache, and to think about the tradeoffs between a simple fixed window (easy, but allows bursts at window boundaries) and a proper sliding window (more accurate, slightly more Redis calls).
Suggested approach
Look at app/utils/cache.py for the existing pattern of "a small module of Redis-backed helper functions used by the routes layer" -- a rate limiter fits the same shape (e.g. app/utils/rate_limit.py with a function like is_rate_limited(identifier) -> bool). Reuse hash_ip/current_app.config["SECRET_KEY"] for the identifier so you are not keying Redis directly off raw IPs. Redis's INCR returns the post-increment value, which combined with EXPIRE (set only on the first increment in a window) gives a simple fixed-window limiter in two calls.
Acceptance criteria
Verification
pytest practice_tickets/tests/test_ticket_11_rate_limiting.py -v
Hints (0/2)
Try it without hints first — the reading is the exercise.
Working on this ticket
Work on a branch named for the ticket — that's what you'll submit.
Branch off your fork
Sign in to create it automatically, or do it yourself:
$git checkout -b feat/rate-limitingFix it and commit
Meet every acceptance criterion, and add a test that would have caught this.
Push the branch
$git push -u origin feat/rate-limitingSubmit it below
Paste your fork URL and the branch name, with a short write-up of the root cause.
Questions
Ask about anything unclear in the ticket — the maintainer and anyone who has solved it can answer. Please don't post full solutions.
Sign in to ask a question or reply.
Sign indocker-entrypoint.shcurl -X POST http://localhost:5000/api/urls \
-H "Content-Type: application/json" \
-d '{"target_url": "https://anthropic.com", "custom_slug": "claude"}'
# -> {"slug": "claude", "short_url": "http://localhost:5000/claude", "expires_at": null}
curl -i http://localhost:5000/claude # 302 redirect, records a click
make seed # or: docker compose exec web flask seed-db
If something is already listening on 5432/6379 locally, uncomment POSTGRES_HOST_PORT / REDIS_HOST_PORT in .env to remap the host side — the app always talks to db/redis over the compose network regardless. Run production-style (gunicorn, no dev server, no bind mount) with docker compose -f docker-compose.prod.yml up --build. GET /healthz checks both Postgres and Redis connectivity.
You 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.
Level 2
Implement a feature
Extend the system within its own patterns.