litellm - ✅(Solved) Fix [Bug]: AsyncHTTPHandler aiohttp transport fails on non-ASCII response headers [1 pull requests, 1 participants]

Official PRs (…)
ON THIS PAGE

Recommended Tools

×6

Utilities matched from this issue’s tags and category — try them while you read without losing context.

GitHub issue graph ai analysis

Paste a GitHub issue URL. We fetch that issue, discover linked issues from bodies/comments/timeline, collect linked pull requests, and produce a structured English report.

The report is written in English Markdown for sharing and archival.

Helpful · Quick feedback

Loading…
GitHub stats
BerriAI/litellm#26548Fetched 2026-04-27 05:29:38
View on GitHub
Comments
0
Participants
1
Timeline
3
Reactions
0
Author
Participants
Timeline (top)
labeled ×2cross-referenced ×1

Error Message

import asyncio import traceback

import httpx import litellm from litellm.llms.custom_httpx.http_handler import AsyncHTTPHandler

RAW_HEADER_VALUE = "视频生成成功".encode("utf-8") BODY = b"ok"

async def handle_client(reader, writer): await reader.read(4096) response = ( b"HTTP/1.1 200 OK\r\n" b"Content-Length: 2\r\n" b"Content-Type: application/octet-stream\r\n" b"X-Ark-Message: " + RAW_HEADER_VALUE + b"\r\n" b"\r\n" + BODY ) writer.write(response) await writer.drain() writer.close() await writer.wait_closed()

async def standard_httpx_get(url): async with httpx.AsyncClient(follow_redirects=True) as client: return await client.get(url)

async def litellm_handler_get(url): handler = AsyncHTTPHandler() try: return await handler.get(url) finally: await handler.close()

async def run_one(name, func): try: result = await func() header = result.headers.get("x-ark-message") print( f"{name}: OK status={result.status_code} " f"body={result.content!r} header={header!r}" ) except Exception as exc: print(f"{name}: ERROR {type(exc).name}: {exc}") traceback.print_exc(limit=8)

async def main(): litellm.disable_aiohttp_transport = False server = await asyncio.start_server(handle_client, "127.0.0.1", 0) host, port = server.sockets[0].getsockname()[:2] url = f"http://{host}:{port}/asset" print("url", url)

async with server:
    await run_one("standard httpx.AsyncClient", lambda: standard_httpx_get(url))
    await run_one("LiteLLM AsyncHTTPHandler", lambda: litellm_handler_get(url))

asyncio.run(main())

Fix Action

Fixed

PR fix notes

PR #26550: fix(custom_httpx): preserve non-ascii response headers

Description (problem / solution / changelog)

PR body

Relevant issues

Fixes ##26548 async AsyncHTTPHandler aiohttp transport failures when upstream response headers contain non-ASCII values.

Pre-Submission checklist

Delays in PR merge?

If you're seeing a delay in your PR being merged, ping the LiteLLM Team on [Slack (#pr-review)]

CI (LiteLLM team)

CI status guideline:

  • 50-55 passing tests: main is stable with minor issues.
  • 45-49 passing tests: acceptable but needs attention
  • <= 40 passing tests: unstable; be careful with your merges and assess the risk.
  • Branch creation CI run
    Link:

  • CI run for the last commit
    Link:

  • Merge / cherry-pick CI run
    Links:

Screenshots / Proof of Fix

Before

When the aiohttp transport received a response header with a non-ASCII value, LiteLLM failed while constructing an httpx.Response:

LiteLLM AsyncHTTPHandler aiohttp: ERROR UnicodeEncodeError:
'ascii' codec can't encode characters in position 0-5: ordinal not in range(128)

The failure came from passing aiohttp.ClientResponse.headers, which is already decoded to str, into httpx.Response(...). httpx.Headers then attempted to normalize that non-ASCII string using ASCII.

After

LiteLLM now preserves aiohttp's raw response header bytes when constructing the bridged httpx.Response:

LiteLLM AsyncHTTPHandler aiohttp: OK status=200 body=b'ok' header='本地化消息'

The masked error path also preserves raw header bytes when rebuilding httpx.Response for MaskedHTTPStatusError, so non-ASCII response headers do not fail again on HTTP error responses.

