@@ -82,6 +82,11 @@ class DevhelmApiError(DevhelmError):
8282 The optional `request_id` field is the per-request id emitted by the
8383 API as the `X-Request-Id` response header and embedded in the JSON
8484 error body. Always include it in support tickets.
85+
86+ The optional `retry_after` field is the parsed value of the
87+ `Retry-After` response header in whole seconds. It's populated on
88+ rate-limit (429) responses that include the header so callers can back
89+ off for exactly as long as the server asked; ``None`` otherwise.
8590 """
8691
8792 status : int
@@ -93,6 +98,7 @@ class DevhelmApiError(DevhelmError):
9398 # narrowing. (Subclasses still inherit the same `str` type.)
9499 code : str
95100 request_id : str | None
101+ retry_after : int | None
96102
97103 def __init__ (
98104 self ,
@@ -103,6 +109,7 @@ def __init__(
103109 body : dict [str , Any ] | str | None = None ,
104110 code : str | None = None ,
105111 request_id : str | None = None ,
112+ retry_after : int | None = None ,
106113 ) -> None :
107114 super ().__init__ (message )
108115 self .status = status
@@ -113,6 +120,9 @@ def __init__(
113120 # `err.code` is never ``None`` for callers switching on category.
114121 self .code = code or "API_ERROR"
115122 self .request_id = request_id
123+ # Parsed from the `Retry-After` response header (seconds). Populated
124+ # on 429 / 503 responses that include it; ``None`` otherwise.
125+ self .retry_after = retry_after
116126
117127
118128class DevhelmAuthError (DevhelmApiError ):
@@ -152,15 +162,40 @@ def __init__(self, message: str, *, cause: Exception | None = None) -> None:
152162 self .__cause__ = cause
153163
154164
165+ def _parse_retry_after (value : str | None ) -> int | None :
166+ """Parse a ``Retry-After`` header value into whole seconds.
167+
168+ The API emits ``Retry-After`` as an integer number of seconds. We parse
169+ defensively: any non-integer value (an HTTP-date form, or garbage from a
170+ misbehaving proxy) yields ``None`` rather than raising, so a malformed
171+ header can never break error construction.
172+ """
173+ if value is None :
174+ return None
175+ try :
176+ return int (value )
177+ except (TypeError , ValueError ):
178+ return None
179+
180+
155181def error_from_response (
156- status : int , body : str , * , request_id : str | None = None
182+ status : int ,
183+ body : str ,
184+ * ,
185+ request_id : str | None = None ,
186+ retry_after : str | None = None ,
157187) -> DevhelmApiError :
158188 """Map an HTTP error response to a typed DevhelmApiError subclass.
159189
160190 `request_id` is the value of the `X-Request-Id` response header. It is
161191 pulled out at the call site (rather than re-parsed from the body) so the
162192 SDK still surfaces the id even when the server returns a non-JSON body
163193 (e.g. an HTML error page from a misconfigured proxy).
194+
195+ `retry_after` is the raw value of the `Retry-After` response header,
196+ pulled out at the call site for the same reason. It's parsed into whole
197+ seconds and surfaced as ``err.retry_after`` (e.g. on 429 responses) so
198+ callers can back off for exactly as long as the server asked.
164199 """
165200 message = f"HTTP { status } "
166201 detail : str | None = None
@@ -195,6 +230,7 @@ def error_from_response(
195230 "body" : parsed_body ,
196231 "code" : code ,
197232 "request_id" : resolved_request_id ,
233+ "retry_after" : _parse_retry_after (retry_after ),
198234 }
199235
200236 if status in (401 , 403 ):
0 commit comments