Nobody has started this yet — be first.
Business impact
Redis is an optimization layer for this app -- the source of truth is always Postgres. But today, if Redis becomes unreachable for any reason (a restart, a network blip, hitting a memory limit, a bad deploy of the Redis service itself), every single GET /<slug> request crashes with a 500, because nothing catches the connection error. This inverts the whole point of a cache-aside pattern: the one thing a cache is supposed to do is fail open (fall back to the source of truth, just slower) -- instead, this app currently fails closed (an unrelated infra outage takes down 100% of redirects, the app's single most important endpoint, even though Postgres itself is perfectly healthy).
Problem
Neither resolve_short_url nor app/utils/cache.py's helper functions have any exception handling around the Redis calls. A ConnectionError (or any other redis-py exception) from get/setex/delete propagates straight up and crashes the request.
Current behavior
GET /<slug> raises redis.exceptions.ConnectionError straight out of app/utils/cache.py::get_cached_url, and the request 500s, even though the underlying row is perfectly resolvable from Postgres.
Expected behavior
If Redis is unreachable, GET /<slug> still works correctly by querying Postgres directly -- slower (no caching benefit until Redis recovers), but functional. The same principle should extend to DELETE/PATCH's cache invalidation calls: a failed invalidation during a Redis outage shouldn't prevent the underlying DB write from succeeding.
Steps to reproduce
Why this matters
This requires deciding where to catch the failure: too broad (wrapping the entire request) risks masking real bugs as "Redis is down"; too narrow (only around get, not setex) leaves half the failure modes uncovered. The right shape is narrow, targeted try/except around each Redis call specifically, treating a connection failure as equivalent to a cache miss (for reads) or a no-op (for writes/invalidation) -- with a log line so an actual outage is still visible in ops, not silently swallowed forever.
Suggested approach
Look at app/utils/cache.py's three functions (get_cached_url/set_cached_url/invalidate_cache) as the natural place to contain this -- each one already has a single, clear responsibility, so each can decide its own safe fallback (a failed get behaves like a miss; a failed set/delete just means "the cache did not get updated this time," always a safe, if suboptimal, outcome for a cache-aside design). Import redis.exceptions.ConnectionError (and consider redis.exceptions.TimeoutError) specifically, rather than a bare except Exception, so a real bug elsewhere doesn't get silently absorbed too.
Acceptance criteria
Verification
pytest practice_tickets/tests/test_ticket_20_redis_degradation.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/graceful-redis-degradationFix it and commit
Meet every acceptance criterion, and add a test that would have caught this.
Push the branch
$git push -u origin feat/graceful-redis-degradationSubmit 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.