Type

🐛 Bug Fix ✅ Test

Changes

  • Use response.raw_headers when converting an aiohttp response into anhttpx.Response in LiteLLMAiohttpTransport.
  • Preserve original_error.response.headers.raw when rebuilding masked httpx.Response objects for MaskedHTTPStatusError.
  • Keep filtering content-encoding and content-length in the masked error path to avoid double-decoding already-decoded response bodies.
  • Add regression coverage for non-ASCII response headers in the aiohttp transport path.
  • Add regression coverage for non-ASCII response headers in the masked HTTP error path.
  • Update aiohttp response test doubles to include raw_headers, matching real aiohttp.ClientResponse objects.

Test Plan

Ran locally during development:

.venv/bin/python -m pytest tests/test_litellm/llms/custom_httpx/test_aiohttp_transport.py -q
16 passed

.venv/bin/python -m pytest tests/test_litellm/llms/custom_httpx/test_credential_leak_prevention.py -q
28 passed

.venv/bin/python -m pytest tests/test_litellm/test_streaming_connection_cleanup.py -q
8 passed

env BLACK_CACHE_DIR=/tmp/black-cache .venv/bin/python -m black --check \
  litellm/llms/custom_httpx/aiohttp_transport.py \
  litellm/llms/custom_httpx/http_handler.py \
  tests/test_litellm/llms/custom_httpx/test_aiohttp_transport.py \
  tests/test_litellm/llms/custom_httpx/test_credential_leak_prevention.py \
  tests/test_litellm/test_streaming_connection_cleanup.py
5 files would be left unchanged.

git diff --check -- \
  litellm/llms/custom_httpx/aiohttp_transport.py \
  litellm/llms/custom_httpx/http_handler.py \
  tests/test_litellm/llms/custom_httpx/test_aiohttp_transport.py \
  tests/test_litellm/llms/custom_httpx/test_credential_leak_prevention.py \
  tests/test_litellm/test_streaming_connection_cleanup.py
exit 0

Also validated with the project commands:

make install-dev
make format
make lint
make install-test-deps
make test-unit

Changed files

  • docs/my-website/docs/proxy/guardrails/xecguard.md (added, +314/-0)
  • docs/my-website/sidebars.js (modified, +1/-0)
  • litellm/exceptions.py (modified, +30/-1)
  • litellm/integrations/custom_guardrail.py (modified, +1/-37)
  • litellm/litellm_core_utils/prompt_templates/factory.py (modified, +19/-4)
  • litellm/llms/bedrock/chat/converse_transformation.py (modified, +2/-2)
  • litellm/llms/bedrock/messages/invoke_transformations/anthropic_claude3_transformation.py (modified, +7/-1)
  • litellm/llms/custom_httpx/aiohttp_transport.py (modified, +1/-1)
  • litellm/llms/custom_httpx/http_handler.py (modified, +5/-5)
  • litellm/llms/ollama/chat/transformation.py (modified, +7/-2)
  • litellm/llms/predibase/chat/handler.py (modified, +43/-228)
  • litellm/llms/predibase/chat/transformation.py (modified, +212/-7)
  • litellm/proxy/guardrails/guardrail_hooks/unified_guardrail/unified_guardrail.py (modified, +10/-0)
  • litellm/proxy/guardrails/guardrail_hooks/xecguard/__init__.py (added, +45/-0)
  • litellm/proxy/guardrails/guardrail_hooks/xecguard/xecguard.py (added, +591/-0)
  • litellm/proxy/pass_through_endpoints/pass_through_endpoints.py (modified, +73/-5)
  • litellm/router.py (modified, +4/-2)
  • litellm/types/guardrails.py (modified, +5/-0)
  • litellm/types/llms/ollama.py (modified, +1/-0)
  • litellm/types/proxy/guardrails/guardrail_hooks/xecguard.py (added, +77/-0)
  • tests/test_litellm/litellm_core_utils/prompt_templates/test_litellm_core_utils_prompt_templates_factory.py (modified, +109/-0)
  • tests/test_litellm/llms/bedrock/messages/invoke_transformations/test_anthropic_claude3_transformation.py (modified, +80/-0)
  • tests/test_litellm/llms/custom_httpx/test_aiohttp_transport.py (modified, +48/-0)
  • tests/test_litellm/llms/custom_httpx/test_credential_leak_prevention.py (modified, +18/-0)
  • tests/test_litellm/llms/ollama/test_ollama_chat_transformation.py (modified, +95/-0)
  • tests/test_litellm/llms/test_predibase_transformation.py (added, +612/-0)
  • tests/test_litellm/proxy/guardrails/guardrail_hooks/test_xecguard.py (added, +1904/-0)
  • tests/test_litellm/proxy/pass_through_endpoints/test_passthrough_post_call_guardrails.py (added, +276/-0)
  • tests/test_litellm/test_router.py (modified, +63/-0)
  • tests/test_litellm/test_streaming_connection_cleanup.py (modified, +2/-0)
  • ui/litellm-dashboard/public/assets/logos/xecguard.svg (added, +4/-0)
  • ui/litellm-dashboard/src/components/guardrails/guardrail_garden_configs.ts (modified, +6/-0)
  • ui/litellm-dashboard/src/components/guardrails/guardrail_garden_data.ts (modified, +10/-0)
  • ui/litellm-dashboard/src/components/guardrails/guardrail_info_helpers.tsx (modified, +2/-0)

