Short summary
GraphAPI._make_request parses every error response as JSON, so a non-JSON body from Meta's edge surfaces as a raw json.JSONDecodeError instead of a WhatsAppError. Callers that wrap sends in except WhatsAppError do not catch it.
Steps to reproduce
# pywa/api.py:109
if res.status_code >= 400:
raise WhatsAppError.from_dict(error=res.json()["error"], response=res)
res.json() is unguarded, and so is the ["error"] lookup.
Measured on 4.4.0:
import httpx
from pywa import WhatsApp
def probe(label, response):
wa = WhatsApp(phone_id="1", token="t", server=None,
session=httpx.Client(transport=httpx.MockTransport(lambda r: response)))
try:
wa.send_message(to="5511999999999", text="hi")
except Exception as e:
print(f"{label:<22} -> {type(e).__module__}.{type(e).__name__}")
probe("429 JSON", httpx.Response(429, json={"error": {"message": "r", "code": 130429}}))
probe("429 HTML from edge", httpx.Response(429, text="<html>429</html>"))
probe("503 HTML", httpx.Response(503, text="<html>503</html>"))
probe("400 empty body", httpx.Response(400, text=""))
Output:
429 JSON -> pywa.errors.RateLimitHit
429 HTML from edge -> json.decoder.JSONDecodeError
503 HTML -> json.decoder.JSONDecodeError
400 empty body -> json.decoder.JSONDecodeError
Expected behavior
A WhatsAppError (or a dedicated subclass) carrying the status code and the raw body, so that:
except WhatsAppError around a send is actually exhaustive;
- a caller can tell a 429 answered by the edge from a 5xx and decide whether repeating is safe.
This shows up precisely under load — an HTML 429 or 503 from the edge is common exactly when a rate limit is being hit — so the failure mode is "our retry logic stops working when we most need it".
A guard around res.json() and the ["error"] lookup, falling back to a generic error with res.status_code and res.text, would cover it.
Environment
pywa 4.4.0, Python 3.13.
Related: #227 shows the same shape one level down — from_update assumes keys that a real payload may not carry.
Short summary
GraphAPI._make_requestparses every error response as JSON, so a non-JSON body from Meta's edge surfaces as a rawjson.JSONDecodeErrorinstead of aWhatsAppError. Callers that wrap sends inexcept WhatsAppErrordo not catch it.Steps to reproduce
res.json()is unguarded, and so is the["error"]lookup.Measured on 4.4.0:
Output:
Expected behavior
A
WhatsAppError(or a dedicated subclass) carrying the status code and the raw body, so that:except WhatsAppErroraround a send is actually exhaustive;This shows up precisely under load — an HTML 429 or 503 from the edge is common exactly when a rate limit is being hit — so the failure mode is "our retry logic stops working when we most need it".
A guard around
res.json()and the["error"]lookup, falling back to a generic error withres.status_codeandres.text, would cover it.Environment
pywa 4.4.0, Python 3.13.
Related: #227 shows the same shape one level down —
from_updateassumes keys that a real payload may not carry.