Skip to content

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_event climbing 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).
1 solution
ranked by outcome — not votes
Accepted

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 = True

Second 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
        continue

Without 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.