Code Example

return httpx.Response(
    status_code=response.status,
    headers=response.headers,
    stream=AiohttpResponseStream(response),
    request=request,
)

---

import asyncio
import traceback

import httpx
import litellm
from litellm.llms.custom_httpx.http_handler import AsyncHTTPHandler

RAW_HEADER_VALUE = "视频生成成功".encode("utf-8")
BODY = b"ok"


async def handle_client(reader, writer):
    await reader.read(4096)
    response = (
        b"HTTP/1.1 200 OK\r\n"
        b"Content-Length: 2\r\n"
        b"Content-Type: application/octet-stream\r\n"
        b"X-Ark-Message: " + RAW_HEADER_VALUE + b"\r\n"
        b"\r\n" + BODY
    )
    writer.write(response)
    await writer.drain()
    writer.close()
    await writer.wait_closed()


async def standard_httpx_get(url):
    async with httpx.AsyncClient(follow_redirects=True) as client:
        return await client.get(url)


async def litellm_handler_get(url):
    handler = AsyncHTTPHandler()
    try:
        return await handler.get(url)
    finally:
        await handler.close()


async def run_one(name, func):
    try:
        result = await func()
        header = result.headers.get("x-ark-message")
        print(
            f"{name}: OK status={result.status_code} "
            f"body={result.content!r} header={header!r}"
        )
    except Exception as exc:
        print(f"{name}: ERROR {type(exc).__name__}: {exc}")
        traceback.print_exc(limit=8)


async def main():
    litellm.disable_aiohttp_transport = False
    server = await asyncio.start_server(handle_client, "127.0.0.1", 0)
    host, port = server.sockets[0].getsockname()[:2]
    url = f"http://{host}:{port}/asset"
    print("url", url)

    async with server:
        await run_one("standard httpx.AsyncClient", lambda: standard_httpx_get(url))
        await run_one("LiteLLM AsyncHTTPHandler", lambda: litellm_handler_get(url))


asyncio.run(main())

---

standard httpx.AsyncClient: OK status=200 body=b'ok' header='视频生成成功'

LiteLLM AsyncHTTPHandler: ERROR UnicodeEncodeError: 'ascii' codec can't encode characters in position 0-5: ordinal not in range(128)

