Skip to content

Commit 42cd4eb

Browse files
authored
Improve client HTTP errors (#263)
Related to #261 This PR improves the `ReplicateError` type by having it conform to the problem details response as defined in RFC 7807. --------- Signed-off-by: Mattt Zmuda <mattt@replicate.com>
1 parent b3486ab commit 42cd4eb

File tree

3 files changed

+141
-2
lines changed

3 files changed

+141
-2
lines changed

replicate/client.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -365,4 +365,4 @@ def _build_httpx_client(
365365

366366
def _raise_for_status(resp: httpx.Response) -> None:
367367
if 400 <= resp.status_code < 600:
368-
raise ReplicateError(resp.json()["detail"])
368+
raise ReplicateError.from_response(resp)

replicate/exceptions.py

Lines changed: 86 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,8 @@
1+
from typing import Optional
2+
3+
import httpx
4+
5+
16
class ReplicateException(Exception):
27
"""A base class for all Replicate exceptions."""
38

@@ -7,4 +12,84 @@ class ModelError(ReplicateException):
712

813

914
class ReplicateError(ReplicateException):
10-
"""An error from Replicate."""
15+
"""
16+
An error from Replicate's API.
17+
18+
This class represents a problem details response as defined in RFC 7807.
19+
"""
20+
21+
type: Optional[str]
22+
"""A URI that identifies the error type."""
23+
24+
title: Optional[str]
25+
"""A short, human-readable summary of the error."""
26+
27+
status: Optional[int]
28+
"""The HTTP status code."""
29+
30+
detail: Optional[str]
31+
"""A human-readable explanation specific to this occurrence of the error."""
32+
33+
instance: Optional[str]
34+
"""A URI that identifies the specific occurrence of the error."""
35+
36+
def __init__(
37+
self,
38+
type: Optional[str] = None,
39+
title: Optional[str] = None,
40+
status: Optional[int] = None,
41+
detail: Optional[str] = None,
42+
instance: Optional[str] = None,
43+
) -> None:
44+
self.type = type
45+
self.title = title
46+
self.status = status
47+
self.detail = detail
48+
self.instance = instance
49+
50+
@classmethod
51+
def from_response(cls, response: httpx.Response) -> "ReplicateError":
52+
"""Create a ReplicateError from an HTTP response."""
53+
try:
54+
data = response.json()
55+
except ValueError:
56+
data = {}
57+
58+
return cls(
59+
type=data.get("type"),
60+
title=data.get("title"),
61+
detail=data.get("detail"),
62+
status=response.status_code,
63+
instance=data.get("instance"),
64+
)
65+
66+
def to_dict(self) -> dict:
67+
return {
68+
key: value
69+
for key, value in {
70+
"type": self.type,
71+
"title": self.title,
72+
"status": self.status,
73+
"detail": self.detail,
74+
"instance": self.instance,
75+
}.items()
76+
if value is not None
77+
}
78+
79+
def __str__(self) -> str:
80+
return "ReplicateError Details:\n" + "\n".join(
81+
[f"{key}: {value}" for key, value in self.to_dict().items()]
82+
)
83+
84+
def __repr__(self) -> str:
85+
class_name = self.__class__.__name__
86+
params = ", ".join(
87+
[
88+
f"type={repr(self.type)}",
89+
f"title={repr(self.title)}",
90+
f"status={repr(self.status)}",
91+
f"detail={repr(self.detail)}",
92+
f"instance={repr(self.instance)}",
93+
]
94+
)
95+
return f"{class_name}({params})"

tests/test_client.py

Lines changed: 54 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -31,3 +31,57 @@ async def test_authorization_when_setting_environ_after_import():
3131
client = replicate.Client(transport=httpx.MockTransport(router.handler))
3232
resp = client._request("GET", "/")
3333
assert resp.status_code == 200
34+
35+
36+
@pytest.mark.asyncio
37+
async def test_client_error_handling():
38+
import replicate
39+
from replicate.exceptions import ReplicateError
40+
41+
router = respx.Router()
42+
router.route(
43+
method="GET",
44+
url="https://api.replicate.com/",
45+
headers={"Authorization": "Token test-client-error"},
46+
).mock(
47+
return_value=httpx.Response(
48+
400,
49+
json={"detail": "Client error occurred"},
50+
)
51+
)
52+
53+
token = "test-client-error" # noqa: S105
54+
55+
with mock.patch.dict(os.environ, {"REPLICATE_API_TOKEN": token}):
56+
client = replicate.Client(transport=httpx.MockTransport(router.handler))
57+
with pytest.raises(ReplicateError) as exc_info:
58+
client._request("GET", "/")
59+
assert "status: 400" in str(exc_info.value)
60+
assert "detail: Client error occurred" in str(exc_info.value)
61+
62+
63+
@pytest.mark.asyncio
64+
async def test_server_error_handling():
65+
import replicate
66+
from replicate.exceptions import ReplicateError
67+
68+
router = respx.Router()
69+
router.route(
70+
method="GET",
71+
url="https://api.replicate.com/",
72+
headers={"Authorization": "Token test-server-error"},
73+
).mock(
74+
return_value=httpx.Response(
75+
500,
76+
json={"detail": "Server error occurred"},
77+
)
78+
)
79+
80+
token = "test-server-error" # noqa: S105
81+
82+
with mock.patch.dict(os.environ, {"REPLICATE_API_TOKEN": token}):
83+
client = replicate.Client(transport=httpx.MockTransport(router.handler))
84+
with pytest.raises(ReplicateError) as exc_info:
85+
client._request("GET", "/")
86+
assert "status: 500" in str(exc_info.value)
87+
assert "detail: Server error occurred" in str(exc_info.value)

0 commit comments

Comments
 (0)