twilio / twilio/twilio-python

AsyncTwilioHttpClient does not JSON-serialize request bodies for application/json endpoints (sync client does) → 400 on JSON-body v2 endpoints

Open Beginner friendly
#932 0 comments 0 reactions 0 assignees View on GitHub

Nobody has claimed this yet.

Dominant language
Python
Stars
2.1k
Forks
815
Avg merge
2d 13h
Merged PRs (30d)
2

Description

Environment
  • twilio (twilio-python): 9.10.9
  • Python: 3.11.15
  • aiohttp: 3.13.5
Description

The async HTTP client (AsyncTwilioHttpClient) is missing the Content-Type: application/json branch that the sync client has, so for any endpoint that sends a JSON request body it passes the body dict to aiohttp's data= (which form-encodes it) while still sending Content-Type: application/json. Twilio receives a body that isn't valid JSON and rejects it with 400 "Request input may be invalid".

This breaks every newer v2 JSON-body endpoint on the async client. Concrete case: client.messaging.v2.channels_senders(sid).update_async(...) (update a WhatsApp sender's business profile). The identical call on the sync client succeeds.

Root cause

Sync client serializes JSON correctly — twilio/http/http_client.py:

if headers and headers.get("Content-Type") == "application/json":
    kwargs["json"] = data          # real JSON body
elif headers and headers.get("Content-Type") == "application/scim+json":
    kwargs["json"] = data
else:
    kwargs["data"] = data

Async client has no such branch — twilio/http/async_http_client.py (request):

kwargs = {
    "method": method.upper(),
    "url": url,
    "params": params,
    "data": data,                  # always form-encoded, even for application/json
    "headers": headers,
    ...
}
...
response = await session.request(**kwargs)

The generated code (e.g. messaging/v2/channels_sender.py::_update_async) correctly sets headers["Content-Type"] = "application/json" and passes data = <dict>, relying on the transport to JSON-encode — which the async client never does.

Reproduction
import asyncio
from twilio.rest import Client
from twilio.http.async_http_client import AsyncTwilioHttpClient
from twilio.rest.messaging.v2.channels_sender import ChannelsSenderList as CSL

async def main():
    client = Client(SID, TOKEN, http_client=AsyncTwilioHttpClient())
    body = CSL.MessagingV2ChannelsSenderRequestsUpdate(
        {"profile": CSL.MessagingV2ChannelsSenderProfile({"logo_url": "https://example.com/x.jpg"})}
    )
    await client.messaging.v2.channels_senders("XE...").update_async(body)  # -> 400

asyncio.run(main())

Result: TwilioRestException(400, '/Channels/Senders/XE...', 'Unable to update record: Request input may be invalid...').

Inspecting the captured request (http_client._test_only_last_request): data is a dict ({'profile': {'logo_url': '...'}}), not a JSON string, with Content-Type: application/json. The same update(body) on the sync TwilioHttpClient returns 200.

Expected

The async client should mirror the sync client: when Content-Type is application/json (or application/scim+json), send the body via aiohttp's json= rather than data=.

Suggested fix

In AsyncTwilioHttpClient.request, replace the unconditional "data": data with the same content-type branch the sync client uses:

if headers and headers.get("Content-Type") in ("application/json", "application/scim+json"):
    kwargs["json"] = data
else:
    kwargs["data"] = data
Impact

Any async call to a JSON-body endpoint fails (Channels Senders create/update, and other v2 resources that send a JSON schema body). Form-encoded endpoints (Messages, Calls, Lookups, etc.) are unaffected. Current workaround: send the request directly (e.g. httpx/aiohttp with json=), which is what the sync client does internally.

Contributor guide

Open the contributing guide

First steps

  1. Read the whole issue, then the project's contributing guide.
  2. Comment on the issue to say you are picking it up — it saves two people doing the same work.
  3. Fork the repository and make your change on a branch.
  4. Open a pull request that references the issue number.

Research direction

Start in twilio/http/async_http_client.py at AsyncTwilioHttpClient.request, then compare its request construction with twilio/http/http_client.py. Verify the async client handles application/json and application/scim+json bodies like the sync client, while form-encoded endpoints remain unaffected; reproduce with the Channels Senders update_async example and confirm the request body is valid JSON.

Written by the indexing model from the issue text.

Assessment

Tech stack
python
Domain
api
Issue type
Bug
Difficulty
2/5
Estimated time
1-3 hours
Activity status
Quiet
Clarity
Clearly specified
Newbie friendliness
82/100

Get new issues in your inbox

A short digest of beginner-friendly GitHub issues.