Skip to content

Commit 9389d5d

Browse files
committed
error response cleansing
1 parent 491a923 commit 9389d5d

2 files changed

Lines changed: 650 additions & 1 deletion

File tree

plane/errors/errors.py

Lines changed: 81 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,8 +1,13 @@
11
class PlaneError(Exception):
2+
"""Base exception for all Plane SDK errors."""
3+
24
def __init__(self, message: str, status_code: int | None = None) -> None:
35
super().__init__(message)
46
self.status_code = status_code
57

8+
def __reduce__(self) -> tuple:
9+
return (type(self), (str(self), self.status_code))
10+
611

712
class ConfigurationError(PlaneError):
813
"""Raised when client configuration is invalid or incomplete."""
@@ -11,7 +16,82 @@ def __init__(self, message: str) -> None:
1116
super().__init__(message, status_code=None)
1217

1318

19+
def _extract_detail(payload: object) -> str | None:
20+
"""Extract a human-readable detail string from an API error payload.
21+
22+
Plane API endpoints return errors in several shapes:
23+
- ``{"detail": "Not found."}``
24+
- ``{"error": "Permission denied."}``
25+
- ``{"name": ["This field is required."]}`` (field-level errors)
26+
- ``{"field": "single error string"}``
27+
- plain text strings
28+
29+
This helper normalises all of those into a single string suitable for
30+
inclusion in an exception message. Returns ``None`` when nothing useful
31+
can be extracted.
32+
"""
33+
if payload is None:
34+
return None
35+
36+
if isinstance(payload, str):
37+
stripped = payload.strip()
38+
return stripped if stripped else None
39+
40+
if isinstance(payload, dict):
41+
# Prefer the canonical "detail" / "error" keys first.
42+
for key in ("detail", "error", "message"):
43+
value = payload.get(key)
44+
if value is not None:
45+
if isinstance(value, list):
46+
return "; ".join(str(v) for v in value)
47+
return str(value)
48+
49+
# Field-level validation errors, e.g. {"name": ["required"]}
50+
parts: list[str] = []
51+
for field, errors in payload.items():
52+
if isinstance(errors, list):
53+
joined = ", ".join(str(e) for e in errors)
54+
parts.append(f"{field}: {joined}")
55+
elif isinstance(errors, str):
56+
parts.append(f"{field}: {errors}")
57+
if parts:
58+
return "; ".join(parts)
59+
60+
# For any other type, fall back to str()
61+
text = str(payload).strip()
62+
return text if text else None
63+
64+
1465
class HttpError(PlaneError):
15-
def __init__(self, message: str, status_code: int, response: object | None = None) -> None:
66+
"""Raised on non-2xx HTTP responses.
67+
68+
Attributes:
69+
status_code: The HTTP status code.
70+
response: The parsed response body (dict / str / None). Use this for
71+
programmatic inspection of field-level errors.
72+
"""
73+
74+
def __init__(
75+
self, message: str, status_code: int, response: object | None = None
76+
) -> None:
1677
super().__init__(message, status_code=status_code)
1778
self.response = response
79+
80+
def __reduce__(self) -> tuple:
81+
return (
82+
type(self),
83+
(super().__str__(), self.status_code, self.response),
84+
)
85+
86+
def __str__(self) -> str:
87+
base = super().__str__()
88+
detail = _extract_detail(self.response)
89+
if detail and detail not in base:
90+
return f"{base}: {detail}"
91+
return base
92+
93+
def __repr__(self) -> str:
94+
return (
95+
f"{type(self).__name__}(message={super().__str__()!r}, "
96+
f"status_code={self.status_code!r}, response={self.response!r})"
97+
)

0 commit comments

Comments
 (0)