| 1 | """Per-future result timeouts must cover the keyless bucket's queue. |
| 2 | |
| 3 | At 1 req/s, a batch of thirteen feed URLs on four workers queues about |
| 4 | thirteen seconds of token waits before the last fetch even starts, and four |
| 5 | subquery streams share the same bucket. A fixed 20-second future timeout then |
| 6 | expires while the fetch is still waiting for a token, and the feed is dropped |
| 7 | (`[RedditRSS] feed future failed:` with an empty message on the 2026-08-31 |
| 8 | smoke run). |
| 9 | """ |
| 10 | |
| 11 | import threading |
| 12 | from unittest import mock |
| 13 | |
| 14 | from lib import http, reddit_listing, reddit_rss |
| 15 | |
| 16 | |
| 17 | def test_limiter_reports_waiting_threads(): |
| 18 | limiter = http.RateLimiter(rate_per_sec=1000.0, burst=1) |
| 19 | assert limiter.waiting == 0 |
| 20 | limiter.acquire() # drains the single token |
| 21 | started = threading.Event() |
| 22 | |
| 23 | def _wait(): |
| 24 | started.set() |
| 25 | limiter.acquire() |
| 26 | |
| 27 | t = threading.Thread(target=_wait) |
| 28 | t.start() |
| 29 | started.wait(timeout=1) |
| 30 | # The waiter is inside acquire() until a token refills. |
| 31 | deadline = threading.Event() |
| 32 | deadline.wait(timeout=0.01) |
| 33 | t.join(timeout=2) |
| 34 | assert limiter.waiting == 0 |
| 35 | |
| 36 | |
| 37 | def test_wait_allowance_scales_with_batch_and_queue(monkeypatch): |
| 38 | limiter = http.RateLimiter(rate_per_sec=1.0, burst=2) |
| 39 | monkeypatch.delenv(http.REDDIT_KEYLESS_RATE_ENV, raising=False) |
| 40 | with mock.patch.object(http, "REDDIT_KEYLESS_LIMITER", limiter): |
| 41 | pad = http.REDDIT_KEYLESS_CONTENTION_SECONDS |
| 42 | assert http.reddit_keyless_wait_allowance(13) == 13.0 + pad |
| 43 | limiter._waiting = 5 |
| 44 | assert http.reddit_keyless_wait_allowance(13) == 18.0 + pad |
| 45 | # The allowance syncs the configured rate before computing. |
| 46 | monkeypatch.setenv(http.REDDIT_KEYLESS_RATE_ENV, "2") |
| 47 | assert http.reddit_keyless_wait_allowance(13) == 9.0 + pad |
| 48 | |
| 49 | |
| 50 | def test_rss_and_listing_result_timeouts_include_the_allowance(monkeypatch): |
| 51 | limiter = http.RateLimiter(rate_per_sec=1.0, burst=2) |
| 52 | monkeypatch.delenv(http.REDDIT_KEYLESS_RATE_ENV, raising=False) |
| 53 | with mock.patch.object(http, "REDDIT_KEYLESS_LIMITER", limiter): |
| 54 | pad = http.REDDIT_KEYLESS_CONTENTION_SECONDS |
| 55 | assert reddit_rss._result_timeout(13) == reddit_rss.FEED_TIMEOUT + 5 + 13.0 + pad |
| 56 | assert reddit_listing._result_timeout(20) == reddit_listing.LISTING_TIMEOUT + 5 + 20.0 + pad |
| 57 | |
| 58 | |
| 59 | def test_allowance_reflects_a_process_env_rate_override(monkeypatch): |
| 60 | limiter = http.RateLimiter(rate_per_sec=1.0, burst=2) |
| 61 | with mock.patch.object(http, "REDDIT_KEYLESS_LIMITER", limiter): |
| 62 | monkeypatch.setenv(http.REDDIT_KEYLESS_RATE_ENV, "0.5") |
| 63 | pad = http.REDDIT_KEYLESS_CONTENTION_SECONDS |
| 64 | assert http.reddit_keyless_wait_allowance(10) == 20.0 + pad |
| 65 |