aiohttp: SSE consumer dies permanently on "ValueError: Chunk too big" from oversized line
An aiohttp SSE consumer that iterates async for line in resp.content dies permanently the first time one line exceeds the StreamReader high-water mark, raising ValueError: Chunk too big. If the consumer resumes with Last-Event-ID (or any stored offset) that it only advances after fully parsing an event, the reconnect replays the same oversized event and crashes again, forever: a self-inflicted poison-pill loop that outlives process supervision and looks exactly like an upstream outage.
Observed on Wikimedia EventStreams (https://stream.wikimedia.org/v2/stream/recentchange) with aiohttp 3.13.3: a single 157,903-byte data: line (a Commons file upload carrying huge metadata) wedged the consumer for 71 hours, emitting ~344,000 identical tracebacks, while WebSocket clients stayed connected to a stream delivering nothing. Normal recentchange lines are 1-3 KiB, so a live sample of the stream shows nothing wrong (12,200 lines sampled, max 2,880 bytes) — the poison event is only visible by replaying history with ?since=<ISO timestamp>.
Diagnostic tells:
- Log pattern is
Connected-> error within <1s -> reconnect, at a fixed 1-2s cadence, indefinitely. Backoff never grows because the code resets it on a successful connect, and the crash happens after connecting. - A stats/heartbeat counter shows
secs_since_last_eventclimbing into the hundreds of thousands while the process uptime is months. - The proxy in front still completes the WebSocket handshake (HTTP 101), so every liveness check on the socket passes. Only payload flow is dead.
- Restarting clears it if the resume point is in-memory only, which makes the outage look transient and hides the bug (it had self-healed once before this way).
Root cause: async for line in resp.content routes through StreamReader.readuntil(), which contains if chunk_size > self._high_water: raise ValueError("Chunk too big"). _high_water is 2 * read_bufsize, i.e. 131072 bytes with the 65536 default. Any single line longer than that is unreadable, not merely slow. Bumping read_bufsize only moves the ceiling and leaves the same cliff.
Fix: stop using aiohttp's line iteration for SSE and split lines yourself over content.iter_any() (which has no limit check), with a cap high enough to be irrelevant and a drop-and-resync path instead of an exception:
MAX_SSE_LINE_BYTES = 4 * 1024 * 1024
async def iter_sse_lines(content):
buf = bytearray()
dropping = False
async for chunk in content.iter_any():
buf += chunk
while True:
nl = buf.find(b'\n')
if nl < 0:
break
line = bytes(buf[:nl])
del buf[:nl + 1]
if dropping:
dropping = False
yield None # tail of a dropped line; resync here
else:
yield line
if len(buf) > MAX_SSE_LINE_BYTES:
buf.clear()
dropping = TrueSecond half of the fix, and the part that actually kills the loop: when a line is dropped (None), advance the resume point anyway. In SSE the id: field precedes data: within an event, so the id of the poison event is already in hand:
async for line_bytes in iter_sse_lines(resp.content):
if line_bytes is None:
if event_id:
self._last_event_id = event_id # never replay it again
event_data = None
continueWithout that, any future unreadable event re-establishes the same permanent loop.
Verification without waiting for another giant event: feed a real aiohttp.streams.StreamReader(limit=2**16) a 157,903-byte line plus a short one. Native async for raises ValueError: Chunk too big; the generator returns [157903, 11]. To find the historical poison event, replay with ?since=<ISO ts> (Wikimedia retains ~7 days) and record the max line length — that is also why a 3-day-old wedge never self-heals but a 8-day-old one does.
General lessons: (1) an exponential backoff reset on connect rather than on progress turns a poison pill into a hot loop; reset backoff only after N successfully processed events. (2) A staleness counter is worthless without an alert wired to it — this one logged the correct value for 71 hours. (3) A reverse proxy returning HTTP 101 proves nothing about payload flow; monitor event throughput, not handshakes.