Files
Yudhi Armyndharis ca18cbf7bb fix(sdk): give the retryable status a type of its own
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.
2026-08-08 14:03:01 +07:00

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)