aio-libs/aiohttp · error · AttributeError

EmptyStreamReader._on_chunk_received is read-only

Error message

EmptyStreamReader._on_chunk_received is read-only

What it means

EmptyStreamReader (the EMPTY_PAYLOAD singleton) shadows _on_chunk_received with a property whose setter always raises AttributeError. This is intentional: the empty payload never delivers chunks, and the singleton is shared across all responses, so attaching a per-response chunk hook would leak across requests. The guard makes any attempt to set the hook loud. aiohttp's own client_reqrep.py already short-circuits this case (`if self._traces and payload is not EMPTY_PAYLOAD`), so user code that hits it has bypassed that check.

Source

Thrown at aiohttp/streams.py:617

    __slots__ = ("_read_eof_chunk",)

    def __init__(self) -> None:
        self._read_eof_chunk = False
        self.total_bytes = 0

    # Shadow the inherited slot with a property so the EMPTY_PAYLOAD singleton
    # can't be polluted with a per-response hook that would leak across
    # requests. EmptyStreamReader never delivers a chunk anyway.
    @property
    def _on_chunk_received(self) -> None:
        return None

    @_on_chunk_received.setter
    def _on_chunk_received(
        self, value: Callable[[bytes], Coroutine[None, None, None]] | None
    ) -> None:
        raise AttributeError("EmptyStreamReader._on_chunk_received is read-only")

    def __repr__(self) -> str:
        return "<%s>" % self.__class__.__name__

    def exception(self) -> BaseException | None:
        return None

    def set_exception(
        self,
        exc: type[BaseException] | BaseException,
        exc_cause: BaseException = _EXC_SENTINEL,
    ) -> None:
        pass

    def on_eof(self, callback: Callable[[], None]) -> None:
        try:
            callback()
        except Exception:

View on GitHub (pinned to c0ef574e29)

Solutions

  1. Guard the assignment: `if payload is not EMPTY_PAYLOAD: payload._on_chunk_received = fn`.
  2. Prefer the official trace_configs API on ClientSession instead of poking the payload hook.
  3. Reset state in test teardown to avoid singleton pollution.
  4. Treat AttributeError here as a programming error in your instrumentation, not an aiohttp bug.

Example fix

// before
payload._on_chunk_received = my_hook  # fails for EMPTY_PAYLOAD
// after
from aiohttp.streams import EMPTY_PAYLOAD
if payload is not EMPTY_PAYLOAD:
    payload._on_chunk_received = my_hook
Defensive patterns

Strategy: validation

Validate before calling

from aiohttp.streams import EMPTY_PAYLOAD

def attach_chunk_hook(payload, hook):
    if payload is EMPTY_PAYLOAD:
        return  # nothing to observe on an empty body
    payload._on_chunk_received = hook

Try / catch

try:
    payload._on_chunk_received = hook
except AttributeError as e:
    if 'read-only' in str(e):
        return  # EMPTY_PAYLOAD — skip
    raise

Prevention

When it happens

Trigger: Directly assigning EMPTY_PAYLOAD._on_chunk_received = fn (e.g. a trace/metrics shim that does not check for EMPTY_PAYLOAD); reaching into client_reqrep internals; monkey-patching the singleton for testing.

Common situations: Custom trace context that sets the hook unconditionally on every response (including empty-body 204/304); test fixtures that attach hooks to the EMPTY_PAYLOAD singleton and leak across tests; instrumentation copied from an older aiohttp that lacked the guard.

Related errors


AI-assisted analysis of aio-libs/aiohttp@c0ef574e29 (2026-08-04). Data as JSON: /data/errors/774c527150d169ba.json. Report an issue: GitHub.