feat(retry): add circuitforge_core.retry — standard backoff wrapper over backon
Some checks failed
CI / test (pull_request) Has been cancelled

Standardizes retry/backoff behavior for cf-core modules and products that
make external calls, replacing ad-hoc per-product retry loops. Wraps
backon (MIT, zero stdlib deps) — chosen over tenacity/backoff per the
eval in #65 for native async support, circuit breaker/hedging primitives,
and a process-wide enable/disable toggle ideal for tests.

Ships the standalone wrapper only; wiring it into llm/affiliates/reranker/
activitypub is left as follow-up work per module rather than retrofitting
LLMRouter's existing fallback-chain error handling in the same change.

Bump to 0.22.0.

Closes: #65
This commit is contained in:
pyr0ball 2026-07-10 17:55:55 -07:00
parent 11e49067a8
commit b812943ed1
4 changed files with 209 additions and 1 deletions

View file

@ -0,0 +1,93 @@
# circuitforge_core/retry.py
"""
circuitforge_core.retry thin standard-retry wrapper over backon (cf-core #65).
Standardizes retry/backoff behavior across cf-core modules and products that
make external calls (LLM endpoints, affiliate links, reranker APIs, federated
ActivityPub delivery). Wraps `backon` (MIT, zero stdlib dependencies) rather
than reimplementing retry logic, and rather than each module hand-rolling its
own ad-hoc retry loop.
Typical usage::
from circuitforge_core.retry import on_exception
import requests
@on_exception(requests.RequestException, max_tries=3)
def fetch(url):
return requests.get(url, timeout=5)
Tests should disable retries globally rather than waiting out real backoff
delays::
from circuitforge_core.retry import disable_retries
def test_fetch_raises_immediately_on_failure():
with disable_retries():
with pytest.raises(requests.RequestException):
fetch("http://unreachable")
"""
from __future__ import annotations
from collections.abc import Callable
from typing import TypeVar
import backon
from backon import disable as disable_retries_globally
from backon import disable_retries, enable_retries
from backon import enable as enable_retries_globally
T = TypeVar("T")
__all__ = [
"on_exception",
"retry",
"disable_retries",
"enable_retries",
"disable_retries_globally",
"enable_retries_globally",
]
_DEFAULT_MAX_TRIES = 3
_DEFAULT_MAX_TIME = 30.0
def on_exception(
exception: type[Exception] | tuple[type[Exception], ...] = Exception,
*,
max_tries: int = _DEFAULT_MAX_TRIES,
max_time: float | None = _DEFAULT_MAX_TIME,
**backon_kwargs,
) -> Callable[[Callable[..., T]], Callable[..., T]]:
"""
Decorator: retry a function on `exception` using full-jitter exponential
backoff CF's standard defaults for external API calls.
Extra keyword arguments are forwarded to `backon.on_exception` (e.g.
`on_backoff=` for a logging callback, `sleep=` to override the sleep
function in tests).
"""
return backon.on_exception(
backon.expo(),
exception,
max_tries=max_tries,
max_time=max_time,
logger="circuitforge_core.retry",
**backon_kwargs,
)
def retry(
target: Callable[..., T],
*args,
exception: type[Exception] | tuple[type[Exception], ...] = Exception,
max_tries: int = _DEFAULT_MAX_TRIES,
max_time: float | None = _DEFAULT_MAX_TIME,
**kwargs,
) -> T:
"""
Call `target(*args, **kwargs)` with CF's standard retry/backoff behavior,
for wrapping an already-defined callable without decorator syntax.
"""
wrapped = on_exception(exception, max_tries=max_tries, max_time=max_time)(target)
return wrapped(*args, **kwargs)

View file

@ -4,13 +4,14 @@ build-backend = "setuptools.build_meta"
[project] [project]
name = "circuitforge-core" name = "circuitforge-core"
version = "0.21.0" version = "0.22.0"
description = "Shared scaffold for CircuitForge products (MIT)" description = "Shared scaffold for CircuitForge products (MIT)"
requires-python = ">=3.11" requires-python = ">=3.11"
dependencies = [ dependencies = [
"pyyaml>=6.0", "pyyaml>=6.0",
"requests>=2.31", "requests>=2.31",
"openai>=1.0", "openai>=1.0",
"backon>=4.0",
] ]
[project.optional-dependencies] [project.optional-dependencies]

View file

View file

@ -0,0 +1,114 @@
"""Tests for circuitforge_core.retry (cf-core #65)."""
from __future__ import annotations
from unittest.mock import patch
import pytest
from circuitforge_core.retry import (
disable_retries,
enable_retries_globally,
on_exception,
retry,
)
class FlakyError(Exception):
pass
def _flaky_after(n_failures):
"""Return a function that raises FlakyError n_failures times, then succeeds."""
state = {"calls": 0}
def fn():
state["calls"] += 1
if state["calls"] <= n_failures:
raise FlakyError(f"attempt {state['calls']}")
return state["calls"]
fn.state = state
return fn
@pytest.fixture(autouse=True)
def _ensure_retries_enabled():
"""Some tests disable retries; always restore the global state after."""
yield
enable_retries_globally()
class TestOnException:
def test_succeeds_without_retry_when_no_exception(self):
calls = {"n": 0}
@on_exception(FlakyError, max_tries=3)
def fn():
calls["n"] += 1
return "ok"
assert fn() == "ok"
assert calls["n"] == 1
def test_retries_until_success(self):
fn = _flaky_after(2)
wrapped = on_exception(FlakyError, max_tries=5, sleep=lambda s: None)(fn)
assert wrapped() == 3
assert fn.state["calls"] == 3
def test_gives_up_after_max_tries(self):
fn = _flaky_after(10)
wrapped = on_exception(FlakyError, max_tries=3, sleep=lambda s: None)(fn)
with pytest.raises(FlakyError):
wrapped()
assert fn.state["calls"] == 3
def test_only_retries_specified_exception_type(self):
@on_exception(FlakyError, max_tries=3, sleep=lambda s: None)
def fn():
raise ValueError("not a FlakyError")
with pytest.raises(ValueError):
fn()
def test_disable_retries_context_manager_calls_once(self):
fn = _flaky_after(10)
wrapped = on_exception(FlakyError, max_tries=5, sleep=lambda s: None)(fn)
with disable_retries():
with pytest.raises(FlakyError):
wrapped()
assert fn.state["calls"] == 1
def test_retries_resume_after_disable_retries_context_exits(self):
fn = _flaky_after(2)
wrapped = on_exception(FlakyError, max_tries=5, sleep=lambda s: None)(fn)
with disable_retries():
pass # just exercise enter/exit without calling wrapped()
assert wrapped() == 3
class TestRetry:
def test_retry_calls_target_with_args_and_kwargs(self):
def fn(a, b, *, c):
return a + b + c
assert retry(fn, 1, 2, c=3, max_tries=1) == 6
def test_retry_retries_on_failure(self):
fn = _flaky_after(2)
with patch("time.sleep"):
assert retry(fn, max_tries=5) == 3
assert fn.state["calls"] == 3
def test_retry_raises_after_exhausting_max_tries(self):
fn = _flaky_after(10)
with patch("time.sleep"), pytest.raises(FlakyError):
retry(fn, exception=FlakyError, max_tries=2)