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
2 changes: 2 additions & 0 deletions fastspec/_modidx.py
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,8 @@
'fastspec.errors.api_error_from_event': ('errors.html#api_error_from_event', 'fastspec/errors.py'),
'fastspec.errors.httpx.HTTPStatusError.api_error': ( 'errors.html#httpx.httpstatuserror.api_error',
'fastspec/errors.py'),
'fastspec.errors.httpx.RequestError.api_error': ( 'errors.html#httpx.requesterror.api_error',
'fastspec/errors.py'),
'fastspec.errors.httpx.Response.error': ('errors.html#httpx.response.error', 'fastspec/errors.py')},
'fastspec.oapi': { 'fastspec.oapi.OpFunc': ('oapi.html#opfunc', 'fastspec/oapi.py'),
'fastspec.oapi.OpFunc.__call__': ('oapi.html#opfunc.__call__', 'fastspec/oapi.py'),
Expand Down
13 changes: 12 additions & 1 deletion fastspec/errors.py
Original file line number Diff line number Diff line change
Expand Up @@ -41,7 +41,7 @@ def _retryable(status_code, error_type, code, message):
hints = ("server_error", "internal", "overload", "rate_limit", "timeout", "unavailable", "temporar")
if any(h in t for h in hints): return True
if any(h in c for h in hints): return True
if any(h in m for h in ("try again", "server error", "temporar", "timeout", "overloaded", "unavailable")): return True
if any(h in m for h in ("try again", "server error", "temporar", "timeout", "overloaded", "unavailable", "rate limit")): return True
return False

# %% ../nbs/00_errors.ipynb #9c1cdf65
Expand Down Expand Up @@ -160,6 +160,17 @@ def api_error(self:httpx.HTTPStatusError, *, provider: str = "", model: str = ""
raw=err.raw,
)

# %% ../nbs/00_errors.ipynb #859197d5
@patch
def api_error(self:httpx.RequestError, *, provider:str="", model:str="", endpoint:str=""):
"Build APIError from httpx RequestError (transport-level failure)."
req = getattr(self, '_request', None)
if not endpoint and req is not None: endpoint = f"{req.method.upper()} {req.url.path}"
et = type(self).__name__
retry = isinstance(self, (httpx.TimeoutException, httpx.NetworkError, httpx.ProtocolError, httpx.ProxyError))
return APIError(str(self) or et, provider=provider, model=model, endpoint=endpoint,
error_type=et, code=et, retryable=retry, raw=self)

# %% ../nbs/00_errors.ipynb #e6e2cc84
def api_error_from_event(event, *, provider: str = "", model: str = "", endpoint: str = ""):
"Build APIError from provider SSE/event-level error payload."
Expand Down
6 changes: 4 additions & 2 deletions fastspec/oapi.py
Original file line number Diff line number Diff line change
Expand Up @@ -154,13 +154,15 @@ def _join_url(base, path):

# %% ../nbs/04_oapi.ipynb #ca48c44b
@patch
def _raise_with_context(self:OpFunc, exc: Exception, *, endpoint: str, route: Optional[dict], query: Optional[dict], body: Optional[dict]):
def _raise_with_context(self:OpFunc, exc:Exception, *, endpoint:str, route:Optional[dict], query:Optional[dict], body:Optional[dict]):
"Raise APIError with operation context for dynamic op calls."
provider,model,ep = '','',''
# TODO: Make APIError generic, users can modify/subclass it include additional info like model,provider etc..
if isinstance(exc, httpx.HTTPStatusError): raise exc.api_error(provider=provider, model=model) from exc
if isinstance(exc, (httpx.HTTPStatusError, httpx.RequestError)):
raise exc.api_error(provider=provider, model=model) from exc
raise exc


# %% ../nbs/04_oapi.ipynb #c7c96f87
@patch
@delegates(AsyncTransport.request) # files, raw
Expand Down
177 changes: 157 additions & 20 deletions nbs/00_errors.ipynb
Original file line number Diff line number Diff line change
Expand Up @@ -57,7 +57,22 @@
"execution_count": null,
"id": "bff800e1",
"metadata": {},
"outputs": [],
"outputs": [
{
"name": "stderr",
"output_type": "stream",
"text": [
"\u001b[92m17:03:50 - LiteLLM:WARNING\u001b[0m: common_utils.py:979 - litellm: could not pre-load bedrock-runtime response stream shape — Bedrock event-stream decoding will be unavailable. Error: No module named 'botocore'\n"
]
},
{
"name": "stderr",
"output_type": "stream",
"text": [
"\u001b[92m17:03:50 - LiteLLM:WARNING\u001b[0m: common_utils.py:24 - litellm: could not pre-load sagemaker-runtime response stream shape — SageMaker event-stream decoding will be unavailable. Error: No module named 'botocore'\n"
]
}
],
"source": [
"enable_cachy()"
]
Expand Down Expand Up @@ -181,7 +196,7 @@
" hints = (\"server_error\", \"internal\", \"overload\", \"rate_limit\", \"timeout\", \"unavailable\", \"temporar\")\n",
" if any(h in t for h in hints): return True\n",
" if any(h in c for h in hints): return True\n",
" if any(h in m for h in (\"try again\", \"server error\", \"temporar\", \"timeout\", \"overloaded\", \"unavailable\")): return True\n",
" if any(h in m for h in (\"try again\", \"server error\", \"temporar\", \"timeout\", \"overloaded\", \"unavailable\", \"rate limit\")): return True\n",
" return False"
]
},
Expand Down Expand Up @@ -313,6 +328,8 @@
{
"data": {
"text/markdown": [
"<div class=\"prose\" markdown=\"1\">\n",
"\n",
"```python\n",
"{ 'code': 'invalid_api_key',\n",
" 'message': 'Incorrect API key provided: sk-fake123. You can find your API '\n",
Expand All @@ -325,7 +342,9 @@
" 'type': 'invalid_request_error'},\n",
" 'status': 401},\n",
" 'type': 'invalid_request_error'}\n",
"```"
"```\n",
"\n",
"</div>"
],
"text/plain": [
"{'message': 'Incorrect API key provided: sk-fake123. You can find your API key at https://platform.openai.com/account/api-keys.',\n",
Expand Down Expand Up @@ -358,7 +377,7 @@
"text/plain": [
"{'type': 'error',\n",
" 'error': {'type': 'authentication_error', 'message': 'invalid x-api-key'},\n",
" 'request_id': 'req_011CZawgT2EDaKt1J84fwjww'}"
" 'request_id': 'req_011CbKdGZGdgRVbdV78kxmXS'}"
]
},
"execution_count": null,
Expand All @@ -384,23 +403,27 @@
{
"data": {
"text/markdown": [
"<div class=\"prose\" markdown=\"1\">\n",
"\n",
"```python\n",
"{ 'code': 'authentication_error',\n",
" 'message': 'invalid x-api-key',\n",
" 'raw': { 'error': { 'message': 'invalid x-api-key',\n",
" 'type': 'authentication_error'},\n",
" 'request_id': 'req_011CZawgT2EDaKt1J84fwjww',\n",
" 'request_id': 'req_011CbKdGZGdgRVbdV78kxmXS',\n",
" 'type': 'error'},\n",
" 'type': 'authentication_error'}\n",
"```"
"```\n",
"\n",
"</div>"
],
"text/plain": [
"{'message': 'invalid x-api-key',\n",
" 'type': 'authentication_error',\n",
" 'code': 'authentication_error',\n",
" 'raw': {'type': 'error',\n",
" 'error': {'type': 'authentication_error', 'message': 'invalid x-api-key'},\n",
" 'request_id': 'req_011CZawgT2EDaKt1J84fwjww'}}"
" 'request_id': 'req_011CbKdGZGdgRVbdV78kxmXS'}}"
]
},
"execution_count": null,
Expand Down Expand Up @@ -455,6 +478,8 @@
{
"data": {
"text/markdown": [
"<div class=\"prose\" markdown=\"1\">\n",
"\n",
"```python\n",
"{ 'code': 'INVALID_ARGUMENT',\n",
" 'message': 'API key not valid. Please pass a valid API key.',\n",
Expand All @@ -471,7 +496,9 @@
" 'key.',\n",
" 'status': 'INVALID_ARGUMENT'}},\n",
" 'type': 'INVALID_ARGUMENT'}\n",
"```"
"```\n",
"\n",
"</div>"
],
"text/plain": [
"{'message': 'API key not valid. Please pass a valid API key.',\n",
Expand Down Expand Up @@ -554,15 +581,9 @@
}
],
"source": [
"# OpenAI SSE — flat {code, message}, no nested \"error\" key\n",
"oai_sse = {\"code\": \"rate_limit_exceeded\", \"message\": \"Rate limit exceeded\"}\n",
"\n",
"# Anthropic SSE — nested error dict with type+message\n",
"ant_sse = {\"type\": \"error\", \"error\": {\"type\": \"overloaded_error\", \"message\": \"Overloaded\"}}\n",
"\n",
"# Gemini SSE — nested error dict with code(int)+message+status(string)\n",
"gem_sse = {\"error\": {\"code\": 500, \"message\": \"An internal error has occurred\", \"status\": \"INTERNAL\"}}\n",
"\n",
"for name, evt in [(\"OpenAI\", oai_sse), (\"Anthropic\", ant_sse), (\"Gemini\", gem_sse)]:\n",
" error = _parse_sse_error_event(evt)\n",
" print(f\"{name:10s} | msg={error.message!r:45s} | et={error.type!r:25s} | code={error.code!r}\")"
Expand Down Expand Up @@ -635,7 +656,7 @@
{
"data": {
"text/plain": [
"APIError(message='invalid x-api-key', provider='anthropic', model='claude-sonnet-4-20250514', endpoint='/v1/messages', status_code=401, error_type='authentication_error', code='authentication_error')"
"APIError(message='invalid x-api-key', provider='anthropic', model='claude-sonnet-4-20250514', endpoint='/v1/messages', status_code=401, error_type='authentication_error', code='authentication_error', request_id='req_011CbKdGiBAkp9ivnhbrGQ54')"
]
},
"execution_count": null,
Expand Down Expand Up @@ -666,7 +687,7 @@
"name": "stdout",
"output_type": "stream",
"text": [
"APIError(message='invalid x-api-key', provider='anthropic', model='claude-sonnet-4-20250514', endpoint='/v1/messages', status_code=401, error_type='authentication_error', code='authentication_error')\n"
"APIError(message='invalid x-api-key', provider='anthropic', model='claude-sonnet-4-20250514', endpoint='/v1/messages', status_code=401, error_type='authentication_error', code='authentication_error', request_id='req_011CbKdGiBAkp9ivnhbrGQ54')\n"
]
}
],
Expand All @@ -683,7 +704,7 @@
{
"data": {
"text/plain": [
"APIError(message='invalid x-api-key', provider='anthropic', model='claude-sonnet-4-20250514', endpoint='/v1/messages', status_code=401, error_type='authentication_error', code='authentication_error')"
"APIError(message='invalid x-api-key', provider='anthropic', model='claude-sonnet-4-20250514', endpoint='/v1/messages', status_code=401, error_type='authentication_error', code='authentication_error', request_id='req_011CbKdGktPBzF6qYTitVBQN')"
]
},
"execution_count": null,
Expand All @@ -704,6 +725,17 @@
"err"
]
},
{
"cell_type": "code",
"execution_count": null,
"id": "e9546c3a",
"metadata": {},
"outputs": [],
"source": [
"test_eq(APIError(\"invalid x-api-key\", status_code=401, error_type=\"authentication_error\").retryable, False)\n",
"test_eq(APIError(\"Bad credentials\", status_code=401, error_type=\"401\", code=\"401\").retryable, False)"
]
},
{
"cell_type": "markdown",
"id": "9cfc6ee4",
Expand Down Expand Up @@ -752,7 +784,7 @@
"name": "stdout",
"output_type": "stream",
"text": [
"APIError(message='invalid x-api-key', provider='anthropic', model='claude-sonnet-4-20250514', endpoint='POST /v1/messages', status_code=401, error_type='authentication_error', code='authentication_error')\n"
"APIError(message='invalid x-api-key', provider='anthropic', model='claude-sonnet-4-20250514', endpoint='POST /v1/messages', status_code=401, error_type='authentication_error', code='authentication_error', request_id='req_011CbKdGom2JBTcNpuUUyZzB')\n"
]
}
],
Expand Down Expand Up @@ -789,6 +821,90 @@
"except httpx.HTTPStatusError as exc: print(exc.api_error())"
]
},
{
"cell_type": "code",
"execution_count": null,
"id": "478685fd",
"metadata": {},
"outputs": [],
"source": [
"resp = httpx.post(\"https://api.anthropic.com/v1/messages\",\n",
" headers={\"x-api-key\":\"sk-fake123\",\"anthropic-version\":\"2023-06-01\"},\n",
" json={\"model\":\"claude-sonnet-4-20250514\",\"max_tokens\":10,\"messages\":[{\"role\":\"user\",\"content\":\"hi\"}]})\n",
"try: resp.raise_for_status()\n",
"except httpx.HTTPStatusError as exc: ae = exc.api_error(provider=\"anthropic\")\n",
"test_eq(ae.status_code, 401)\n",
"test_eq(ae.retryable, False)\n",
"test_eq(ae.error_type, \"authentication_error\")"
]
},
{
"cell_type": "code",
"execution_count": null,
"id": "859197d5",
"metadata": {},
"outputs": [],
"source": [
"#| export\n",
"@patch\n",
"def api_error(self:httpx.RequestError, *, provider:str=\"\", model:str=\"\", endpoint:str=\"\"):\n",
" \"Build APIError from httpx RequestError (transport-level failure).\"\n",
" req = getattr(self, '_request', None)\n",
" if not endpoint and req is not None: endpoint = f\"{req.method.upper()} {req.url.path}\"\n",
" et = type(self).__name__\n",
" retry = isinstance(self, (httpx.TimeoutException, httpx.NetworkError, httpx.ProtocolError, httpx.ProxyError))\n",
" return APIError(str(self) or et, provider=provider, model=model, endpoint=endpoint,\n",
" error_type=et, code=et, retryable=retry, raw=self)"
]
},
{
"cell_type": "code",
"execution_count": null,
"id": "f281e2a3",
"metadata": {},
"outputs": [],
"source": [
"req = httpx.Request(\"POST\", \"https://api.openai.com/v1/chat/completions\")\n",
"\n",
"err = httpx.ConnectError(\"connection refused\", request=req)\n",
"ae = err.api_error(provider=\"openai\", model=\"gpt-4o\")\n",
"test_eq(ae.retryable, True)\n",
"test_eq(ae.error_type, \"ConnectError\")\n",
"test_eq(ae.endpoint, \"POST /v1/chat/completions\")\n",
"test_eq(ae.provider, \"openai\")\n",
"test_eq(ae.model, \"gpt-4o\")\n",
"test_is(ae.raw, err)\n",
"\n",
"err = httpx.ReadTimeout(\"read timed out\", request=req)\n",
"test_eq(err.api_error().retryable, True)\n",
"test_eq(err.api_error().error_type, \"ReadTimeout\")\n",
"\n",
"err = httpx.RemoteProtocolError(\"server disconnected\", request=req)\n",
"test_eq(err.api_error().retryable, True)\n",
"\n",
"err = httpx.UnsupportedProtocol(\"bad scheme\", request=req)\n",
"test_eq(err.api_error(provider=\"anthropic\").retryable, False)\n",
"test_eq(err.api_error().error_type, \"UnsupportedProtocol\")\n",
"\n",
"err = httpx.DecodingError(\"bad gzip\", request=req)\n",
"test_eq(err.api_error().retryable, False)"
]
},
{
"cell_type": "code",
"execution_count": null,
"id": "03980d0b",
"metadata": {},
"outputs": [],
"source": [
"try: httpx.get(\"http://127.0.0.1:1/nope\", timeout=1)\n",
"except httpx.RequestError as exc: ae = exc.api_error(provider=\"local\")\n",
"test_eq(ae.retryable, True)\n",
"test_eq(ae.provider, \"local\")\n",
"test_eq(ae.endpoint, \"GET /nope\")\n",
"assert isinstance(ae.raw, httpx.RequestError)"
]
},
{
"cell_type": "markdown",
"id": "ffeb1531",
Expand Down Expand Up @@ -835,6 +951,19 @@
" print(err)"
]
},
{
"cell_type": "code",
"execution_count": null,
"id": "df7880ee",
"metadata": {},
"outputs": [],
"source": [
"test_eq(api_error_from_event(oai_sse).retryable, True) # code='rate_limit_exceeded'\n",
"test_eq(api_error_from_event(ant_sse).retryable, True) # overloaded_error\n",
"test_eq(api_error_from_event(gem_sse).retryable, True) # INTERNAL\n",
"test_eq(api_error_from_event({\"error\": {\"message\": \"API rate limit exceeded\"}}).retryable, True)"
]
},
{
"cell_type": "markdown",
"id": "1c3da91f",
Expand All @@ -853,7 +982,7 @@
"name": "stdout",
"output_type": "stream",
"text": [
"APIError(message='API rate limit exceeded', endpoint='GET /repos/{owner}/{repo}')\n"
"APIError(message='API rate limit exceeded', endpoint='GET /repos/{owner}/{repo}', retryable=True)\n"
]
}
],
Expand Down Expand Up @@ -884,7 +1013,15 @@
]
}
],
"metadata": {},
"metadata": {
"solveit": {
"default_code": false,
"mode": "concise",
"use_thinking": true,
"use_tools": false,
"ver": 2
}
},
"nbformat": 4,
"nbformat_minor": 5
}
Loading