44
55import logging
66import os
7+ import re
8+ import time
79from typing import Any
810
911import requests
2830DEFAULT_BASE_URL = "https://listingsapi.com"
2931DEFAULT_TIMEOUT = 240.0
3032DEFAULT_MAX_RETRIES = 2
33+ DEFAULT_BACKOFF_FACTOR = 0.5
34+ _IDEMPOTENT_METHODS = frozenset ({"GET" , "HEAD" , "OPTIONS" })
35+ _RETRYABLE_STATUSES = frozenset ({429 , 500 , 502 , 503 , 504 })
3136
3237
3338class ListingsAPI :
@@ -69,6 +74,7 @@ def __init__(
6974
7075 self ._base_url = (base_url or DEFAULT_BASE_URL ).rstrip ("/" )
7176 self ._timeout = timeout
77+ self ._max_retries = max_retries
7278
7379 self ._session = requests .Session ()
7480 self ._session .headers .update (
@@ -80,9 +86,9 @@ def __init__(
8086
8187 retry = Retry (
8288 total = max_retries ,
83- backoff_factor = 0.5 ,
84- status_forcelist = [ 429 , 500 , 502 , 503 , 504 ] ,
85- allowed_methods = [ "GET" , "POST" ] ,
89+ backoff_factor = DEFAULT_BACKOFF_FACTOR ,
90+ status_forcelist = list ( _RETRYABLE_STATUSES ) ,
91+ allowed_methods = _IDEMPOTENT_METHODS ,
8692 respect_retry_after_header = True ,
8793 )
8894 adapter = HTTPAdapter (max_retries = retry )
@@ -123,32 +129,67 @@ def _get(self, path: str, params: dict | None = None) -> dict[str, Any]:
123129 raise APIConnectionError (f"Request timed out: { e } " ) from e
124130 return self ._handle_response (response )
125131
126- def _post (self , path : str , json_body : dict [str , Any ]) -> dict [str , Any ]:
132+ def _post (
133+ self ,
134+ path : str ,
135+ json_body : dict [str , Any ],
136+ * ,
137+ idempotency_key : str | None = None ,
138+ ) -> dict [str , Any ]:
127139 url = f"{ self ._base_url } /api/v4/{ path } "
128140 logger .debug ("POST %s" , url )
129- try :
130- response = self ._session .post (url , json = json_body , timeout = self ._timeout )
131- except requests .ConnectionError as e :
132- raise APIConnectionError (f"Connection error: { e } " ) from e
133- except requests .Timeout as e :
134- raise APIConnectionError (f"Request timed out: { e } " ) from e
141+ response = self ._send_write ("POST" , url , json_body , idempotency_key )
135142 data = self ._handle_response (response )
136143 self ._raise_for_mutation_errors (data , response )
137144 return data
138145
139- def _delete (self , path : str , json_body : dict [str , Any ] | None = None ) -> dict [str , Any ]:
146+ def _delete (
147+ self ,
148+ path : str ,
149+ json_body : dict [str , Any ] | None = None ,
150+ * ,
151+ idempotency_key : str | None = None ,
152+ ) -> dict [str , Any ]:
140153 url = f"{ self ._base_url } /api/v4/{ path } "
141154 logger .debug ("DELETE %s" , url )
142- try :
143- response = self ._session .delete (url , json = json_body , timeout = self ._timeout )
144- except requests .ConnectionError as e :
145- raise APIConnectionError (f"Connection error: { e } " ) from e
146- except requests .Timeout as e :
147- raise APIConnectionError (f"Request timed out: { e } " ) from e
155+ response = self ._send_write ("DELETE" , url , json_body , idempotency_key )
148156 data = self ._handle_response (response )
149157 self ._raise_for_mutation_errors (data , response )
150158 return data
151159
160+ def _send_write (
161+ self ,
162+ method : str ,
163+ url : str ,
164+ json_body : dict [str , Any ] | None ,
165+ idempotency_key : str | None ,
166+ ) -> requests .Response :
167+ """Send a write request, retrying only when it is safe to repeat.
168+
169+ Writes are not idempotent, so by default they are sent exactly once and
170+ any 429/5xx is surfaced immediately — a blind retry after a
171+ completed-but-5xx write could duplicate a create, reply, or post. When
172+ the caller supplies an idempotency key the request carries an
173+ ``Idempotency-Key`` header and is retried on 429/5xx up to
174+ ``max_retries`` times, since the server can then dedupe repeats.
175+ """
176+ headers = {"Idempotency-Key" : idempotency_key } if idempotency_key else None
177+ attempts = self ._max_retries + 1 if idempotency_key else 1
178+ for attempt in range (attempts ):
179+ try :
180+ response = self ._session .request (
181+ method , url , json = json_body , headers = headers , timeout = self ._timeout
182+ )
183+ except requests .ConnectionError as e :
184+ raise APIConnectionError (f"Connection error: { e } " ) from e
185+ except requests .Timeout as e :
186+ raise APIConnectionError (f"Request timed out: { e } " ) from e
187+ is_last = attempt + 1 >= attempts
188+ if is_last or response .status_code not in _RETRYABLE_STATUSES :
189+ return response
190+ _sleep_before_retry (attempt , response )
191+ raise RuntimeError ("no write attempt was made" ) # pragma: no cover
192+
152193 def _location_get (
153194 self , location_id : str | int , path_suffix : str , params : dict | None = None
154195 ) -> dict [str , Any ]:
@@ -166,23 +207,26 @@ def _handle_response(self, response: requests.Response) -> dict[str, Any]:
166207
167208 status = response .status_code
168209 body = response .text
210+ errors = _error_entries_from_response (response )
169211 msg = f"API request failed: { status } "
170212
171213 if status == 401 :
172- raise AuthenticationError (msg , status_code = status , response_body = body )
214+ raise AuthenticationError (msg , status_code = status , response_body = body , errors = errors )
173215 elif status == 403 :
174- raise PermissionDeniedError (msg , status_code = status , response_body = body )
216+ raise PermissionDeniedError (msg , status_code = status , response_body = body , errors = errors )
175217 elif status == 404 :
176- raise NotFoundError (msg , status_code = status , response_body = body )
218+ raise NotFoundError (msg , status_code = status , response_body = body , errors = errors )
177219 elif status == 429 :
178220 retry_after = response .headers .get ("Retry-After" )
179- raise RateLimitError (msg , status_code = status , response_body = body , retry_after = retry_after )
221+ raise RateLimitError (
222+ msg , status_code = status , response_body = body , retry_after = retry_after , errors = errors
223+ )
180224 elif status in (400 , 422 ):
181- raise ValidationError (msg , status_code = status , response_body = body )
225+ raise ValidationError (msg , status_code = status , response_body = body , errors = errors )
182226 elif status >= 500 :
183- raise InternalServerError (msg , status_code = status , response_body = body )
227+ raise InternalServerError (msg , status_code = status , response_body = body , errors = errors )
184228 else :
185- raise APIError (msg , status_code = status , response_body = body )
229+ raise APIError (msg , status_code = status , response_body = body , errors = errors )
186230
187231 def _raise_for_mutation_errors (self , data : dict [str , Any ], response : requests .Response ) -> None :
188232 if not isinstance (data , dict ):
@@ -231,24 +275,71 @@ def subscriptions(self) -> list[APIObject]:
231275 return [APIObject (item ) for item in items ]
232276
233277
278+ _CODE_PREFIX_RE = re .compile (r"^\s*(SY\d+)\s*:\s*(.*)$" , re .DOTALL )
279+
280+
281+ def _split_code_prefix (message : str ) -> tuple [str | None , str ]:
282+ """Split a leading ``SYxxxxx:`` error code out of a message.
283+
284+ The platform embeds error codes as a prefix in the message string
285+ (``"SY90005: Invalid Token"``) rather than as a separate field. Returns the
286+ code and the message with the prefix stripped, or ``(None, message)`` when
287+ the message has no code prefix.
288+ """
289+ match = _CODE_PREFIX_RE .match (message )
290+ if match :
291+ return match .group (1 ), match .group (2 ).strip ()
292+ return None , message
293+
294+
234295def _parse_error_entries (raw : Any ) -> list [dict [str , Any ]]:
235296 if not raw :
236297 return []
237298 entries : list [dict [str , Any ]] = []
238299 for item in raw if isinstance (raw , list ) else [raw ]:
239300 if isinstance (item , dict ):
240- entries .append (
241- {
242- "code" : item .get ("code" ),
243- "message" : item .get ("message" ) or str (item ),
244- "context" : item .get ("context" ) or {},
245- }
246- )
301+ message = item .get ("message" ) or str (item )
302+ code = item .get ("code" )
303+ if not code :
304+ code , message = _split_code_prefix (message )
305+ entries .append ({"code" : code , "message" : message , "context" : item .get ("context" ) or {}})
247306 else :
248- entries .append ({"code" : None , "message" : str (item ), "context" : {}})
307+ code , message = _split_code_prefix (str (item ))
308+ entries .append ({"code" : code , "message" : message , "context" : {}})
249309 return entries
250310
251311
312+ def _error_entries_from_response (response : requests .Response ) -> list [dict [str , Any ]]:
313+ """Best-effort parse of error entries from a non-2xx response body.
314+
315+ Handles the ``errors[]`` envelope, a bare ``{"message": ..., "code": ...}``
316+ body (the shape the platform uses for 401s), and plain-text bodies (which
317+ yield no entries).
318+ """
319+ try :
320+ data = response .json ()
321+ except ValueError :
322+ return []
323+ if not isinstance (data , dict ):
324+ return []
325+ if data .get ("errors" ):
326+ return _parse_error_entries (data .get ("errors" ))
327+ if data .get ("message" ):
328+ return _parse_error_entries ([{"code" : data .get ("code" ), "message" : data .get ("message" )}])
329+ return []
330+
331+
332+ def _sleep_before_retry (attempt : int , response : requests .Response ) -> None :
333+ retry_after = response .headers .get ("Retry-After" )
334+ delay = DEFAULT_BACKOFF_FACTOR * (2 ** attempt )
335+ if retry_after :
336+ try :
337+ delay = float (retry_after )
338+ except ValueError :
339+ pass
340+ time .sleep (delay )
341+
342+
252343_AUTH_CODES = {"SY90005" , "SY90001" }
253344_PERMISSION_CODES = {"SY90003" }
254345
0 commit comments