Skip to content

Commit d8907c8

Browse files
committed
listingsAPI Python SDK: safer write retries, SYxxxxx error parsing, typing
- Do not auto-retry writes (POST/DELETE) by default; retry only GET/HEAD/OPTIONS, or writes carrying an explicit Idempotency-Key (prevents duplicate creates on 5xx) - Parse SYxxxxx: error-code prefix from message; map SY90005/SY90001 to AuthenticationError on both HTTP 200 and 401 - Ship py.typed (PEP 561) + Typing::Typed + py3.9-3.13 classifiers; requires-python >=3.9 - Docs: retry section reflects reads-retry / writes-don't; PyPI Source -> docs page
1 parent 8da874a commit d8907c8

7 files changed

Lines changed: 281 additions & 42 deletions

File tree

README.md

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -490,7 +490,7 @@ except listingsapi.APIConnectionError:
490490
print("Network error. Could not reach the API.")
491491
```
492492

493-
The client automatically retries 429 and 5xx responses (default 2 attempts, honoring `Retry-After`). A `RateLimitError` means retries were exhausted; back off for `e.retry_after` seconds before trying again:
493+
The client automatically retries **read** requests (GET) on 429 and 5xx responses — up to `max_retries` attempts (default 2), honoring `Retry-After`. Writes (creates, replies, posts, deletes) are **not** retried automatically: they are not idempotent, so a blind retry after a completed-but-5xx write could duplicate the record. If you need to retry a write, do it yourself only when the operation is safe to repeat. A `RateLimitError` means retries were exhausted; back off for `e.retry_after` seconds before trying again:
494494

495495
```python
496496
import time
@@ -526,7 +526,7 @@ client = listingsapi.ListingsAPI(
526526
| `api_key` | `LISTINGSAPI_KEY` env var | Your API key |
527527
| `base_url` | `https://listingsapi.com` | API host |
528528
| `timeout` | `240.0` | Request timeout in seconds |
529-
| `max_retries` | `2` | Automatic retries on 429 and 5xx |
529+
| `max_retries` | `2` | Automatic retries on 429 and 5xx for read (GET) requests; writes are never auto-retried |
530530

531531
## Examples and development
532532

pyproject.toml

Lines changed: 18 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -11,15 +11,28 @@ requires-python = ">=3.9"
1111
authors = [{ name = "listingsAPI", email = "support@listingsapi.com" }]
1212
license = { file = "LICENSE" }
1313
keywords = ["listingsapi", "local-seo", "listings", "reviews", "gbp", "api"]
14+
classifiers = [
15+
"Development Status :: 4 - Beta",
16+
"Intended Audience :: Developers",
17+
"License :: OSI Approved :: MIT License",
18+
"Operating System :: OS Independent",
19+
"Programming Language :: Python :: 3",
20+
"Programming Language :: Python :: 3.9",
21+
"Programming Language :: Python :: 3.10",
22+
"Programming Language :: Python :: 3.11",
23+
"Programming Language :: Python :: 3.12",
24+
"Programming Language :: Python :: 3.13",
25+
"Topic :: Software Development :: Libraries :: Python Modules",
26+
"Typing :: Typed",
27+
]
1428
dependencies = [
1529
"requests>=2.28",
1630
]
1731

1832
[project.urls]
1933
Homepage = "https://www.listingsapi.com"
2034
Documentation = "https://docs.listingsapi.com/sdks/python"
21-
Source = "https://github.com/synup/listingsapi-python-sdk"
22-
Issues = "https://github.com/synup/listingsapi-python-sdk/issues"
35+
Source = "https://docs.listingsapi.com/sdks/python"
2336

2437
[project.optional-dependencies]
2538
dev = [
@@ -30,5 +43,8 @@ dev = [
3043
[tool.setuptools.packages.find]
3144
where = ["src"]
3245

46+
[tool.setuptools.package-data]
47+
listingsapi = ["py.typed"]
48+
3349
[tool.pytest.ini_options]
3450
testpaths = ["tests"]

src/listingsapi/_client.py

Lines changed: 123 additions & 32 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,8 @@
44

55
import logging
66
import os
7+
import re
8+
import time
79
from typing import Any
810

911
import requests
@@ -28,6 +30,9 @@
2830
DEFAULT_BASE_URL = "https://listingsapi.com"
2931
DEFAULT_TIMEOUT = 240.0
3032
DEFAULT_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

3338
class 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+
234295
def _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

src/listingsapi/exceptions.py

Lines changed: 8 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -20,7 +20,8 @@ class APIError(ListingsAPIError):
2020
status_code: HTTP status code of the response (may be 200 for
2121
payload-level errors).
2222
response_body: Raw response text from the API, when available.
23-
code: The first API error code (e.g. "SY10005"), when the API sent one.
23+
code: The first API error code (e.g. "SY10005"), taken from the error's
24+
"code" field or parsed from a leading "SYxxxxx:" message prefix.
2425
errors: Parsed API error entries, each normalized to a dict with
2526
"code", "message", and "context" keys.
2627
@@ -58,7 +59,12 @@ def __init__(
5859

5960

6061
class AuthenticationError(APIError):
61-
"""401 — Invalid or missing API key."""
62+
"""Invalid or missing API key.
63+
64+
Raised for HTTP 401 and for invalid-token error payloads the platform
65+
returns with HTTP 200 (codes SY90005 / SY90001, whether supplied as a
66+
``code`` field or embedded as a ``SYxxxxx:`` prefix in the message).
67+
"""
6268

6369

6470
class PermissionDeniedError(APIError):

src/listingsapi/py.typed

Whitespace-only changes.

src/listingsapi/resources/_base.py

Lines changed: 8 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -17,11 +17,15 @@ def __init__(self, client: ListingsAPI) -> None:
1717
def _get(self, path: str, params: dict | None = None) -> dict[str, Any]:
1818
return self._client._get(path, params)
1919

20-
def _post(self, path: str, json_body: dict[str, Any]) -> dict[str, Any]:
21-
return self._client._post(path, json_body)
20+
def _post(
21+
self, path: str, json_body: dict[str, Any], *, idempotency_key: str | None = None
22+
) -> dict[str, Any]:
23+
return self._client._post(path, json_body, idempotency_key=idempotency_key)
2224

23-
def _delete(self, path: str, json_body: dict[str, Any] | None = None) -> dict[str, Any]:
24-
return self._client._delete(path, json_body)
25+
def _delete(
26+
self, path: str, json_body: dict[str, Any] | None = None, *, idempotency_key: str | None = None
27+
) -> dict[str, Any]:
28+
return self._client._delete(path, json_body, idempotency_key=idempotency_key)
2529

2630
def _location_get(
2731
self, location_id: str | int, path_suffix: str, params: dict | None = None

0 commit comments

Comments
 (0)