Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
23 changes: 23 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
42 changes: 41 additions & 1 deletion blockrun_llm/apikey.py
Original file line number Diff line number Diff line change
Expand Up @@ -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_``.
Expand Down Expand Up @@ -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",
Expand All @@ -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",
Expand Down
41 changes: 37 additions & 4 deletions blockrun_llm/client.py
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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

Expand Down Expand Up @@ -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

Expand Down Expand Up @@ -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

Expand Down Expand Up @@ -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

Expand Down Expand Up @@ -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

Expand Down Expand Up @@ -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

Expand Down Expand Up @@ -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

Expand Down Expand Up @@ -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

Expand Down
45 changes: 41 additions & 4 deletions blockrun_llm/solana_client.py
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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

Expand Down Expand Up @@ -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

Expand Down Expand Up @@ -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

Expand Down Expand Up @@ -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

Expand Down Expand Up @@ -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)
Expand Down Expand Up @@ -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

Expand Down Expand Up @@ -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

Expand Down Expand Up @@ -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)
Expand Down Expand Up @@ -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(
Expand Down Expand Up @@ -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(
Expand Down
Loading
Loading