ca18cbf7bb
Every one of the five SDKs classified 401, 403, 404, 409, 429 and 501 into a dedicated error class and let 503 fall through to the base one. Go typed 400 as well and still not 503. That inverts the mapping against usefulness. 501 Not Implemented is permanent — the active engine cannot do this and never will, so retrying is pointless — and it had a type everywhere. 503 is the transport failure a caller should retry, and it had none: a caller wanting to retry had to reach past the typed surface and read the raw status off the base error. The timing is what makes it matter now. 0.14.5 turned 503 into the standard answer for "WhatsApp never confirmed the operation" across the engine surface, and 47 of the 189 published operations document one. These error classes were designed when the status barely occurred. Each SDK gains one class or sentinel and one branch. The doc comment says it is retryable, and notes that the gateway deliberately leaves the non-idempotent sends — group create, channel create, media send — unbounded precisely so they never answer a 503 for a caller to retry into a duplicate. Tests cover the classification in JavaScript, Python, Java and Go. The Go case also asserts a 503 does NOT match ErrNotImplemented, since a sentinel that matched both would tell a caller to give up on the retryable one.
111 lines
3.6 KiB
Python
111 lines
3.6 KiB
Python
"""Typed error hierarchy for the OpenWA Python SDK.
|
|
|
|
The OpenWA API returns NestJS-default errors of the shape::
|
|
|
|
{"statusCode": int, "message": str | list[str], "error": str}
|
|
|
|
This module maps that to a typed, ergonomic error tree so callers can
|
|
``isinstance``-check or branch on ``.status``.
|
|
"""
|
|
|
|
from __future__ import annotations
|
|
|
|
from typing import Any
|
|
|
|
|
|
class OpenWAError(Exception):
|
|
"""Base class for every error raised by the SDK."""
|
|
|
|
|
|
class OpenWAApiError(OpenWAError):
|
|
"""Raised when the API responds with a non-2xx status.
|
|
|
|
Attributes:
|
|
status: HTTP status code.
|
|
body: Parsed JSON body if available, otherwise the raw text.
|
|
error_kind: Value of the ``error`` field in the NestJS envelope.
|
|
"""
|
|
|
|
def __init__(self, message: str, status: int, body: Any = None, error_kind: str | None = None) -> None:
|
|
super().__init__(message)
|
|
self.status = status
|
|
self.body = body
|
|
self.error_kind = error_kind
|
|
|
|
@classmethod
|
|
def from_response(cls, status_code: int, text: str, context: str) -> "OpenWAApiError":
|
|
import json
|
|
|
|
body: Any = None
|
|
if text:
|
|
try:
|
|
body = json.loads(text)
|
|
except ValueError:
|
|
body = text
|
|
envelope = body if isinstance(body, dict) and "statusCode" in body else None
|
|
raw_message = envelope.get("message") if envelope else body
|
|
if isinstance(raw_message, list):
|
|
message_text = ", ".join(str(m) for m in raw_message)
|
|
elif isinstance(raw_message, str):
|
|
message_text = raw_message
|
|
else:
|
|
message_text = str(raw_message)
|
|
message = f"OpenWA API {status_code} — {context}: {message_text}"
|
|
return classify(status_code, message, body, envelope.get("error") if envelope else None)
|
|
|
|
|
|
class OpenWAAuthError(OpenWAApiError):
|
|
"""401 Unauthorized — missing or invalid API key."""
|
|
|
|
|
|
class OpenWAForbiddenError(OpenWAApiError):
|
|
"""403 Forbidden — insufficient role."""
|
|
|
|
|
|
class OpenWANotFoundError(OpenWAApiError):
|
|
"""404 Not Found."""
|
|
|
|
|
|
class OpenWAConflictError(OpenWAApiError):
|
|
"""409 Conflict — typically an engine-not-ready condition."""
|
|
|
|
|
|
class OpenWARateLimitError(OpenWAApiError):
|
|
"""429 Too Many Requests."""
|
|
|
|
|
|
class OpenWANotImplementedError(OpenWAApiError):
|
|
"""501 Not Implemented — the active engine does not support this operation."""
|
|
|
|
|
|
class OpenWAServiceUnavailableError(OpenWAApiError):
|
|
"""503 Service Unavailable -- a transport failure, not a refusal.
|
|
|
|
The gateway answers this when the engine did not confirm the operation in time: WhatsApp never
|
|
replied, the socket was down, or the request budget ran out. Retryable, unlike every other typed
|
|
error here. The non-idempotent sends are deliberately left unbounded by the gateway so they never
|
|
answer one.
|
|
"""
|
|
|
|
|
|
class OpenWATimeoutError(OpenWAError):
|
|
"""Raised when a request exceeds the configured timeout."""
|
|
|
|
def __init__(self, timeout: float) -> None:
|
|
super().__init__(f"Request timed out after {timeout}s")
|
|
self.timeout = timeout
|
|
|
|
|
|
def classify(status: int, message: str, body: Any, error_kind: str | None) -> OpenWAApiError:
|
|
"""Pick the most specific :class:`OpenWAApiError` subclass for a status."""
|
|
cls = {
|
|
401: OpenWAAuthError,
|
|
403: OpenWAForbiddenError,
|
|
404: OpenWANotFoundError,
|
|
409: OpenWAConflictError,
|
|
429: OpenWARateLimitError,
|
|
501: OpenWANotImplementedError,
|
|
503: OpenWAServiceUnavailableError,
|
|
}.get(status, OpenWAApiError)
|
|
return cls(message, status, body, error_kind)
|