Skip to content

Commit 9429f48

Browse files
fix(python): handle non-JSON error responses in HTTP decorators (#200)
The generated OpenAPI client calls response.json() for all status codes without checking Content-Type. When the server returns a plain-text error body (e.g. "Unauthorized\n" on a 401), JSONDecodeError propagates and gets wrapped as a generic RenderError with an unhelpful parse error message. Now, we catch JSONDecodeError explicitly in handle_http_errors decorators and surface the raw response body so callers can diagnose the actual server error. GitOrigin-RevId: e5601e666fbc56386a61cb154e7892ea262cc96d
1 parent 4051e9a commit 9429f48

4 files changed

Lines changed: 66 additions & 78 deletions

File tree

python/render_sdk/client/tests/test_util.py

Lines changed: 36 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -1,11 +1,13 @@
1+
import json
2+
13
import httpx
24
import pytest
35

4-
from render_sdk.client.errors import ClientError, ServerError, TimeoutError
6+
from render_sdk.client.errors import ClientError, RenderError, ServerError, TimeoutError
57
from render_sdk.client.util import (
8+
_handle_wrapper_exception,
69
handle_api_error,
710
handle_http_error,
8-
handle_http_errors,
911
handle_httpx_exception,
1012
handle_storage_http_error,
1113
retry_with_backoff,
@@ -169,19 +171,39 @@ def test_handle_api_error_fallback_when_content_is_not_json():
169171
handle_api_error(response, "API request")
170172

171173

172-
@pytest.mark.asyncio
173-
async def test_decorator_handle_http_errors():
174-
@handle_http_errors("test operation")
175-
async def test_operation():
176-
return Response(
177-
status_code=400,
178-
content=b"",
179-
headers={},
180-
parsed=Error(message="Bad request"),
181-
)
174+
def test_handle_wrapper_exception_reraises_render_error():
175+
with pytest.raises(RenderError, match="already a render error"):
176+
_handle_wrapper_exception(RenderError("already a render error"), "op")
177+
178+
179+
def test_handle_wrapper_exception_httpx_request_error():
180+
exc = httpx.TimeoutException("timed out")
181+
with pytest.raises(TimeoutError, match="op timed out"):
182+
_handle_wrapper_exception(exc, "op")
183+
184+
185+
def test_handle_wrapper_exception_json_decode_error():
186+
exc = json.JSONDecodeError("Expecting value", "Unauthorized\n", 0)
187+
with pytest.raises(
188+
RenderError, match="server returned a non-JSON response: Unauthorized"
189+
):
190+
_handle_wrapper_exception(exc, "create task")
191+
192+
193+
def test_handle_wrapper_exception_json_decode_error_empty_body():
194+
exc = json.JSONDecodeError("Expecting value", "", 0)
195+
with pytest.raises(
196+
RenderError, match="server returned a non-JSON response: empty response"
197+
):
198+
_handle_wrapper_exception(exc, "create task")
199+
182200

183-
with pytest.raises(ClientError, match="test operation failed: Bad request"):
184-
await test_operation()
201+
def test_handle_wrapper_exception_unexpected_error():
202+
exc = ValueError("something weird")
203+
with pytest.raises(
204+
RenderError, match="failed with unexpected error: something weird"
205+
):
206+
_handle_wrapper_exception(exc, "op")
185207

186208

187209
class TestHandleStorageHttpError:

python/render_sdk/client/tests/test_util_sync.py

Lines changed: 1 addition & 32 deletions
Original file line numberDiff line numberDiff line change
@@ -3,10 +3,7 @@
33

44
import pytest
55

6-
from render_sdk.client.errors import ClientError
7-
from render_sdk.client.util_sync import handle_http_errors, retry_with_backoff
8-
from render_sdk.public_api.models.error import Error
9-
from render_sdk.public_api.types import Response
6+
from render_sdk.client.util_sync import retry_with_backoff
107

118

129
class _TestException(Exception):
@@ -74,31 +71,3 @@ def fn():
7471
fn, max_retries=3, poll_interval=0.001, backoff_factor=1.0
7572
)
7673
assert result is None
77-
78-
79-
def test_decorator_handle_http_errors():
80-
@handle_http_errors("test operation")
81-
def test_operation():
82-
return Response(
83-
status_code=400,
84-
content=b"",
85-
headers={},
86-
parsed=Error(message="Bad request"),
87-
)
88-
89-
with pytest.raises(ClientError, match="test operation failed: Bad request"):
90-
test_operation()
91-
92-
93-
def test_decorator_handle_http_errors_success():
94-
@handle_http_errors("test operation")
95-
def test_operation():
96-
return Response(
97-
status_code=200,
98-
content=b"",
99-
headers={},
100-
parsed={"data": "ok"},
101-
)
102-
103-
result = test_operation()
104-
assert result.status_code == 200

python/render_sdk/client/util.py

Lines changed: 22 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,5 @@
11
import functools
2+
import json
23
import logging
34
from asyncio import sleep
45
from collections.abc import Awaitable, Callable
@@ -197,8 +198,6 @@ def handle_api_error(
197198
elif response.parsed is None and response.content:
198199
# Parsed is None (e.g., 400 not handled) - try to extract from raw content
199200
try:
200-
import json
201-
202201
error_data = json.loads(response.content)
203202
if isinstance(error_data, dict):
204203
message = error_data.get("message")
@@ -227,6 +226,24 @@ def handle_api_error(
227226
raise ClientError(full_message)
228227

229228

229+
def _handle_wrapper_exception(exc: Exception, operation: str) -> NoReturn:
230+
"""
231+
Translate exceptions caught by handle_http_errors into custom exceptions.
232+
233+
Shared by both the async and sync versions of the decorator.
234+
"""
235+
if isinstance(exc, RenderError):
236+
raise exc
237+
if isinstance(exc, httpx.RequestError):
238+
handle_httpx_exception(exc, operation)
239+
if isinstance(exc, json.JSONDecodeError):
240+
body = exc.doc.strip() if exc.doc else "empty response"
241+
raise RenderError(
242+
f"{operation} failed: server returned a non-JSON response: {body}"
243+
) from exc
244+
raise RenderError(f"{operation} failed with unexpected error: {exc}") from exc
245+
246+
230247
def handle_http_errors(operation: str):
231248
"""
232249
Decorator that handles HTTPX exceptions and HTTP error responses.
@@ -250,20 +267,10 @@ def decorator(func: Callable[..., Awaitable[Response[Any | Error]]]):
250267
async def wrapper(*args, **kwargs):
251268
try:
252269
result = await func(*args, **kwargs)
253-
254-
handle_api_error(result, operation)
255-
256-
return result
257-
258-
except httpx.RequestError as exc:
259-
handle_httpx_exception(exc, operation)
260-
except RenderError:
261-
raise
262270
except Exception as exc:
263-
# Unexpected exception
264-
raise RenderError(
265-
f"{operation} failed with unexpected error: {exc}"
266-
) from exc
271+
_handle_wrapper_exception(exc, operation)
272+
handle_api_error(result, operation)
273+
return result
267274

268275
return wrapper
269276

python/render_sdk/client/util_sync.py

Lines changed: 7 additions & 17 deletions
Original file line numberDiff line numberDiff line change
@@ -10,10 +10,8 @@
1010
from time import sleep
1111
from typing import Any
1212

13-
import httpx
14-
15-
from render_sdk.client.errors import RenderError
1613
from render_sdk.client.util import (
14+
_handle_wrapper_exception,
1715
handle_api_error,
1816
handle_http_error,
1917
handle_httpx_exception,
@@ -26,6 +24,7 @@
2624
__all__ = [
2725
"retry_with_backoff",
2826
"handle_http_errors",
27+
"_handle_wrapper_exception",
2928
"handle_api_error",
3029
"handle_http_error",
3130
"handle_httpx_exception",
@@ -60,7 +59,8 @@ def retry_with_backoff(
6059

6160

6261
def handle_http_errors(operation: str):
63-
"""Decorator that handles HTTPX exceptions and HTTP error responses.
62+
"""
63+
Decorator that handles HTTPX exceptions and HTTP error responses.
6464
6565
Sync version of the decorator in util.py.
6666
@@ -73,20 +73,10 @@ def decorator(func: Callable[..., Response[Any | Error]]):
7373
def wrapper(*args, **kwargs):
7474
try:
7575
result = func(*args, **kwargs)
76-
77-
handle_api_error(result, operation)
78-
79-
return result
80-
81-
except httpx.RequestError as exc:
82-
handle_httpx_exception(exc, operation)
83-
except RenderError:
84-
raise
8576
except Exception as exc:
86-
# Unexpected exception
87-
raise RenderError(
88-
f"{operation} failed with unexpected error: {exc}"
89-
) from exc
77+
_handle_wrapper_exception(exc, operation)
78+
handle_api_error(result, operation)
79+
return result
9080

9181
return wrapper
9282

0 commit comments

Comments
 (0)