Jump to solution
Verify

The Fix

Fix headers being mutated if passed to web.Response as a CIMultiDict.

Based on closed aio-libs/aiohttp issue #10670 · PR/commit linked

Jump to Verify Open PR/Commit
@@ -0,0 +1 @@ @@ -0,0 +1 @@ +Fixed :class:`multidict.CIMultiDict` being mutated when passed to :class:`aiohttp.web.Response` -- by :user:`bdraco`. diff --git a/aiohttp/web_response.py b/aiohttp/web_response.py index d1bb401a5e6..56596905a35 100644
repro.py
import asyncio import json import random import aiohttp from aiohttp import web, ClientSession from multidict import CIMultiDict default_headers = CIMultiDict({"Content-Type": "application/json"}) async def handle(request): print(aiohttp.__version__) print(default_headers) return web.Response( text=json.dumps({"message": random.randbytes(random.randint(1, 1000)).hex()}), headers=default_headers ) async def run_server(): app = web.Application() app.router.add_get("/", handle) runner = web.AppRunner(app) await runner.setup() site = web.TCPSite(runner, "localhost", 8080) await site.start() async def run_client(): async with ClientSession() as session: for i in range(10): await asyncio.sleep(1) async with session.get("http://localhost:8080") as resp: print(await resp.json()) async def main(): server_task = asyncio.create_task(run_server()) client_task = asyncio.create_task(run_client()) await client_task # Run client once server_task.cancel() # Stop server if __name__ == "__main__": asyncio.run(main())
verify
Re-run the minimal reproduction on your broken version, then apply the fix and re-run.
fix.md
Option A — Apply the official fix\nFix headers being mutated if passed to web.Response as a CIMultiDict.\nWhen NOT to use: Do not use this fix if headers should remain mutable for other responses.\n\nOption C — Workaround\nis to pass dict instead of CIMultiDict\nWhen NOT to use: Do not use this fix if headers should remain mutable for other responses.\n\n

Why This Fix Works in Production

  • Trigger: 3.11.15
  • Mechanism: Fix headers being mutated if passed to web.Response as a CIMultiDict.
Production impact:
  • If left unfixed, this can cause silent data inconsistencies that propagate (bad cache entries, incorrect downstream decisions).

Why This Breaks in Prod

  • Shows up under Python 3.13 in real deployments (not just unit tests).
  • Surfaces as: 3.11.15

Proof / Evidence

Discussion

High-signal excerpts from the issue thread (symptoms, repros, edge-cases).

“I am using the same version of multidict==6.3.0”
@Reskov · 2025-04-01 · source
“OK, maybe it's related to a performance improvement recently. @bdraco?”
@Dreamsorcerer · 2025-04-01 · source
“I don't see anything that'd cause that though”
@Dreamsorcerer · 2025-04-01 · source
“Right, so it's been there for some time then. Looks like that could be the cause. At a quick glance, looks like we may need…”
@Dreamsorcerer · 2025-04-01 · source

Failure Signature (Search String)

  • 3.11.15

Error Message

Stack trace
error.txt
Error Message ------------- 3.11.15 <CIMultiDict('Content-Type': 'application/json')> {'message': '7fd6a48fe3d8'} 3.11.15 <CIMultiDict('Content-Type': 'application/json; charset=utf-8', 'Content-Length': '1763', 'Date': 'Tue, 01 Apr 2025 13:08:07 GMT', 'Server': 'Python/3.13 aiohttp/3.11.15')> Traceback (most recent call last): File "/src/auth/.venv/lib/python3.13/site-packages/aiohttp/client_proto.py", line 264, in data_received messages, upgraded, tail = self._parser.feed_data(data) ~~~~~~~~~~~~~~~~~~~~~~^^^^^^ File "aiohttp/_http_parser.pyx", line 558, in aiohttp._http_parser.HttpParser.feed_data aiohttp.http_exceptions.BadHttpMessage: 400, message: Expected HTTP/: b'{

Minimal Reproduction

repro.py
import asyncio import json import random import aiohttp from aiohttp import web, ClientSession from multidict import CIMultiDict default_headers = CIMultiDict({"Content-Type": "application/json"}) async def handle(request): print(aiohttp.__version__) print(default_headers) return web.Response( text=json.dumps({"message": random.randbytes(random.randint(1, 1000)).hex()}), headers=default_headers ) async def run_server(): app = web.Application() app.router.add_get("/", handle) runner = web.AppRunner(app) await runner.setup() site = web.TCPSite(runner, "localhost", 8080) await site.start() async def run_client(): async with ClientSession() as session: for i in range(10): await asyncio.sleep(1) async with session.get("http://localhost:8080") as resp: print(await resp.json()) async def main(): server_task = asyncio.create_task(run_server()) client_task = asyncio.create_task(run_client()) await client_task # Run client once server_task.cancel() # Stop server if __name__ == "__main__": asyncio.run(main())

Environment

  • Python: 3.13

What Broke

Responses failed due to incorrect Content-Length, leading to BadHttpMessage errors.

Fix Options (Details)

Option A — Apply the official fix

Fix headers being mutated if passed to web.Response as a CIMultiDict.

When NOT to use: Do not use this fix if headers should remain mutable for other responses.

Option C — Workaround Temporary workaround

is to pass dict instead of CIMultiDict

When NOT to use: Do not use this fix if headers should remain mutable for other responses.

Use only if you cannot change versions today. Treat this as a stopgap and remove once upgraded.

Fix reference: https://github.com/aio-libs/aiohttp/pull/10672

Last verified: 2026-02-09. Validate in your environment.

Get updates

We publish verified fixes weekly. No spam.

Subscribe

When NOT to Use This Fix

  • Do not use this fix if headers should remain mutable for other responses.

Verify Fix

verify
Re-run the minimal reproduction on your broken version, then apply the fix and re-run.

Did This Fix Work in Your Case?

Quick signal helps us prioritize which fixes to verify and improve.

Prevention

  • Capture the exact failing error string in logs and tests so you can reproduce via a minimal script.
  • Pin production dependencies and upgrade only with a reproducible test that hits the failing path.

Version Compatibility Table

VersionStatus
3.11.8 Broken

Related Issues

No related fixes found.

Sources

We don’t republish the full GitHub discussion text. Use the links above for context.