diff --git a/CHANGELOG.md b/CHANGELOG.md index e5bb029..2c28dd4 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,29 @@ All notable changes to blockrun-llm will be documented in this file. +## Unreleased + +### Fixed +- **A 502/503 on the account rail is no longer replayed, so it can no longer + bill twice.** With an API key there is no unsigned probe: the first request + carries the key and is the billed one. The raw paid helpers still retried it + once on a 502/503 (and the stream probe up to three times on any 5xx), so a + call the gateway had already accepted could be charged again. + - Affected: `search`, `exa` / `exa_search` / `exa_contents` / + `exa_find_similar` / `exa_answer`, `pm` / `pm_query`, `defi`, `dex`, + `modal`, `rpc` / `rpc_batch`, `price` / `price_history` / `list_symbols`, + `portrait_enroll`, `realface_enroll`, `chat` / `chat_completion` and + `chat_completion_stream` on `SolanaLLMClient`, plus the same surfaces + (`search`, Exa, `pm`, `defi`, `dex`, `modal`, `image_edit`, chat and + streaming) on the Base `LLMClient`. Sync and async. + - Any 5xx on the account rail now raises `APIError` at once, keeping its + status code and `Retry-After`, with a message saying the request may + already have been accepted and billed and that retrying may bill it + again. The Solana `image()` / `video()` / audio submit, which already + skipped the replay, now says the same. + - The wallet rail is unchanged: its first request is an unsigned probe that + costs nothing, so a 502/503 there is still retried once. + ## 1.18.0 — 2026-10-02 ### Added diff --git a/blockrun_llm/apikey.py b/blockrun_llm/apikey.py index 2809105..c23dfe8 100644 --- a/blockrun_llm/apikey.py +++ b/blockrun_llm/apikey.py @@ -26,7 +26,8 @@ from typing import Any from urllib.parse import urlsplit -from .types import APIError, PaymentError +from .types import APIError, PaymentError, retry_after_of +from .validation import sanitize_error_response #: Prefix every BlockRun API key carries. It is what lets one credential #: parameter accept either kind: a hex private key can never start with ``brk_``. @@ -245,6 +246,43 @@ def raise_for_api_key_402(response: Any, api_key: str | None) -> None: raise api_key_payment_error(body) +def api_key_unconfirmed_error(response: Any) -> APIError: + """Explain a 5xx that arrived on the account rail, where it is not retried. + + On the x402 rail the first POST is an unsigned probe: it costs nothing, so a + 502/503 there can be replayed. On this rail there is no probe. The first POST + carries the key and IS the billed request, and a 5xx can arrive after the + gateway accepted it and drew down credit (an upstream failing mid-call, a + proxy timing out a request that is still running). Replaying it is a second + charge for one call, so the error says that instead of doing it. + """ + try: + error_body = response.json() + except Exception: + error_body = {"error": "Request failed"} + return APIError( + f"API error: {response.status_code} from api.blockrun.ai. The request may " + "already have been accepted and billed to this account, so it was not " + "retried automatically; retrying may bill it again. Check usage at " + "https://user.blockrun.ai/dashboard before retrying.", + response.status_code, + sanitize_error_response(error_body), + retry_after=retry_after_of(response), + ) + + +def raise_for_api_key_5xx(response: Any, api_key: str | None) -> None: + """Refuse to replay an account-rail request that answered 5xx. + + Every request site that auto-retries a 502/503 calls this first, so the + retry survives only where it is free: the wallet rail's unsigned probe. + A no-op on the x402 rail and for any non-5xx status. + """ + if not api_key or response.status_code < 500: + return + raise api_key_unconfirmed_error(response) + + __all__ = [ "API_KEY_PREFIX", "DEFAULT_API_KEY_URL", @@ -255,11 +293,13 @@ def raise_for_api_key_402(response: Any, api_key: str | None) -> None: "APIError", "api_key_base_url", "api_key_payment_error", + "api_key_unconfirmed_error", "auth_headers", "configure_credential", "is_api_key", "missing_credential_error", "payment_mode", + "raise_for_api_key_5xx", "raise_for_api_key_402", "resolve_api_key", "resolve_poll_url", diff --git a/blockrun_llm/client.py b/blockrun_llm/client.py index 56cf4ea..0f84d02 100644 --- a/blockrun_llm/client.py +++ b/blockrun_llm/client.py @@ -55,6 +55,7 @@ auth_headers, missing_credential_error, payment_mode, + raise_for_api_key_5xx, raise_for_api_key_402, resolve_api_key, wallet_only, @@ -1139,6 +1140,10 @@ def _stream_with_payment( raise_for_api_key_402(resp1, self.api_key) payment_headers, cost_usd = self._sign_payment_from_response(body, resp1) break # advance to phase 2 + # Account rail: this request carried the key and was the billed one, + # so a 5xx is never replayed (it could bill twice). Only the wallet + # rail's unsigned probe reaches the retry below. + raise_for_api_key_5xx(resp1, self.api_key) if resp1.status_code in self._STREAM_5XX_STATUSES and attempt < len(backoffs): import time @@ -1406,7 +1411,11 @@ def _request_with_payment(self, endpoint: str, body: dict[str, Any]) -> ChatResp # First attempt (will likely return 402) response = self._client.post(url, json=body, headers=req_headers) - # Auto-retry on transient server errors + # Account rail: this request carried the key and was the billed one, + # so a 5xx is never replayed (it could bill twice). Only the wallet + # rail's unsigned probe reaches the retry below. + raise_for_api_key_5xx(response, self.api_key) + # Auto-retry on transient server errors (wallet rail only, per above) if response.status_code in (502, 503): import time @@ -1609,7 +1618,11 @@ def _request_with_payment_raw(self, endpoint: str, body: dict[str, Any]) -> dict response = self._client.post(url, json=body, headers=req_headers) - # Auto-retry on transient server errors + # Account rail: this request carried the key and was the billed one, + # so a 5xx is never replayed (it could bill twice). Only the wallet + # rail's unsigned probe reaches the retry below. + raise_for_api_key_5xx(response, self.api_key) + # Auto-retry on transient server errors (wallet rail only, per above) if response.status_code in (502, 503): import time @@ -1766,6 +1779,10 @@ def _get_with_payment_raw( response = self._client.get(url, params=params, headers=req_headers) + # Account rail: this request carried the key and was the billed one, + # so a 5xx is never replayed (it could bill twice). Only the wallet + # rail's unsigned probe reaches the retry below. + raise_for_api_key_5xx(response, self.api_key) if response.status_code in (502, 503): import time @@ -3144,6 +3161,10 @@ async def _stream_with_payment( raise_for_api_key_402(resp1, self.api_key) payment_headers, cost_usd = self._sign_payment_from_response(body, resp1) break + # Account rail: this request carried the key and was the billed one, + # so a 5xx is never replayed (it could bill twice). Only the wallet + # rail's unsigned probe reaches the retry below. + raise_for_api_key_5xx(resp1, self.api_key) if resp1.status_code in statuses_5xx and attempt < len(backoffs): import asyncio @@ -3316,7 +3337,11 @@ async def _request_with_payment(self, endpoint: str, body: dict[str, Any]) -> Ch response = await self._client.post(url, json=body, headers=req_headers) - # Auto-retry on transient server errors + # Account rail: this request carried the key and was the billed one, + # so a 5xx is never replayed (it could bill twice). Only the wallet + # rail's unsigned probe reaches the retry below. + raise_for_api_key_5xx(response, self.api_key) + # Auto-retry on transient server errors (wallet rail only, per above) if response.status_code in (502, 503): import asyncio @@ -3499,7 +3524,11 @@ async def _request_with_payment_raw( response = await self._client.post(url, json=body, headers=req_headers) - # Auto-retry on transient server errors + # Account rail: this request carried the key and was the billed one, + # so a 5xx is never replayed (it could bill twice). Only the wallet + # rail's unsigned probe reaches the retry below. + raise_for_api_key_5xx(response, self.api_key) + # Auto-retry on transient server errors (wallet rail only, per above) if response.status_code in (502, 503): import asyncio @@ -3639,6 +3668,10 @@ async def _get_with_payment_raw( response = await self._client.get(url, params=params, headers=req_headers) + # Account rail: this request carried the key and was the billed one, + # so a 5xx is never replayed (it could bill twice). Only the wallet + # rail's unsigned probe reaches the retry below. + raise_for_api_key_5xx(response, self.api_key) if response.status_code in (502, 503): import asyncio diff --git a/blockrun_llm/solana_client.py b/blockrun_llm/solana_client.py index d43060d..c0fbef8 100644 --- a/blockrun_llm/solana_client.py +++ b/blockrun_llm/solana_client.py @@ -34,6 +34,7 @@ auth_headers, missing_credential_error, payment_mode, + raise_for_api_key_5xx, raise_for_api_key_402, resolve_api_key, wallet_only, @@ -1307,6 +1308,10 @@ def _stream_once( raise_for_api_key_402(resp1, self.api_key) payment_headers, cost_usd = self._sign_payment_from_response(resp1) break + # Account rail: this request carried the key and was the billed one, + # so a 5xx is never replayed (it could bill twice). Only the wallet + # rail's unsigned probe reaches the retry below. + raise_for_api_key_5xx(resp1, self.api_key) if resp1.status_code in self._STREAM_5XX_STATUSES and attempt < len(backoffs): import time @@ -1531,7 +1536,11 @@ def _request_once( response = self._client.post(url, json=body, headers=headers, timeout=eff_timeout) - # Auto-retry on transient server errors + # Account rail: this request carried the key and was the billed one, + # so a 5xx is never replayed (it could bill twice). Only the wallet + # rail's unsigned probe reaches the retry below. + raise_for_api_key_5xx(response, self.api_key) + # Auto-retry on transient server errors (wallet rail only, per above) if response.status_code in (502, 503): import time @@ -1685,7 +1694,11 @@ def _request_with_payment_raw_once( response = self._client.post(url, json=body, headers=headers, timeout=eff_timeout) - # Auto-retry on transient server errors + # Account rail: this request carried the key and was the billed one, + # so a 5xx is never replayed (it could bill twice). Only the wallet + # rail's unsigned probe reaches the retry below. + raise_for_api_key_5xx(response, self.api_key) + # Auto-retry on transient server errors (wallet rail only, per above) if response.status_code in (502, 503): import time @@ -1841,6 +1854,10 @@ def _get_with_payment_raw_once( response = self._client.get(url, params=params, headers=headers, timeout=eff_timeout) + # Account rail: this request carried the key and was the billed one, + # so a 5xx is never replayed (it could bill twice). Only the wallet + # rail's unsigned probe reaches the retry below. + raise_for_api_key_5xx(response, self.api_key) if response.status_code in (502, 503): import time @@ -2032,7 +2049,9 @@ def _request_image_with_payment( probe = self._client.post(url, json=body, headers=probe_headers, timeout=eff_timeout) # Not on the account rail: there the first POST is the billed submit # (the key IS the payment), and a 502 can arrive after the charge, so - # replaying it could bill the job twice. Mirrors the Base VideoClient. + # replaying it could bill the job twice; it raises instead, saying so. + # Mirrors the Base VideoClient. + raise_for_api_key_5xx(probe, self.api_key) if probe.status_code in (502, 503) and not self.api_key: _time.sleep(1) probe = self._client.post(url, json=body, headers=probe_headers, timeout=eff_timeout) @@ -4061,6 +4080,10 @@ async def _stream_once( raise_for_api_key_402(resp1, self.api_key) payment_headers, cost_usd = await self._sign_payment_from_response(resp1) break + # Account rail: this request carried the key and was the billed one, + # so a 5xx is never replayed (it could bill twice). Only the wallet + # rail's unsigned probe reaches the retry below. + raise_for_api_key_5xx(resp1, self.api_key) if resp1.status_code in self._STREAM_5XX_STATUSES and attempt < len(backoffs): import asyncio @@ -4253,6 +4276,10 @@ async def _request_once( eff_timeout = timeout if timeout is not None else self._timeout response = await self._client.post(url, json=body, headers=headers, timeout=eff_timeout) + # Account rail: this request carried the key and was the billed one, + # so a 5xx is never replayed (it could bill twice). Only the wallet + # rail's unsigned probe reaches the retry below. + raise_for_api_key_5xx(response, self.api_key) if response.status_code in (502, 503): import asyncio @@ -4384,6 +4411,10 @@ async def _request_with_payment_raw_once( eff_timeout = timeout if timeout is not None else self._timeout response = await self._client.post(url, json=body, headers=headers, timeout=eff_timeout) + # Account rail: this request carried the key and was the billed one, + # so a 5xx is never replayed (it could bill twice). Only the wallet + # rail's unsigned probe reaches the retry below. + raise_for_api_key_5xx(response, self.api_key) if response.status_code in (502, 503): await asyncio.sleep(1) response = await self._client.post(url, json=body, headers=headers, timeout=eff_timeout) @@ -4481,6 +4512,10 @@ async def _get_with_payment_raw_once( eff_timeout = timeout if timeout is not None else self._timeout response = await self._client.get(url, params=params, headers=headers, timeout=eff_timeout) + # Account rail: this request carried the key and was the billed one, + # so a 5xx is never replayed (it could bill twice). Only the wallet + # rail's unsigned probe reaches the retry below. + raise_for_api_key_5xx(response, self.api_key) if response.status_code in (502, 503): await asyncio.sleep(1) response = await self._client.get( @@ -5213,7 +5248,9 @@ async def _request_image_with_payment( probe = await self._client.post(url, json=body, headers=probe_headers, timeout=eff_timeout) # Not on the account rail: there the first POST is the billed submit # (the key IS the payment), and a 502 can arrive after the charge, so - # replaying it could bill the job twice. Mirrors the Base VideoClient. + # replaying it could bill the job twice; it raises instead, saying so. + # Mirrors the Base VideoClient. + raise_for_api_key_5xx(probe, self.api_key) if probe.status_code in (502, 503) and not self.api_key: await asyncio.sleep(1) probe = await self._client.post( diff --git a/tests/unit/test_apikey.py b/tests/unit/test_apikey.py index 0de790b..623de72 100644 --- a/tests/unit/test_apikey.py +++ b/tests/unit/test_apikey.py @@ -10,7 +10,7 @@ import httpx import pytest -from blockrun_llm import LLMClient +from blockrun_llm import AsyncLLMClient, LLMClient from blockrun_llm.apikey import ( DEFAULT_API_KEY_URL, ENV_API_KEY, @@ -20,11 +20,12 @@ api_key_base_url, auth_headers, is_api_key, + raise_for_api_key_5xx, resolve_api_key, resolve_poll_url, ) from blockrun_llm.image import ImageClient -from blockrun_llm.types import PaymentError +from blockrun_llm.types import APIError, PaymentError API_KEY = "brk_live_TESTKEYTESTKEYTESTKEY" WALLET_KEY = "0xac0974bec39a17e36ba4a6b4d238ff944bacb478cbed5efcae784d7bf4f2ff80" @@ -314,3 +315,123 @@ def test_rotating_env_only_affects_new_clients(monkeypatch): monkeypatch.delenv(ENV_API_KEY) with LLMClient(private_key=WALLET_KEY) as wallet: assert "authorization" not in wallet._client.headers + + +# --------------------------------------------------------------------------- +# No 5xx replay on the account rail. The first request carries the key and is +# the billed one, so the wallet rail's "retry the probe once on 502/503" is a +# second charge here. Every Base helper that has that retry must refuse it. +# --------------------------------------------------------------------------- + + +def _counting(requests: list, status: int): + def handler(request: httpx.Request) -> httpx.Response: + requests.append(request) + return httpx.Response(status, json={"error": "upstream"}) + + return handler + + +_BASE_ACCOUNT_CALLS = { + "search": lambda c: c.search("q"), + "exa_search": lambda c: c.exa_search("q"), + "pm_get": lambda c: c.pm("polymarket/markets"), + "pm_query": lambda c: c.pm_query("polymarket/markets", {"limit": 1}), + "chat": lambda c: c.chat("openai/gpt-4o", "2+2?"), + "chat_stream": lambda c: list( + c.chat_completion_stream("openai/gpt-4o", [{"role": "user", "content": "hi"}]) + ), +} + + +async def _adrain(agen) -> list: + return [chunk async for chunk in agen] + + +_ASYNC_BASE_ACCOUNT_CALLS = { + "search": lambda c: c.search("q"), + "pm_get": lambda c: c.pm("polymarket/markets"), + "pm_query": lambda c: c.pm_query("polymarket/markets", {"limit": 1}), + "chat": lambda c: c.chat("openai/gpt-4o", "2+2?"), + "chat_stream": lambda c: _adrain( + c.chat_completion_stream("openai/gpt-4o", [{"role": "user", "content": "hi"}]) + ), +} + + +class TestNoAccountRail5xxReplay: + @pytest.fixture(autouse=True) + def _no_sleep(self, monkeypatch): + async def _anoop(_s): + return None + + monkeypatch.setattr("time.sleep", lambda _s: None) + monkeypatch.setattr("asyncio.sleep", _anoop) + + @pytest.mark.parametrize("status", [502, 503]) + @pytest.mark.parametrize("call", sorted(_BASE_ACCOUNT_CALLS)) + def test_billed_first_request_is_never_replayed(self, call, status): + requests: list = [] + client = LLMClient(private_key=API_KEY) + client._client = httpx.Client( + transport=httpx.MockTransport(_counting(requests, status)), + headers=auth_headers(API_KEY), + ) + with pytest.raises(APIError) as exc: + _BASE_ACCOUNT_CALLS[call](client) + assert len(requests) == 1 + assert exc.value.status_code == status + assert "may already have been accepted and billed" in str(exc.value) + assert "retrying may bill it again" in str(exc.value) + + @pytest.mark.parametrize("status", [502, 503]) + @pytest.mark.parametrize("call", sorted(_ASYNC_BASE_ACCOUNT_CALLS)) + async def test_async_billed_first_request_is_never_replayed(self, call, status): + requests: list = [] + client = AsyncLLMClient(private_key=API_KEY) + await client._client.aclose() + client._client = httpx.AsyncClient( + transport=httpx.MockTransport(_counting(requests, status)), + headers=auth_headers(API_KEY), + ) + try: + with pytest.raises(APIError) as exc: + await _ASYNC_BASE_ACCOUNT_CALLS[call](client) + assert len(requests) == 1 + assert exc.value.status_code == status + assert "may already have been accepted and billed" in str(exc.value) + finally: + await client._client.aclose() + + def test_wallet_rail_probe_is_still_retried(self): + """The unsigned probe costs nothing, so the wallet rail keeps its retry.""" + requests: list = [] + + def handler(request: httpx.Request) -> httpx.Response: + requests.append(request) + if len(requests) == 1: + return httpx.Response(502, json={"error": "upstream"}) + return httpx.Response(200, json={"query": "q", "summary": "s"}) + + client = LLMClient(private_key=WALLET_KEY) + client._client = httpx.Client(transport=httpx.MockTransport(handler)) + assert client.search("q").summary == "s" + assert len(requests) == 2 + + +class TestRaiseForApiKey5xx: + @pytest.mark.parametrize("status", [200, 202, 402, 429]) + def test_non_5xx_is_a_no_op(self, status): + raise_for_api_key_5xx(httpx.Response(status), API_KEY) + + def test_wallet_rail_is_a_no_op(self): + raise_for_api_key_5xx(httpx.Response(502), None) + + @pytest.mark.parametrize("status", [500, 502, 503, 504]) + def test_account_rail_5xx_raises_with_status_and_retry_after(self, status): + resp = httpx.Response(status, json={"error": "upstream"}, headers={"retry-after": "7"}) + with pytest.raises(APIError) as exc: + raise_for_api_key_5xx(resp, API_KEY) + assert exc.value.status_code == status + assert exc.value.retry_after == "7" + assert "user.blockrun.ai/dashboard" in str(exc.value) diff --git a/tests/unit/test_solana_media.py b/tests/unit/test_solana_media.py index c6d48be..7c8a974 100644 --- a/tests/unit/test_solana_media.py +++ b/tests/unit/test_solana_media.py @@ -1178,3 +1178,162 @@ def test_video_foreign_poll_origin_is_refused_without_a_request(self) -> None: with pytest.raises(APIError, match="different polling origin"): client.video("x", model="bytedance/seedance-2.0") assert polls == [] + + +# --------------------------------------------------------------------------- +# Account rail, non-media endpoints. search, Exa, pm, chat and the stream probe +# still go through the raw helpers, whose first request carries the key and is +# the billed one. A 502/503 there used to be replayed once, which on this rail +# is a second charge. It must raise instead, saying the call may be billed. +# --------------------------------------------------------------------------- + +_SEARCH_OK = {"query": "q", "summary": "s"} +_CHAT_OK = { + "id": "x", + "object": "chat.completion", + "created": 1, + "model": "openai/gpt-4o", + "choices": [ + {"index": 0, "message": {"role": "assistant", "content": "4"}, "finish_reason": "stop"} + ], +} + + +def _counting_handler(requests: list[httpx.Request], status: int): + def handler(request: httpx.Request) -> httpx.Response: + requests.append(request) + return httpx.Response(status, json={"error": "upstream"}) + + return handler + + +_SOLANA_ACCOUNT_CALLS = { + "search": lambda c: c.search("q"), + "exa_search": lambda c: c.exa_search("q"), + "exa": lambda c: c.exa("contents", {"urls": ["https://example.com"]}), + "pm_get": lambda c: c.pm("polymarket/markets"), + "pm_query": lambda c: c.pm_query("polymarket/markets", {"limit": 1}), + "chat": lambda c: c.chat("openai/gpt-4o", "2+2?"), + "chat_stream": lambda c: list( + c.chat_completion_stream("openai/gpt-4o", [{"role": "user", "content": "hi"}]) + ), +} + + +async def _drain(agen: Any) -> list[Any]: + return [chunk async for chunk in agen] + + +_ASYNC_SOLANA_ACCOUNT_CALLS = { + "search": lambda c: c.search("q"), + "exa_search": lambda c: c.exa_search("q"), + "exa": lambda c: c.exa("contents", {"urls": ["https://example.com"]}), + "pm_get": lambda c: c.pm("polymarket/markets"), + "pm_query": lambda c: c.pm_query("polymarket/markets", {"limit": 1}), + "chat": lambda c: c.chat("openai/gpt-4o", "2+2?"), + "chat_stream": lambda c: _drain( + c.chat_completion_stream("openai/gpt-4o", [{"role": "user", "content": "hi"}]) + ), +} + + +class TestSolanaAccountRailNoReplay: + @pytest.fixture(autouse=True) + def _no_sleep(self, monkeypatch: pytest.MonkeyPatch) -> None: + # If a replay sneaks back in, fail on the count, not on a slow sleep. + monkeypatch.setattr("time.sleep", lambda _s: None) + + @pytest.mark.parametrize("status", [502, 503]) + @pytest.mark.parametrize("call", sorted(_SOLANA_ACCOUNT_CALLS)) + def test_billed_first_request_is_never_replayed(self, call: str, status: int) -> None: + requests: list[httpx.Request] = [] + client = _account_client(_counting_handler(requests, status)) + with pytest.raises(APIError) as exc: + _SOLANA_ACCOUNT_CALLS[call](client) + assert len(requests) == 1 + assert exc.value.status_code == status + assert "may already have been accepted and billed" in str(exc.value) + assert "retrying may bill it again" in str(exc.value) + assert all("PAYMENT-SIGNATURE" not in r.headers for r in requests) + + @pytest.mark.parametrize("status", [502, 503]) + @pytest.mark.parametrize("call", sorted(_ASYNC_SOLANA_ACCOUNT_CALLS)) + async def test_async_billed_first_request_is_never_replayed( + self, call: str, status: int + ) -> None: + requests: list[httpx.Request] = [] + client = _async_account_client(_counting_handler(requests, status)) + try: + with pytest.raises(APIError) as exc: + await _ASYNC_SOLANA_ACCOUNT_CALLS[call](client) + assert len(requests) == 1 + assert exc.value.status_code == status + assert "may already have been accepted and billed" in str(exc.value) + finally: + await client._client.aclose() + + def test_image_probe_5xx_says_it_may_be_billed(self) -> None: + requests: list[httpx.Request] = [] + client = _account_client(_counting_handler(requests, 502)) + with pytest.raises(APIError, match="may already have been accepted and billed"): + client.image("a cat", model="openai/gpt-image-2") + assert len(requests) == 1 + + def test_account_success_still_one_request(self) -> None: + requests: list[httpx.Request] = [] + + def handler(request: httpx.Request) -> httpx.Response: + requests.append(request) + return httpx.Response(200, json=_SEARCH_OK) + + client = _account_client(handler) + assert client.search("q").summary == "s" + assert len(requests) == 1 + + +def _wallet_probe_retry_handler(unsigned: list[httpx.Request], signed: list[httpx.Request]): + """First unsigned probe 502s, the replayed probe gets the 402, the signed + request succeeds: the wallet rail's free probe retry, which must survive.""" + + def handler(request: httpx.Request) -> httpx.Response: + if "PAYMENT-SIGNATURE" in request.headers: + signed.append(request) + return httpx.Response(200, json=_SEARCH_OK) + unsigned.append(request) + if len(unsigned) == 1: + return httpx.Response(502, json={"error": "upstream"}) + return httpx.Response( + 402, + headers={"content-type": "application/json", "payment-required": "stub"}, + json={"error": "Payment Required"}, + ) + + return handler + + +class TestSolanaWalletProbeRetryKept: + def test_unsigned_probe_is_still_retried_on_502(self, monkeypatch: pytest.MonkeyPatch) -> None: + monkeypatch.setattr("time.sleep", lambda _s: None) + unsigned: list[httpx.Request] = [] + signed: list[httpx.Request] = [] + client = _make_client(_wallet_probe_retry_handler(unsigned, signed)) + assert client.search("q").summary == "s" + assert len(unsigned) == 2 + assert len(signed) == 1 + + async def test_async_unsigned_probe_is_still_retried_on_502( + self, monkeypatch: pytest.MonkeyPatch + ) -> None: + async def _no_sleep(_s: float) -> None: + return None + + monkeypatch.setattr("blockrun_llm.solana_client.asyncio.sleep", _no_sleep) + unsigned: list[httpx.Request] = [] + signed: list[httpx.Request] = [] + client = _make_async_client(_wallet_probe_retry_handler(unsigned, signed)) + try: + assert (await client.search("q")).summary == "s" + assert len(unsigned) == 2 + assert len(signed) == 1 + finally: + await client._client.aclose()