Skip to content

Bug: non-JSON error response raises JSONDecodeError instead of WhatsAppError #230

Description

@geanportugal

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.

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions