From 64dee6b0a8c458f33632f7e83c776d5492a5decb Mon Sep 17 00:00:00 2001 From: beam Date: Tue, 20 Oct 2026 21:56:50 +0000 Subject: [PATCH] beam: add bounded retry with exponential backoff + tests --- .../beam/__pycache__/retry.cpython-314.pyc | Bin 0 -> 1981 bytes .../test_atomic_write.cpython-314.pyc | Bin 0 -> 7731 bytes .../__pycache__/test_retry.cpython-314.pyc | Bin 0 -> 4333 bytes .../test_token_bucket.cpython-314.pyc | Bin 0 -> 5263 bytes .../__pycache__/token_bucket.cpython-314.pyc | Bin 0 -> 6745 bytes wizard-work/beam/retry.py | 38 ++++++++ wizard-work/beam/test_retry.py | 84 ++++++++++++++++++ 7 files changed, 122 insertions(+) create mode 100644 wizard-work/beam/__pycache__/retry.cpython-314.pyc create mode 100644 wizard-work/beam/__pycache__/test_atomic_write.cpython-314.pyc create mode 100644 wizard-work/beam/__pycache__/test_retry.cpython-314.pyc create mode 100644 wizard-work/beam/__pycache__/test_token_bucket.cpython-314.pyc create mode 100644 wizard-work/beam/__pycache__/token_bucket.cpython-314.pyc create mode 100644 wizard-work/beam/retry.py create mode 100644 wizard-work/beam/test_retry.py diff --git a/wizard-work/beam/__pycache__/retry.cpython-314.pyc b/wizard-work/beam/__pycache__/retry.cpython-314.pyc new file mode 100644 index 0000000000000000000000000000000000000000..eb930ae0dbad593017568cc70796f69771639b5a GIT binary patch literal 1981 zcmZux-EY%Y6hDp=+i{#GP1>eq>5@WWgqDPrGF4iEK?Q3CEnr2}plu>Iaoa!=n;U1e z725j1L&wWHZPM7p1N)F*(kAh`y)piQYK1Mm>$V4;cnboFX?xkZPE6UfBl+In{W?CM zzrGf~3@A4r{c^G526)64jwlVlJ-v!x1yryg11dMhjk+vXjA7RpZ}Il*w%j=LW1dmL z5=KQ!9F;6-)N6TTpt>`>>QMz%R3+6rBOl~cdB%OV!}-njtol?}N>G&usQw5PgGHC6 zsIFa*XjwjR+^ClGdOl8ci!8;j6s(1~zGxa{y=)bc-axl>T=9>Zi@LfbsU{Jv6$0s$7lNE$z*bd_}Hz86PBqdTLB(UU|eSi z%l~r33Dp$Q%6X%dRPAYo3g~r}l&N6MUet5eH-Hfr<_uEOtO|2`Crze^VOS}P7*$i> zU$O9E)l6L}ENdjc|B68_rDk=ll(M6jG?%E7%{DWpXR{=NI+!b!3plL6bLi&6PuNhC zf^{C}x&Yyhy2y|OtsQkQLox*0>pq4Q2&U?OhFU<`RSz%}1i7c)%1{UdMGJRE;Q{aaMMkKVrgr1QVbTL0B`c$P~Z}t$1Fd~T`xCvyi~1N@mW27EV53c8R%9jddDptz{R{GTcuQp5gNdUFk zeQLMpc5m9PXLlIo2$hOj#mef7IVv{khjOSI366RiHu_P|o?ctxwh+djXau7}vKO)4 zE1L*bpxy>j+nV-3jBK@Zu6GS?v>aNO4%NigwPV-byU}&)+|6$LAi2?!T9;C_Kzm=Z)yk;XSKj-6eZNo8k742d|%c;(@l;>Ybpp{p5?S zyJPFVSWOPCO#E4IhhWz;5WHQrNayNkEzohN_2G_v4+8ss54LZG+SdwqF5i9oyQ>d- zhBrcoS4V1z{yUeyjjSE}bnK(C8``b#gSOpwbE_vdmDaW0AD+H``tPkk^cis8P)%vA z^Em%i=|%7EbqdG&T=)9KksY3U1A!6Q^P@D3<9%7do%<0U$Aq7X*=*j(WwTVyX6LF% zRE$zsO_C@M)NK_?Iu)!XvrwLIkSS|D!YY_B5}@7}WZ6g+3o&V9Lnj;T=u5*TBVR4* zM@SO)ShZDhIH0v0w+V0j20I^t|5xz+0RtN_P!|~S2+*~|A+4^cLt33thqMk3-;Fx7 YwP)Y$3twDtsH=DHb4Fb@2iivb1F9d+?f?J) literal 0 HcmV?d00001 diff --git a/wizard-work/beam/__pycache__/test_atomic_write.cpython-314.pyc b/wizard-work/beam/__pycache__/test_atomic_write.cpython-314.pyc new file mode 100644 index 0000000000000000000000000000000000000000..6b0cf67f57b71f62ffc65048b9b311512d7db324 GIT binary patch literal 7731 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 literal 0 HcmV?d00001 diff --git a/wizard-work/beam/__pycache__/test_retry.cpython-314.pyc b/wizard-work/beam/__pycache__/test_retry.cpython-314.pyc new file mode 100644 index 0000000000000000000000000000000000000000..571248678bb772851edc61c85da9a8bf525295ff GIT binary patch literal 4333 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 Cr?^P~ literal 0 HcmV?d00001 diff --git a/wizard-work/beam/retry.py b/wizard-work/beam/retry.py new file mode 100644 index 0000000..dc1ab7b --- /dev/null +++ b/wizard-work/beam/retry.py @@ -0,0 +1,38 @@ +"""Bounded retry with exponential backoff + jitter.""" +from __future__ import annotations + +import random +import time +from typing import Callable, Optional, Tuple, Type + + +def retry( + fn: Callable, + *, + max_attempts: int = 3, + base_delay: float = 0.1, + max_delay: float = 10.0, + retryable: Tuple[Type[BaseException], ...] = (Exception,), + sleep: Callable[[float], None] = time.sleep, + rng: Optional[random.Random] = None, +) -> object: + if max_attempts < 1: + raise ValueError("max_attempts must be >= 1") + if base_delay < 0 or max_delay < 0: + raise ValueError("delays must be >= 0") + r = rng if rng is not None else random.Random() + last_exc = None + for attempt in range(max_attempts): + try: + return fn() + except retryable as e: + last_exc = e + if attempt == max_attempts - 1: + break + exp = min(max_delay, base_delay * (2 ** attempt)) + delay = r.uniform(0, exp) if exp > 0 else 0.0 + sleep(delay) + except BaseException: + raise + assert last_exc is not None + raise last_exc diff --git a/wizard-work/beam/test_retry.py b/wizard-work/beam/test_retry.py new file mode 100644 index 0000000..03f13e9 --- /dev/null +++ b/wizard-work/beam/test_retry.py @@ -0,0 +1,84 @@ +import time +from retry import retry + + +def test_succeeds_first(): + calls = {"n": 0} + def f(): + calls["n"] += 1 + return "ok" + assert retry(f) == "ok" + assert calls["n"] == 1 + + +def test_retries_then_succeeds(): + calls = {"n": 0} + def f(): + calls["n"] += 1 + if calls["n"] < 3: + raise ValueError("nope") + return "done" + assert retry(f, max_attempts=5, base_delay=0, max_delay=0) == "done" + assert calls["n"] == 3 + + +def test_exhausts_and_reraises(): + calls = {"n": 0} + def f(): + calls["n"] += 1 + raise RuntimeError("boom") + try: + retry(f, max_attempts=2, base_delay=0, max_delay=0) + assert False + except RuntimeError as e: + assert str(e) == "boom" + assert calls["n"] == 2 + + +def test_non_retryable_propagates_immediately(): + calls = {"n": 0} + def f(): + calls["n"] += 1 + raise KeyError("x") + try: + retry(f, max_attempts=5, base_delay=0, retryable=(ValueError,)) + assert False + except KeyError: + pass + assert calls["n"] == 1 + + +def test_invalid_params(): + for kw in ({"max_attempts": 0}, {"base_delay": -1}, {"max_delay": -1}): + try: + retry(lambda: None, **kw) + assert False + except ValueError: + pass + + +def test_sleep_called_with_bounded_delay(): + sleeps = [] + calls = {"n": 0} + def f(): + calls["n"] += 1 + if calls["n"] < 3: + raise ValueError("x") + return "y" + retry(f, max_attempts=3, base_delay=1.0, max_delay=2.0, sleep=sleeps.append) + assert len(sleeps) == 2 + for d in sleeps: + assert 0 <= d <= 2.0 + + +def test_keyboard_interrupt_not_retried(): + calls = {"n": 0} + def f(): + calls["n"] += 1 + raise KeyboardInterrupt() + try: + retry(f, max_attempts=5, base_delay=0) + assert False + except KeyboardInterrupt: + pass + assert calls["n"] == 1