Traceback (most recent call last):
  File ".../litellm/llms/custom_httpx/http_handler.py", line 498, in get
    response = await self.client.get(
  File ".../httpx/_client.py", line 1730, in _send_single_request
    response = await transport.handle_async_request(request)
  File ".../litellm/llms/custom_httpx/aiohttp_transport.py", line 342, in handle_async_request
    return httpx.Response(
  File ".../httpx/_models.py", line 532, in __init__
    self.headers = Headers(headers)
  File ".../httpx/_models.py", line 82, in _normalize_header_value
    return value.encode(encoding or "ascii")
UnicodeEncodeError: 'ascii' codec can't encode characters in position 0-5: ordinal not in range(128)

Real Volcengine Ark asset URL evidence:


video_url host ark-acg-cn-beijing.tos-cn-beijing.volces.com
video_url standard_status 200
video_url content_type video/mp4
video_url non_ascii_header_count 1
video_url non_ascii_header x-tos-expiration 'expiry-date="Fri, 23 Oct 2026 16:00:00 GMT", rule-id="180day自动清理"'

last_frame_url host ark-acg-cn-beijing.tos-cn-beijing.volces.com
last_frame_url standard_status 200
last_frame_url content_type image/jpeg
last_frame_url non_ascii_header_count 1
last_frame_url non_ascii_header x-tos-expiration 'expiry-date="Fri, 23 Oct 2026 16:00:00 GMT", rule-id="180day自动清理"'

video_url litellm_async_error UnicodeEncodeError 'ascii' codec can't encode characters in position 60-63: ordinal not in range(128)
last_frame_url litellm_async_error UnicodeEncodeError 'ascii' codec can't encode characters in position 60-63: ordinal not in range(128)
RAW_BUFFERClick to expand / collapse

Check for existing issues

  • I have searched the existing issues and checked that my issue is not a duplicate.

What happened?

LiteLLM's AsyncHTTPHandler fails when the default aiohttp transport receives a response header whose value contains non-ASCII characters, such as Chinese text.

Expected behavior: the async HTTP handler should successfully return the response, matching standard httpx.AsyncClient behavior.

Actual behavior: the request fails while the LiteLLM aiohttp bridge constructs an httpx.Response from the aiohttp response headers.

The failure happens in litellm/llms/custom_httpx/aiohttp_transport.py when passing response.headers into httpx.Response(...):

return httpx.Response(
    status_code=response.status,
    headers=response.headers,
    stream=AiohttpResponseStream(response),
    request=request,
)

aiohttp has already decoded the header value into a Python str containing non-ASCII characters. httpx.Headers then attempts to normalize that value using ASCII and raises UnicodeEncodeError.

This can affect providers or asset download URLs that return localized/non-ASCII response headers. One concrete case is Volcengine Ark video asset URLs: both the MP4 video_url and last_frame_url have been observed returning a Chinese value in the x-tos-expiration response header.

Steps to Reproduce

  1. Run a local raw HTTP server that returns a UTF-8 Chinese response header:
import asyncio
import traceback

import httpx
import litellm
from litellm.llms.custom_httpx.http_handler import AsyncHTTPHandler

RAW_HEADER_VALUE = "视频生成成功".encode("utf-8")
BODY = b"ok"


async def handle_client(reader, writer):
    await reader.read(4096)
    response = (
        b"HTTP/1.1 200 OK\r\n"
        b"Content-Length: 2\r\n"
        b"Content-Type: application/octet-stream\r\n"
        b"X-Ark-Message: " + RAW_HEADER_VALUE + b"\r\n"
        b"\r\n" + BODY
    )
    writer.write(response)
    await writer.drain()
    writer.close()
    await writer.wait_closed()


async def standard_httpx_get(url):
    async with httpx.AsyncClient(follow_redirects=True) as client:
        return await client.get(url)


async def litellm_handler_get(url):
    handler = AsyncHTTPHandler()
    try:
        return await handler.get(url)
    finally:
        await handler.close()


async def run_one(name, func):
    try:
        result = await func()
        header = result.headers.get("x-ark-message")
        print(
            f"{name}: OK status={result.status_code} "
            f"body={result.content!r} header={header!r}"
        )
    except Exception as exc:
        print(f"{name}: ERROR {type(exc).__name__}: {exc}")
        traceback.print_exc(limit=8)


async def main():
    litellm.disable_aiohttp_transport = False
    server = await asyncio.start_server(handle_client, "127.0.0.1", 0)
    host, port = server.sockets[0].getsockname()[:2]
    url = f"http://{host}:{port}/asset"
    print("url", url)

    async with server:
        await run_one("standard httpx.AsyncClient", lambda: standard_httpx_get(url))
        await run_one("LiteLLM AsyncHTTPHandler", lambda: litellm_handler_get(url))


asyncio.run(main())
  1. Observe that standard httpx.AsyncClient succeeds.
  2. Observe that LiteLLM AsyncHTTPHandler fails when aiohttp transport is enabled.
  3. Optionally set litellm.disable_aiohttp_transport = True and observe that LiteLLM AsyncHTTPHandler succeeds with the httpx transport.

Relevant log output

standard httpx.AsyncClient: OK status=200 body=b'ok' header='视频生成成功'

LiteLLM AsyncHTTPHandler: ERROR UnicodeEncodeError: 'ascii' codec can't encode characters in position 0-5: ordinal not in range(128)

Traceback (most recent call last):
  File ".../litellm/llms/custom_httpx/http_handler.py", line 498, in get
    response = await self.client.get(
  File ".../httpx/_client.py", line 1730, in _send_single_request
    response = await transport.handle_async_request(request)
  File ".../litellm/llms/custom_httpx/aiohttp_transport.py", line 342, in handle_async_request
    return httpx.Response(
  File ".../httpx/_models.py", line 532, in __init__
    self.headers = Headers(headers)
  File ".../httpx/_models.py", line 82, in _normalize_header_value
    return value.encode(encoding or "ascii")
UnicodeEncodeError: 'ascii' codec can't encode characters in position 0-5: ordinal not in range(128)

Real Volcengine Ark asset URL evidence:


video_url host ark-acg-cn-beijing.tos-cn-beijing.volces.com
video_url standard_status 200
video_url content_type video/mp4
video_url non_ascii_header_count 1
video_url non_ascii_header x-tos-expiration 'expiry-date="Fri, 23 Oct 2026 16:00:00 GMT", rule-id="180day自动清理"'

last_frame_url host ark-acg-cn-beijing.tos-cn-beijing.volces.com
last_frame_url standard_status 200
last_frame_url content_type image/jpeg
last_frame_url non_ascii_header_count 1
last_frame_url non_ascii_header x-tos-expiration 'expiry-date="Fri, 23 Oct 2026 16:00:00 GMT", rule-id="180day自动清理"'

video_url litellm_async_error UnicodeEncodeError 'ascii' codec can't encode characters in position 60-63: ordinal not in range(128)
last_frame_url litellm_async_error UnicodeEncodeError 'ascii' codec can't encode characters in position 60-63: ordinal not in range(128)

What part of LiteLLM is this about?

Proxy

What LiteLLM version are you on ?

v1.83.13

Twitter / LinkedIn details

No response

extent analysis

TL;DR

The issue can be resolved by modifying the httpx.Response initialization to handle non-ASCII characters in the response headers.

Guidance

  • The error occurs because httpx.Headers attempts to normalize the header value using ASCII, which fails for non-ASCII characters.
  • To fix this, the httpx.Response initialization should be modified to handle non-ASCII characters in the response headers, potentially by using a different encoding or by manually encoding the header values.
  • The aiohttp library has already decoded the header value into a Python str containing non-ASCII characters, so the issue lies in the httpx library's handling of these characters.
  • The provided code snippet can be used to reproduce the issue and test potential fixes.

Example

# Potential fix: manually encode the header values using UTF-8
return httpx.Response(
    status_code=response.status,
    headers={k: v.encode('utf-8') for k, v in response.headers.items()},
    stream=AiohttpResponseStream(response),
    request=request,
)

Note: This example is a potential fix, but it may not be the final solution and may require further modifications.

Notes

  • The issue is specific to the aiohttp transport in LiteLLM, and disabling it allows the httpx transport to handle the request successfully.
  • The provided code snippet and log output suggest that the issue is related to the handling of non-ASCII characters in the response headers.

Recommendation

Apply workaround: modify the httpx.Response initialization to handle non-ASCII characters in the response headers, as shown in the example above. This should allow the AsyncHTTPHandler to successfully return the response.

Vote matrix · Quick signals

Works
Did the solution work? Tap to confirm.
Easy Fix
Was it a quick fix?
Time Saver
Did it save you time?
Blocking
Was it severely blocking?
Common Issue
Are others likely hitting this too?
Flaky / Intermittent
Is it intermittent?
Verified / Reproducible
Can you reproduce it reliably?
Loading…

Still need to ship something?

×6

Another batch ranked right after the header list — different links, same matching logic.

Back to top recommendations

TRENDING