From 53ff50af79e37ac967b0c84321ac5e3f6e449753 Mon Sep 17 00:00:00 2001 From: Eirik Botten Nicolaysen Date: Sat, 24 Oct 2026 08:17:54 +0200 Subject: [PATCH 1/3] Record the answering endpoint in target responses ASRTarget, DecisionTarget and HTTPAppTarget add an endpoint block (scheme, host, port, via_proxy) to TargetResponse.raw, warn once at construction on plain http to a non-loopback host, and create their client through one helper. ASRTarget uploads the file as audio. --- README.md | 4 +- simpleaudit/targets/_http.py | 77 ++++++++++++++++++++++++ simpleaudit/targets/asr.py | 13 ++--- simpleaudit/targets/decision.py | 8 +-- simpleaudit/targets/http.py | 8 +-- tests/test_endpoint_provenance.py | 97 +++++++++++++++++++++++++++++++ 6 files changed, 191 insertions(+), 16 deletions(-) create mode 100644 simpleaudit/targets/_http.py create mode 100644 tests/test_endpoint_provenance.py diff --git a/README.md b/README.md index e1fe73a..1d12d87 100644 --- a/README.md +++ b/README.md @@ -965,7 +965,7 @@ r = results[0] r.judgment["choice"], r.judgment["confidence"], r.judgment["probabilities"] ``` -For Jev on OpenRouter, use `DecisionTarget.openrouter("typesafe/jev-1.13")`, with the key in `OPENROUTER_API_KEY`. Provider routing, for example `{"provider": {"zdr": True}}`, goes in `extra_body`. +For Jev on OpenRouter, use `DecisionTarget.openrouter("typesafe/jev-1.13")`, with the key in `OPENROUTER_API_KEY`. Provider routing, for example `{"provider": {"zdr": True}}`, goes in `extra_body`. Each response's `raw` gains an `endpoint` block (scheme, host, port, and whether the standard proxy settings route it through a proxy), and a plain `http://` URL to a host other than loopback raises a warning. `DecisionTarget`: - checks the endpoint's limits before sending: 2–26 options per question, and 64 KiB per request for Ollama. A scenario over a limit is recorded as an error; its documents are never shortened. @@ -999,6 +999,8 @@ Use `max_turns=1` with `choice_match`. Follow-up turns need an `auditor_model` o local servers can use the same target. No timestamps are required. Provider/model support for languages, formats and optional parameters varies; unsupported requests are reported as errors rather than silently changing the input. +The file is uploaded as `audio` plus its lowercased extension, so the local +filename is not sent with the upload. ```python from simpleaudit import Auditor, ASRTarget diff --git a/simpleaudit/targets/_http.py b/simpleaudit/targets/_http.py new file mode 100644 index 0000000..12d8907 --- /dev/null +++ b/simpleaudit/targets/_http.py @@ -0,0 +1,77 @@ +"""HTTP plumbing shared by the targets: client creation and endpoint provenance. + +``describe_endpoint`` records where a request went (scheme, host, port, and +whether the standard proxy settings route it through a proxy) so an audit +record can say which endpoint answered. It never includes the path, query, +userinfo, headers or keys. +""" + +from __future__ import annotations + +import ipaddress +import urllib.request +import warnings +from typing import Any +from urllib.parse import urlsplit + +_DEFAULT_PORTS = {"http": 80, "https": 443} + + +def make_client(timeout: float) -> Any: + """A fresh ``httpx.AsyncClient(timeout=timeout)``, as the targets always created.""" + import httpx + + return httpx.AsyncClient(timeout=timeout) + + +def _is_loopback(host: str) -> bool: + if host.lower() == "localhost": + return True + try: + return ipaddress.ip_address(host).is_loopback + except ValueError: + return False + + +def describe_endpoint(url: str) -> dict[str, Any]: + """``{"scheme", "host", "port", "via_proxy"}`` for ``url``. + + ``via_proxy`` follows ``urllib.request.getproxies()`` / ``proxy_bypass()`` + (the environment's proxy variables); an injected client's own proxy + configuration is not visible here. + """ + parts = urlsplit(url) + scheme = parts.scheme.lower() + host = parts.hostname or "" + proxies = urllib.request.getproxies() + proxied = bool(proxies.get(scheme) or proxies.get("all")) + return { + "scheme": scheme, + "host": host, + "port": parts.port or _DEFAULT_PORTS.get(scheme), + "via_proxy": proxied and not urllib.request.proxy_bypass(host), + } + + +def warn_if_cleartext(url: str) -> None: + """Warn when ``url`` is plain http to a host other than loopback.""" + parts = urlsplit(url) + if parts.scheme.lower() == "http" and not _is_loopback(parts.hostname or ""): + warnings.warn( + f"target endpoint http://{parts.hostname} uses plain http: requests " + "and responses to it are not encrypted", + UserWarning, + stacklevel=3, + ) + + +def with_endpoint(raw: Any, url: str) -> dict[str, Any]: + """``raw`` with an ``endpoint`` block added; provider fields are never overwritten. + + A dict without an ``endpoint`` key gets one; anything else is wrapped as + ``{"response": raw, "endpoint": ...}``. + """ + endpoint = describe_endpoint(url) + if isinstance(raw, dict) and "endpoint" not in raw: + return {**raw, "endpoint": endpoint} + return {"response": raw, "endpoint": endpoint} diff --git a/simpleaudit/targets/asr.py b/simpleaudit/targets/asr.py index aad3e86..88975a2 100644 --- a/simpleaudit/targets/asr.py +++ b/simpleaudit/targets/asr.py @@ -8,6 +8,7 @@ from typing import Any from urllib.parse import unquote, urlparse +from ._http import make_client, warn_if_cleartext, with_endpoint from .base import TargetContext, TargetResponse @@ -35,6 +36,7 @@ def __init__( ) -> None: self.model = model self.url = base_url.rstrip("/") + "/audio/transcriptions" + warn_if_cleartext(self.url) self.api_key = api_key self.timeout = timeout self.options = dict(options or {}) @@ -63,8 +65,6 @@ async def send( documents: list[str | dict[str, Any]] | None = None, response_format: dict[str, Any] | None = None, ) -> TargetResponse: - import httpx - if file_uri is None: file_uri = next( ( @@ -102,9 +102,7 @@ async def send( headers = {"Authorization": f"Bearer {self.api_key}"} if self.api_key else {} if context: headers.update(context.trace_headers) - client = ( - self._client if self._client is not None else httpx.AsyncClient(timeout=self.timeout) - ) + client = self._client if self._client is not None else make_client(self.timeout) try: with path.open("rb") as audio: response = await client.post( @@ -113,7 +111,8 @@ async def send( headers=headers, files={ "file": ( - path.name, + # Neutral name: the local filename stays local. + "audio" + path.suffix.lower(), audio, mimetypes.guess_type(path.name)[0] or "application/octet-stream", ) @@ -124,7 +123,7 @@ async def send( payload = response.json() if not isinstance(payload, dict) or not isinstance(payload.get("text"), str): raise ValueError("Transcription response must contain a string 'text' field") - return TargetResponse(content=payload["text"], raw=payload) + return TargetResponse(content=payload["text"], raw=with_endpoint(payload, self.url)) finally: if self._client is None: await client.aclose() diff --git a/simpleaudit/targets/decision.py b/simpleaudit/targets/decision.py index 7fcab6f..ce1abdd 100644 --- a/simpleaudit/targets/decision.py +++ b/simpleaudit/targets/decision.py @@ -40,6 +40,7 @@ from typing import Any from ..context_marks import parse_documents +from ._http import make_client, warn_if_cleartext, with_endpoint from .base import TargetContext, TargetResponse, history_documents #: Ollama's request-body cap for System One requests without images. @@ -70,6 +71,7 @@ def __init__( max_body_bytes: int | None = None, ) -> None: self.url = url + warn_if_cleartext(url) self.model = model self.headers = dict(headers or {}) if api_key: @@ -177,9 +179,7 @@ async def send( if client := self._client: pass else: - import httpx - - client = httpx.AsyncClient(timeout=self.timeout) + client = make_client(self.timeout) try: resp = await client.post(self.url, content=payload, headers=headers) if resp.status_code >= 400: @@ -204,7 +204,7 @@ async def send( usage = data.get("usage") or {} return TargetResponse( content=f"{choice}: {description}" if description else choice, - raw=data, + raw=with_endpoint(data, self.url), input_tokens=usage.get("input_tokens"), output_tokens=usage.get("output_tokens"), decision=answer, diff --git a/simpleaudit/targets/http.py b/simpleaudit/targets/http.py index 0e86df1..b5fb773 100644 --- a/simpleaudit/targets/http.py +++ b/simpleaudit/targets/http.py @@ -24,6 +24,7 @@ from typing import Any, Callable, Dict, List, Optional, Union from ..context_marks import document_texts +from ._http import make_client, warn_if_cleartext, with_endpoint from .base import ( Target, TargetContext, @@ -113,6 +114,7 @@ def __init__( system_field: Optional[str] = None, ) -> None: self.url = url + warn_if_cleartext(url) self.method = method.upper() self.headers = dict(headers or {}) self.request_template = dict(request_template or {}) @@ -215,9 +217,7 @@ async def send( if client := self._client: pass else: - import httpx - - client = httpx.AsyncClient(timeout=self.timeout) + client = make_client(self.timeout) try: resp = await client.request(self.method, self.url, json=body, headers=headers) resp.raise_for_status() @@ -242,7 +242,7 @@ async def send( return TargetResponse( content=content, - raw=data, + raw=with_endpoint(data, self.url), input_tokens=int(in_tok) if in_tok is not None else None, output_tokens=int(out_tok) if out_tok is not None else None, ) diff --git a/tests/test_endpoint_provenance.py b/tests/test_endpoint_provenance.py new file mode 100644 index 0000000..7e97cbc --- /dev/null +++ b/tests/test_endpoint_provenance.py @@ -0,0 +1,97 @@ +"""Endpoint provenance in target responses, the cleartext warning, neutral ASR filenames.""" + +import warnings + +import httpx +import pytest + +from simpleaudit import ASRTarget, DecisionTarget, HTTPAppTarget +from simpleaudit.targets._http import describe_endpoint + +from .test_decision_target import _context, _endpoint, _send + +URL = "https://user:secret@api.example.com:8443/v1/chat?key=abc123" +PROXY_VARS = ("HTTP_PROXY", "HTTPS_PROXY", "ALL_PROXY", "NO_PROXY") + + +@pytest.fixture +def proxy_env(monkeypatch): + for name in PROXY_VARS: + monkeypatch.delenv(name, raising=False) + monkeypatch.delenv(name.lower(), raising=False) + # An environment proxy for another scheme keeps the system settings out of it. + monkeypatch.setenv("HTTP_PROXY", "http://proxy.test:3128") + return monkeypatch + + +@pytest.mark.parametrize( + "https_proxy,no_proxy,expected", + [(None, None, False), ("http://proxy.test:3128", None, True), + ("http://proxy.test:3128", "api.example.com", False)], +) +def test_describe_endpoint_and_via_proxy(proxy_env, https_proxy, no_proxy, expected): + if https_proxy: + proxy_env.setenv("HTTPS_PROXY", https_proxy) + if no_proxy: + proxy_env.setenv("NO_PROXY", no_proxy) + assert describe_endpoint(URL) == { + "scheme": "https", "host": "api.example.com", "port": 8443, "via_proxy": expected + } + + +def _http_target(payload): + client = httpx.AsyncClient(transport=httpx.MockTransport(lambda r: httpx.Response(200, **payload))) + return HTTPAppTarget(URL, response_path="answer", client=client) + + +@pytest.mark.asyncio +async def test_endpoint_block_is_merged_and_carries_no_path_query_or_key(proxy_env): + raw = (await _http_target({"json": {"answer": "ok", "id": 7}}).send(user="q")).raw + assert raw["answer"] == "ok" and raw["id"] == 7 + assert raw["endpoint"]["host"] == "api.example.com" + assert not any(s in repr(raw["endpoint"]) for s in ("secret", "user", "/v1", "key", "abc123")) + + +@pytest.mark.asyncio +async def test_non_dict_or_conflicting_raw_is_wrapped_not_overwritten(proxy_env): + raw = (await _http_target({"text": "plain"}).send(user="q")).raw + assert raw["response"] == "plain" and raw["endpoint"]["scheme"] == "https" + raw = (await _http_target({"json": {"answer": "ok", "endpoint": "provider"}}).send(user="q")).raw + assert raw["response"]["endpoint"] == "provider" and raw["endpoint"]["port"] == 8443 + + +def test_decision_response_carries_the_endpoint(proxy_env): + client, _ = _endpoint() + raw = _send(DecisionTarget(URL, "clef", client=client), context=_context()).raw + assert raw["answers"]["verdict"]["choice"] == "yes" and raw["endpoint"]["port"] == 8443 + + +def test_plain_http_to_a_remote_host_warns(): + with pytest.warns(UserWarning, match="http://example.com uses plain http"): + HTTPAppTarget("http://example.com/chat") + + +@pytest.mark.parametrize( + "url", ["http://localhost:11434", "http://127.0.0.5/v1", "http://[::1]:8000", "https://example.com"] +) +def test_loopback_and_https_do_not_warn(url): + with warnings.catch_warnings(): + warnings.simplefilter("error") + DecisionTarget(url, "clef") + ASRTarget("m", base_url=url) + + +@pytest.mark.asyncio +async def test_asr_upload_filename_is_neutral(tmp_path): + audio = tmp_path / "Interview Kari Nordmann.WAV" + audio.write_bytes(b"RIFF-audio") + seen = [] + + def handler(request): + seen.append(request.content) + return httpx.Response(200, json={"text": "ok"}) + + async with httpx.AsyncClient(transport=httpx.MockTransport(handler)) as client: + await ASRTarget("m", client=client).send(user="", file_uri=str(audio)) + assert b'filename="audio.wav"' in seen[0] + assert b"Kari" not in seen[0] and b"Interview" not in seen[0] From 6de1ae35cb90f393da32fd34682c2333dd39af13 Mon Sep 17 00:00:00 2001 From: Eirik Botten Nicolaysen Date: Sat, 24 Oct 2026 08:23:45 +0200 Subject: [PATCH 2/3] Ignore the cleartext warning from the tests' plain-http stub hosts --- pyproject.toml | 2 ++ 1 file changed, 2 insertions(+) diff --git a/pyproject.toml b/pyproject.toml index 4d04332..ec55240 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -91,6 +91,8 @@ target-version = ["py311", "py312", "py313"] testpaths = ["tests"] addopts = "--cov=simpleaudit --cov-report=html --cov-report=term-missing" asyncio_mode = "auto" +# Tests use plain-http stub hosts; the targets' cleartext warning is expected there. +filterwarnings = ['ignore:target endpoint http\S+ uses plain http:UserWarning'] [tool.coverage.run] source = ["simpleaudit"] From ccd99ee0cf86371d70d36d1a2d5472dcdbbf35e1 Mon Sep 17 00:00:00 2001 From: Eirik Botten Nicolaysen Date: Tue, 27 Oct 2026 18:00:43 +0100 Subject: [PATCH 3/3] DecisionTarget: optional client reuse and retries reuse_client=True keeps one httpx client per event loop instead of making one per request; aclose() and the async context manager close it. A client from an earlier loop (each sync run() has its own) is replaced, not reused. max_retries and retry_backoff retry transport errors and 429/5xx with a doubling backoff, with ModelTarget's names and defaults (0 and 0.5 s). A Retry-After header on a 429 or 503 replaces the wait, capped at 60 s. Both features default to today's behaviour: one client per request, one attempt. --- simpleaudit/targets/decision.py | 121 ++++++++++++++--- tests/test_decision_target.py | 230 ++++++++++++++++++++++++++++++++ 2 files changed, 336 insertions(+), 15 deletions(-) diff --git a/simpleaudit/targets/decision.py b/simpleaudit/targets/decision.py index ce1abdd..3e77faa 100644 --- a/simpleaudit/targets/decision.py +++ b/simpleaudit/targets/decision.py @@ -34,9 +34,12 @@ from __future__ import annotations +import asyncio import json import os from collections.abc import Mapping +from datetime import datetime, timezone +from email.utils import parsedate_to_datetime from typing import Any from ..context_marks import parse_documents @@ -46,10 +49,33 @@ #: Ollama's request-body cap for System One requests without images. OLLAMA_MAX_BODY_BYTES = 64 * 1024 +#: Statuses worth another attempt: rate limiting and server-side failures. +RETRY_STATUSES = frozenset({429, 500, 502, 503, 504}) + +#: Statuses whose ``Retry-After`` header (RFC 9110 section 10.2.3) is honoured. +RETRY_AFTER_STATUSES = frozenset({429, 503}) + +#: Longest wait, in seconds, that a ``Retry-After`` header can ask for. +_RETRY_AFTER_MAX = 60.0 + #: OpenRouter's System One endpoint (``POST /api/v1/systemone``). OPENROUTER_SYSTEMONE_URL = "https://openrouter.ai/api/v1/systemone" +def _retry_after_seconds(resp: Any) -> float | None: + """Seconds a ``Retry-After`` header asks for (delay-seconds or HTTP-date), else None.""" + value = (resp.headers.get("Retry-After") or "").strip() + if value.isdigit(): + return float(value) + try: + when = parsedate_to_datetime(value) + except (TypeError, ValueError): + return None + if when.tzinfo is None: + when = when.replace(tzinfo=timezone.utc) + return max(0.0, (when - datetime.now(timezone.utc)).total_seconds()) + + class DecisionTarget: """Send a scenario's decision question to a System One endpoint.""" @@ -69,7 +95,14 @@ def __init__( min_options: int = 2, max_options: int | None = 26, max_body_bytes: int | None = None, + max_retries: int = 0, + retry_backoff: float = 0.5, + reuse_client: bool = False, ) -> None: + if max_retries < 0: + raise ValueError("max_retries must be 0 or more") + if retry_backoff < 0: + raise ValueError("retry_backoff must be 0 or more") self.url = url warn_if_cleartext(url) self.model = model @@ -82,6 +115,73 @@ def __init__( self.min_options = min_options self.max_options = max_options self.max_body_bytes = max_body_bytes + #: Extra attempts after a transport error or a status in ``RETRY_STATUSES``; + #: 0 (the default) sends each request once. Attempt ``n`` waits + #: ``retry_backoff * 2**n`` seconds before the next one, as in ``ModelTarget``. + #: A ``Retry-After`` header on a 429 or 503 replaces that wait, capped at + #: ``_RETRY_AFTER_MAX`` (60 s). + self.max_retries = max_retries + self.retry_backoff = retry_backoff + self._reuse_client = reuse_client + self._shared_client: Any | None = None + self._shared_loop: asyncio.AbstractEventLoop | None = None + + def _acquire_client(self) -> tuple[Any, bool]: + """The client for one request and whether the request must close it. + + An injected client is never closed here. With ``reuse_client`` one + client serves every request on the running event loop. Each sync + ``run()`` has its own loop, and a client cannot be used or closed on + another loop, so a client from an earlier loop is dropped and replaced. + """ + if self._client is not None: + return self._client, False + if not self._reuse_client: + return make_client(self.timeout), True + loop = asyncio.get_running_loop() + if self._shared_client is None or self._shared_loop is not loop: + self._shared_client = make_client(self.timeout) + self._shared_loop = loop + return self._shared_client, False + + async def aclose(self) -> None: + """Close the client kept by ``reuse_client``; an injected client is the caller's.""" + client, loop = self._shared_client, self._shared_loop + self._shared_client = self._shared_loop = None + if client is not None and loop is asyncio.get_running_loop(): + await client.aclose() + + async def __aenter__(self) -> DecisionTarget: + return self + + async def __aexit__(self, *exc: Any) -> None: + await self.aclose() + + async def _post(self, payload: bytes, headers: dict[str, str]) -> Any: + """POST ``payload``, retrying transient failures up to ``max_retries`` times.""" + import httpx + + client, owns_client = self._acquire_client() + try: + for attempt in range(self.max_retries + 1): + last = attempt == self.max_retries + delay = self.retry_backoff * 2**attempt + try: + resp = await client.post(self.url, content=payload, headers=headers) + except httpx.TransportError: + if last: + raise + else: + if resp.status_code not in RETRY_STATUSES or last: + return resp + if resp.status_code in RETRY_AFTER_STATUSES: + asked = _retry_after_seconds(resp) + if asked is not None: + delay = min(asked, _RETRY_AFTER_MAX) + await asyncio.sleep(delay) + finally: + if owns_client: + await client.aclose() @classmethod def ollama( @@ -175,21 +275,12 @@ async def send( if context is not None: headers.update(context.trace_headers) - owns_client = self._client is None - if client := self._client: - pass - else: - client = make_client(self.timeout) - try: - resp = await client.post(self.url, content=payload, headers=headers) - if resp.status_code >= 400: - raise RuntimeError( - f"decision endpoint returned HTTP {resp.status_code}: {resp.text[:300]}" - ) - data = resp.json() - finally: - if owns_client: - await client.aclose() + resp = await self._post(payload, headers) + if resp.status_code >= 400: + raise RuntimeError( + f"decision endpoint returned HTTP {resp.status_code}: {resp.text[:300]}" + ) + data = resp.json() answer = (data.get("answers") or {}).get(decision["id"]) if isinstance(data, dict) else None if not isinstance(answer, dict): diff --git a/tests/test_decision_target.py b/tests/test_decision_target.py index 79edcc0..5c5082b 100644 --- a/tests/test_decision_target.py +++ b/tests/test_decision_target.py @@ -265,6 +265,236 @@ def make_client(**kwargs): assert client.is_closed +# --------------------------------------------------------------------------- +# Client reuse and retries +# --------------------------------------------------------------------------- + + +def _count_clients(monkeypatch, handler): + """Patch httpx.AsyncClient to a mock-backed one; returns the list of clients made.""" + real_client = httpx.AsyncClient + created = [] + + def make_client(**kwargs): + client = real_client(transport=httpx.MockTransport(handler)) + created.append(client) + return client + + monkeypatch.setattr(httpx, "AsyncClient", make_client) + return created + + +def _ok(request): + return httpx.Response(200, json={"answers": {"verdict": ANSWER}}) + + +def test_reuse_client_serves_every_request_with_one_client(monkeypatch): + created = _count_clients(monkeypatch, _ok) + target = DecisionTarget("http://decisions.test/v1/systemone", "clef", reuse_client=True) + + async def three_requests(): + for _ in range(3): + await target.send(user="Was he convicted?", context=_context()) + assert not created[0].is_closed + await target.aclose() + + asyncio.run(three_requests()) + assert len(created) == 1 + assert created[0].is_closed + + +def test_reuse_client_replaces_a_client_from_an_earlier_event_loop(monkeypatch): + created = _count_clients(monkeypatch, _ok) + target = DecisionTarget("http://decisions.test/v1/systemone", "clef", reuse_client=True) + _send(target) + _send(target) + assert len(created) == 2 + + +def test_reuse_client_as_context_manager_closes_on_exit(monkeypatch): + created = _count_clients(monkeypatch, _ok) + + async def run(): + async with DecisionTarget( + "http://decisions.test/v1/systemone", "clef", reuse_client=True + ) as target: + await target.send(user="Was he convicted?", context=_context()) + await target.send(user="Was he convicted?", context=_context()) + + asyncio.run(run()) + assert len(created) == 1 + assert created[0].is_closed + + +def test_aclose_leaves_an_injected_client_open(): + client, _ = _endpoint() + target = DecisionTarget( + "http://decisions.test/v1/systemone", "clef", client=client, reuse_client=True + ) + _send(target) + + async def close(): + await target.aclose() + return client.is_closed + + assert asyncio.run(close()) is False + + +def _flaky(statuses): + """Handler answering with ``statuses`` in turn, then with a good answer. + + An entry is a status or a ``(status, headers)`` pair. + """ + seen = [] + + def handler(request): + seen.append(request) + if len(seen) <= len(statuses): + status = statuses[len(seen) - 1] + status, headers = status if isinstance(status, tuple) else (status, {}) + return httpx.Response(status, headers=headers, json={"error": "later"}) + return _ok(request) + + return httpx.AsyncClient(transport=httpx.MockTransport(handler)), seen + + +@pytest.fixture +def sleeps(monkeypatch): + waited = [] + + async def fake_sleep(seconds): + waited.append(seconds) + + monkeypatch.setattr("simpleaudit.targets.decision.asyncio.sleep", fake_sleep) + return waited + + +def test_default_sends_once_and_does_not_retry(sleeps): + client, seen = _flaky([503]) + target = DecisionTarget("http://decisions.test/v1/systemone", "clef", client=client) + with pytest.raises(RuntimeError, match="HTTP 503"): + _send(target) + assert len(seen) == 1 + assert sleeps == [] + + +def test_transient_status_is_retried_with_doubling_backoff(sleeps): + client, seen = _flaky([503, 429]) + target = DecisionTarget( + "http://decisions.test/v1/systemone", "clef", client=client, max_retries=3, retry_backoff=0.5 + ) + assert _send(target).content == "yes: Found guilty" + assert len(seen) == 3 + assert sleeps == [0.5, 1.0] + + +def test_default_backoff_matches_model_target(): + target = DecisionTarget("http://decisions.test/v1/systemone", "clef") + assert (target.max_retries, target.retry_backoff) == (0, 0.5) + + +def _retrying(statuses, **kwargs): + client, seen = _flaky(statuses) + target = DecisionTarget( + "http://decisions.test/v1/systemone", "clef", client=client, max_retries=2, **kwargs + ) + return target, seen + + +def test_retry_after_replaces_the_backoff_on_429_and_503(sleeps): + target, _ = _retrying( + [(429, {"Retry-After": "7"}), (503, {"Retry-After": "3"})], retry_backoff=10 + ) + assert _send(target).content == "yes: Found guilty" + assert sleeps == [7, 3] + + +def test_retry_after_is_used_unchanged_up_to_the_cap(sleeps): + client, _ = _flaky([(429, {"Retry-After": "30"})]) + target = DecisionTarget( + "http://decisions.test/v1/systemone", "clef", client=client, max_retries=1 + ) + _send(target) + assert sleeps == [30] + + +def test_retry_after_http_date_is_read_as_a_delay(sleeps): + from email.utils import format_datetime + from datetime import datetime, timedelta, timezone + + when = format_datetime(datetime.now(timezone.utc) + timedelta(seconds=30), usegmt=True) + target, _ = _retrying([(429, {"Retry-After": when})], retry_backoff=100) + _send(target) + assert 25 <= sleeps[0] <= 30 + + +def test_retry_after_in_the_past_waits_no_time(sleeps): + target, _ = _retrying([(503, {"Retry-After": "Wed, 21 Oct 2015 07:28:00 GMT"})], retry_backoff=10) + _send(target) + assert sleeps == [0.0] + + +def test_retry_after_is_capped_at_60_seconds(sleeps): + client, _ = _flaky([(429, {"Retry-After": "120"})]) + target = DecisionTarget( + "http://decisions.test/v1/systemone", "clef", client=client, max_retries=1 + ) + _send(target) + assert sleeps == [60.0] + + +def test_unreadable_retry_after_and_other_statuses_use_the_backoff(sleeps): + target, _ = _retrying( + [(429, {"Retry-After": "soon"}), (502, {"Retry-After": "1"})], retry_backoff=4 + ) + _send(target) + assert sleeps == [4, 8] + + +def test_retries_stop_at_the_cap_and_report_the_last_status(sleeps): + client, seen = _flaky([502, 502, 502, 502]) + target = DecisionTarget( + "http://decisions.test/v1/systemone", "clef", client=client, max_retries=2 + ) + with pytest.raises(RuntimeError, match="HTTP 502"): + _send(target) + assert len(seen) == 3 + assert len(sleeps) == 2 + + +def test_a_client_error_is_not_retried(sleeps): + client, seen = _flaky([400]) + target = DecisionTarget( + "http://decisions.test/v1/systemone", "clef", client=client, max_retries=3 + ) + with pytest.raises(RuntimeError, match="HTTP 400"): + _send(target) + assert len(seen) == 1 + + +def test_transport_errors_are_retried_then_raised(sleeps): + attempts = [] + + def handler(request): + attempts.append(request) + raise httpx.ConnectError("refused", request=request) + + client = httpx.AsyncClient(transport=httpx.MockTransport(handler)) + target = DecisionTarget( + "http://decisions.test/v1/systemone", "clef", client=client, max_retries=2 + ) + with pytest.raises(httpx.ConnectError): + _send(target) + assert len(attempts) == 3 + + +def test_retry_settings_are_validated(): + with pytest.raises(ValueError, match="max_retries"): + DecisionTarget("http://decisions.test/v1/systemone", "clef", max_retries=-1) + with pytest.raises(ValueError, match="retry_backoff"): + DecisionTarget("http://decisions.test/v1/systemone", "clef", retry_backoff=-1) + + # --------------------------------------------------------------------------- # Constructors # ---------------------------------------------------------------------------