| 1 | """Reddit answers an anonymous 429 with x-ratelimit-reset and no Retry-After. |
| 2 | |
| 3 | Reading only Retry-After sends the caller into exponential backoff (3s, 5s, 9s), |
| 4 | every step of which is shorter than the ~42s Reddit actually requires, so each |
| 5 | retry re-429s and the source is reported dead when it was only early. |
| 6 | """ |
| 7 | |
| 8 | from lib import http |
| 9 | |
| 10 | |
| 11 | class TestRetryDelayFromHeaders: |
| 12 | def test_prefers_retry_after_when_present(self): |
| 13 | assert http.retry_delay_from_headers({"Retry-After": "17"}, 3.0) == 17.0 |
| 14 | |
| 15 | def test_falls_back_to_x_ratelimit_reset(self): |
| 16 | # The header Reddit actually sends on search/RSS 429s. |
| 17 | assert http.retry_delay_from_headers({"x-ratelimit-reset": "42"}, 3.0) == 42.0 |
| 18 | |
| 19 | def test_retry_after_wins_over_ratelimit_reset(self): |
| 20 | headers = {"Retry-After": "5", "x-ratelimit-reset": "42"} |
| 21 | assert http.retry_delay_from_headers(headers, 3.0) == 5.0 |
| 22 | |
| 23 | def test_real_reddit_429_headers(self): |
| 24 | # Captured verbatim from an anonymous GET to |
| 25 | # https://www.reddit.com/search.rss?q=... on 2026-08-23. |
| 26 | headers = { |
| 27 | "x-ratelimit-used": "1", |
| 28 | "x-ratelimit-remaining": "0.0", |
| 29 | "x-ratelimit-reset": "42", |
| 30 | "server-timing": 'reddit-ct;desc="dn=FT,p=LON,cs=MISS"', |
| 31 | } |
| 32 | # Must not fall through to the 3s fallback -- that is the bug. |
| 33 | assert http.retry_delay_from_headers(headers, 3.0) == 42.0 |
| 34 | |
| 35 | def test_missing_headers_use_fallback(self): |
| 36 | assert http.retry_delay_from_headers({}, 7.5) == 7.5 |
| 37 | assert http.retry_delay_from_headers(None, 7.5) == 7.5 |
| 38 | |
| 39 | def test_unparseable_values_use_fallback(self): |
| 40 | # Retry-After may be an HTTP-date rather than a delta-seconds integer. |
| 41 | assert http.retry_delay_from_headers({"Retry-After": "Wed, 21 Oct 2026 07:28:00 GMT"}, 4.0) == 4.0 |
| 42 | assert http.retry_delay_from_headers({"x-ratelimit-reset": ""}, 4.0) == 4.0 |
| 43 | |
| 44 | def test_non_positive_values_use_fallback(self): |
| 45 | # A spent bucket sometimes reports 0; sleeping 0 just re-429s immediately. |
| 46 | assert http.retry_delay_from_headers({"x-ratelimit-reset": "0"}, 4.0) == 4.0 |
| 47 | assert http.retry_delay_from_headers({"Retry-After": "-1"}, 4.0) == 4.0 |
| 48 | |
| 49 | def test_skips_unparseable_header_and_reads_the_next(self): |
| 50 | headers = {"Retry-After": "soon", "x-ratelimit-reset": "42"} |
| 51 | assert http.retry_delay_from_headers(headers, 3.0) == 42.0 |
| 52 | |
| 53 | |
| 54 | class TestRetryDelayBounds: |
| 55 | """A reset header must never park a thread for minutes (or, for an epoch |
| 56 | timestamp, for years).""" |
| 57 | |
| 58 | def test_epoch_reset_is_converted_to_a_delta_and_capped(self): |
| 59 | # GitHub sends x-ratelimit-reset as epoch seconds with no Retry-After. |
| 60 | import time |
| 61 | epoch = str(int(time.time()) + 30) |
| 62 | delay = http.retry_delay_from_headers({"x-ratelimit-reset": epoch}, 3.0) |
| 63 | assert 0 < delay <= http.MAX_RETRY_DELAY_SECONDS |
| 64 | assert 25 <= delay <= 31 |
| 65 | |
| 66 | def test_far_future_epoch_is_capped(self): |
| 67 | # 2100-01-01 as epoch seconds: a delta of decades still yields the cap. |
| 68 | delay = http.retry_delay_from_headers({"x-ratelimit-reset": "4102444800"}, 3.0) |
| 69 | assert delay == http.MAX_RETRY_DELAY_SECONDS |
| 70 | |
| 71 | def test_long_delta_is_capped(self): |
| 72 | assert http.retry_delay_from_headers({"x-ratelimit-reset": "540"}, 3.0) == http.MAX_RETRY_DELAY_SECONDS |
| 73 | assert http.retry_delay_from_headers({"Retry-After": "600"}, 3.0) == http.MAX_RETRY_DELAY_SECONDS |
| 74 |