11class 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
712class 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+
1465class 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