Skip to content

[4.41/W2] WhatsApp: send_template and typed API errors #237

Description

@patrick-chinchill

Summary

Add two public WhatsApp APIs upstream shipped in 4.34 and 4.40. send_template() sends pre-approved template messages, the only type the Cloud API accepts outside the 24-hour customer-service window, so business-initiated flows need it. WhatsAppApiError is a typed AdapterError subclass exposing Meta's error envelope (code, subcode, fbtrace_id, details) mapped onto the shared AdapterError.code taxonomy, so callers stop parsing error strings.

Upstream changes

  • 2338a665 feat(whatsapp): add sendTemplate for pre-approved template messages (#588) — chat@4.34.0 — sendTemplate(threadId, template) posts type: "template" with template: {name, language: {code}, components?}; components only when non-empty; emoji placeholders converted in text parameters only (payloads, URLs, media refs stay literal); throws when the response has no message id. New types WhatsAppTemplateMessage, WhatsAppTemplateComponent, WhatsAppTemplateParameter, WhatsAppTemplateButtonParameter. Since 3e6e866a (4.39.0) it spreads ...recipient() instead of to.
  • 31bce0a7 feat(whatsapp): expose typed API errors (#896) — chat@4.40.0
    • New errors.ts exports WhatsAppApiError extends AdapterError, with fields status, errorCode, providerMessage, type, details, subcode, traceId and raw.
    • The message is "{label}: {status} {error.message ?? body}", with non-JSON bodies truncated to 500 chars in the message.
    • Taxonomy:
      • RATE_LIMITED: 429 or codes 4/17/32/613/80007/130429/131048/131056.
      • AUTH_FAILED: 401 or codes 0/190.
      • PERMISSION_DENIED: 403, codes 3/10, or codes 200–299.
      • NOT_FOUND: 404.
    • New graphFetchJson wraps transport failures and non-JSON success bodies as NetworkError. It is used by message sends, media upload and media metadata GETs.

Current Python behavior

  • grep -rniI 'send_template\|WhatsAppTemplate' src/chat_sdk/adapters/whatsapp finds nothing. The only send_template hits are Messenger's unrelated _send_template_message (messenger/adapter.py:479,534).
  • src/chat_sdk/adapters/whatsapp/adapter.py:1122-1148 _graph_api_request treats any status other than 200 as an error, raises a plain AdapterError(f"WhatsApp API error: {status} {body}", "whatsapp") with no code, and lets transport (aiohttp.ClientError) and JSON decode errors escape raw.
  • adapter.py:680-750 download_media raises RuntimeError(f"Failed to get media URL: ...") for a failed metadata GET (:704) and RuntimeError(f"Failed to download media: ...") (:747).
  • The shared error base is at src/chat_sdk/shared/errors.py:6-12: AdapterError(message, adapter, code=None). NetworkError is at :72-81.

Scope

  • whatsapp/types.py:
    • Add snake_case TypedDicts that mirror the wire format: WhatsAppTemplateParameter, covering the text/currency/date_time/image/document/video variants.
    • Add WhatsAppTemplateButtonParameter (text/payload), WhatsAppTemplateComponent (header/body, or button with sub_type and index) and WhatsAppTemplateMessage (name, language, optional components).
    • Add WhatsAppGraphError / WhatsAppGraphErrorBody.
    • Add "template" to the documented inbound type values.
  • New src/chat_sdk/adapters/whatsapp/errors.py: WhatsAppApiError(AdapterError) with status, error_code, provider_message, type, details, subcode, trace_id and raw. It also holds the private _parse_graph_error, _integer and _taxonomy_code helpers. Export WhatsAppApiError from chat_sdk.adapters.whatsapp.
  • adapter.py: add a _graph_fetch_json(method, url, *, label, context, json=None, data=None, headers) helper.
    • An aiohttp.ClientError or asyncio.TimeoutError becomes NetworkError("whatsapp", f"{label}: request failed", original_error=...).
    • A non-2xx response becomes WhatsAppApiError(label, status, body_text).
    • A JSON decode failure on a 2xx response becomes NetworkError(... "response was not valid JSON").
    • Route _graph_api_request (label "WhatsApp API error") and the download_media metadata GET (label "Failed to get media URL") through it.
  • adapter.py: add async def send_template(self, thread_id: str, template: WhatsAppTemplateMessage) -> RawMessage.
  • Mention send_template in the open_dm docstring, as upstream does.
  • Record in docs/UPSTREAM_SYNC.md the snake_case field names on WhatsAppApiError (error_code, trace_id, …) against upstream's camelCase.

Out of scope

Porting notes

  • Keep the except AdapterError compatibility. WhatsAppApiError subclasses AdapterError(message, "whatsapp", code). The message format changes slightly when Meta returns JSON: upstream now uses error.message instead of the raw JSON body. Match upstream and call it out in the CHANGELOG.
  • _integer() must reject bool, because isinstance(True, int) is true in Python. Accept only int, or a str matching ^-?\d+$. Reject floats, including 4.0, to match Number.isInteger combined with the string regex. Code 0 is valid: test "preserves error code zero without requiring optional fields". Use is not None checks and never truthiness.
  • raw holds the parsed JSON when the body parses, and the original text otherwise. Only the message is truncated to 500 chars plus ….
  • Status check: upstream uses !response.ok, which is 2xx. Python checks != 200, so switch it to 200 <= status < 300. That is a small behavior change for a 201/204 response; note it.
  • For components, use if template.get("components"):, which matches components?.length. Build new dicts and do not mutate the caller's template.
  • Emoji conversion: convert_emoji_placeholders(text, "whatsapp") applies only to parameters with type == "text", in body/header and button components alike. Do not JSON-dump the whole template the way cards do, because that would rewrite :payload:-like strings.

Tests

Upstream packages/adapter-whatsapp/src/index.test.ts and errors.test.ts are not fidelity-mapped. Port into tests/test_whatsapp_api.py and a new tests/test_whatsapp_errors.py:

  • describe("sendTemplate"): "sends a template with name and language", "includes components when provided", "converts emoji placeholders in text parameters", "does not emoji-convert quick reply payloads", "omits components when the array is empty", "throws when the API returns no message ID", "throws on invalid thread ID", "sends templates to BSUID recipients" (from 3e6e866a).
  • describe("WhatsAppApiError"): "preserves the error contract and raw Meta response", "maps status $status and Meta code $code to $expected" (all 11 upstream rows), "accepts numeric strings from proxies in front of the Cloud API", "retains non-JSON response %j without masking the HTTP error", "bounds a long non-JSON body in the message and keeps it whole in raw", "falls back to the body when Meta omits a message", "ignores invalid field shapes in %j", "preserves error code zero without requiring optional fields".
  • describe("API errors"): "preserves Meta error fields for $name", "wraps transport failures for $name", "wraps non-JSON success bodies for $name", parametrized over message sends and media metadata requests. The media uploads row lands in [4.41/W3] WhatsApp: outbound files/media, CTA URL LinkButton, no duplicate card title #238.
  • Python-specific: True and 4.0 as code give error_code is None; isinstance(err, AdapterError) holds. Use AsyncMock/async context-manager session mocks, no real network.

Acceptance criteria

  • send_template sends the upstream payload shape, uses the [4.41/W1] WhatsApp: business-scoped user ids (BSUID) and inbound context variants #236 recipient routing, and is exported and documented.
  • Every Graph JSON call raises WhatsAppApiError on non-2xx and NetworkError on transport or JSON failure. Existing except AdapterError handlers still catch it.
  • Full validation command from CLAUDE.md passes.
  • docs/UPSTREAM_SYNC.md updated (field-name casing, status-range change).
  • CHANGELOG entry under an "Unreleased (4.41 wave)" heading.
  • Consumer-visible changes called out:
    • The error message text changes when Meta returns JSON.
    • download_media metadata failures now raise WhatsAppApiError, where they used to raise RuntimeError.

Dependencies

Blocked by #236. Blocks #238.

Metadata

  • Effort: M (200-700 LOC)
  • Consumer impact: none for Slack/Teams. Low for WhatsApp: the exception type and message change for Graph API failures.
  • Suggested branch: sync/4.41-w2

Part of #184.

Metadata

Metadata

Assignees

No one assigned

    Type

    No type

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions