Fable 5.2 High
Six bugs, the first three serious enough to be returning wrong results in production right now.
Bugs, by severity
1. Cache hits return the wrong type (critical). The cache stores the `Response` object, but a fresh call returns `resp.json()`. Every repeat call with the same arguments gets a `Response` instead of a dict.
2. Error responses are cached (critical). `cache[key] = resp` runs before the status check, so one 500 is stored for good. Every later call returns that failed response with no exception.
3. Failures are swallowed (critical). `attempt == max_retries` is never true, because `range(3)` stops at 2. After the last failure the function sleeps another 4 seconds and returns `None`.
4. The cache never expires or shrinks (high). The `cache={}` default lives for the whole process, so data goes stale indefinitely and memory grows with every distinct URL and params pair.
5. Everything is retried (medium). A 404 or 401 costs three requests plus backoff, and the bare `except Exception` also retries JSON decode errors and plain code bugs.
6. The cache key is fragile (low). `str(params)` depends on dict order, so `{"a":1,"b":2}` and `{"b":2,"a":1}` miss each other. `params={}` is also a mutable default, though nothing mutates it today.
Fixed version
import json, time
import requests
_cache = {}
RETRYABLE = {429, 500, 502, 503, 504}
def fetch(url, params=None, max_retries=3, ttl=300):
if max_retries < 1:
raise ValueError("max_retries must be >= 1")
key = (url, json.dumps(params or {}, sort_keys=True, default=str))
hit = _cache.get(key)
if hit and hit[0] > time.monotonic():
return hit[1]
for attempt in range(max_retries):
last = attempt == max_retries - 1
try:
resp = requests.get(url, params=params, timeout=5)
except (requests.ConnectionError, requests.Timeout):
if last:
raise
else:
if last or resp.status_code not in RETRYABLE:
resp.raise_for_status()
data = resp.json()
_cache[key] = (time.monotonic() + ttl, data)
return data
time.sleep(2 ** attempt)
I ran this against mocked responses: success, retry then success, exhausted retries, 404, timeouts, bad JSON, and reordered params all behave correctly.
Things to know before you ship it:
* TTL: the 300 seconds is my placeholder; set it to how stale your data can be.
* Memory: expired entries are replaced on refetch but never evicted, so with many distinct keys use `cachetools.TTLCache` instead of the dict.
* Exceptions: it now raises `requests.HTTPError` rather than a bare `Exception`, so check any caller that matches on the old message.
* Callers: anything that adapted to the old behaviour (handling `None` or a `Response`) will need updating.