Skip to content

Bug: _chunk_bytes_to_sse raises AttributeError when chunk.get('bytes') is None in Bedrock stream decoder #1975

Open

Description

@chauvuusvn

Describe the bug

In anthropic.lib.bedrock._stream_decoder._chunk_bytes_to_sse(raw: bytes), raw.decode() is called unconditionally on line 72:

def _chunk_bytes_to_sse(raw: bytes) -> ServerSentEvent | None:
    decoded = raw.decode()

In _parse_message_from_event, _chunk_bytes_to_sse is invoked with chunk.get("bytes"):

chunk = parsed_response.get("chunk")
if not chunk:
    return None

return _chunk_bytes_to_sse(chunk.get("bytes"))

When AWS Bedrock sends an empty chunk frame, a control payload, or a chunk dictionary where the "bytes" key is missing or None (e.g. chunk = {"bytes": None} or chunk = {}), chunk.get("bytes") evaluates to None.

Passing None to _chunk_bytes_to_sse raises AttributeError: 'NoneType' object has no attribute 'decode', immediately crashing the active Bedrock stream.

To Reproduce

from anthropic.lib.bedrock._stream_decoder import _chunk_bytes_to_sse

_chunk_bytes_to_sse(None)
# Traceback (most recent call last):
#   File ".../anthropic/lib/bedrock/_stream_decoder.py", line 72, in _chunk_bytes_to_sse
#     decoded = raw.decode()
# AttributeError: 'NoneType' object has no attribute 'decode'

Expected Behavior

_chunk_bytes_to_sse should safely guard against None or non-bytes payloads and return None (dropping the empty frame) rather than crashing with an AttributeError.

Activity

  1. kimnamu commented on Oct 16, 2026

    @kimnamu

    Thanks for the clear write-up with a one-line repro, and for opening #1976 so quickly.

    As far as I can tell, None can't reach the helper through a real stream on main (bcd55fe, v1.15.0). botocore's JSON parser skips null and missing members, so {}, {"bytes": null} and an empty body all parse to {'chunk': {}}, and if not chunk: return None (_stream_decoder.py L65) drops them first. botocore 1.31.57 (the pyproject.toml floor) parses them the same way.

    # main @ bcd55fe
    {} / {'bytes': null} / empty body / unknown type ['message_start', 'message_stop']
    {'bytes': ''}                                    ['message_start'] then JSONDecodeError
    # #1976 @ bfe041a
    {} / {'bytes': null} / empty body / unknown type ['message_start', 'message_stop']
    {'bytes': ''}                                    ['message_start'] then JSONDecodeError
    # main + call-site falsy check (below)
    {} / {'bytes': null} / empty body / unknown type ['message_start', 'message_stop']
    {'bytes': ''}                                    ['message_start', 'message_stop']
    

    The one input I found that gets past that line is {"bytes": ""}, and the isinstance check in #1976 lets b"" through. If you'd like to keep a guard, a falsy check at the call site mirrors the line above and needs no pyright: ignore:

            raw = chunk.get("bytes")
            if not raw:
                return None
    
            return _chunk_bytes_to_sse(raw)

    With it, uv run pyright src/anthropic/lib/bedrock is clean and the four Bedrock/AWS test files pass (205). Happy to leave it with you.

    Repro script
    import json, base64, struct, binascii
    
    import httpx2
    from anthropic import AnthropicBedrock
    
    
    def frame(event_type: str, body: bytes) -> bytes:  # same framing as tests/lib/test_bedrock.py::_eventstream_frame
        headers = {":message-type": "event", ":event-type": event_type, ":content-type": "application/json"}
        h = b"".join(bytes([len(k)]) + k.encode() + b"\x07" + struct.pack(">H", len(v)) + v.encode() for k, v in headers.items())
        prelude = struct.pack(">II", 16 + len(h) + len(body), len(h))
        msg = prelude + struct.pack(">I", binascii.crc32(prelude)) + h + body
        return msg + struct.pack(">I", binascii.crc32(msg))
    
    
    def chunk(payload: dict) -> bytes:
        return frame("chunk", json.dumps({"bytes": base64.b64encode(json.dumps(payload).encode()).decode()}).encode())
    
    
    start = chunk({"type": "message_start", "message": {"id": "msg_1", "type": "message", "role": "assistant", "content": [],
                   "model": "claude", "stop_reason": None, "stop_sequence": None, "usage": {"input_tokens": 5, "output_tokens": 1}}})
    stop = chunk({"type": "message_stop"})
    cases = {
        "{} / {'bytes': null} / empty body / unknown type": frame("chunk", b"{}") + frame("chunk", b'{"bytes": null}')
        + frame("chunk", b"") + frame("somethingNew", b"{}"),
        "{'bytes': ''}": frame("chunk", b'{"bytes": ""}'),
    }
    for name, middle in cases.items():
        body = start + middle + stop
        transport = httpx2.MockTransport(lambda request, body=body: httpx2.Response(
            200, content=body, headers={"content-type": "application/vnd.amazon.eventstream"}))
        client = AnthropicBedrock(aws_region="us-east-1", aws_access_key="k", aws_secret_key="s",
                                  http_client=httpx2.Client(transport=transport))
        seen = []
        try:
            for event in client.messages.create(model="anthropic.claude-x", max_tokens=8, stream=True,
                                                messages=[{"role": "user", "content": "hi"}]):
                seen.append(event.type)
            print(f"{name:48} {seen}")
        except Exception as e:
            print(f"{name:48} {seen} then {type(e).__name__}")
    # EventStreamJSONParser + bedrock-runtime ResponseStream shape, botocore 1.31.57
    b'{}'              -> {'chunk': {}}
    b'{"bytes": null}' -> {'chunk': {}}
    b''                -> {'chunk': {}}
    b'{"bytes": ""}'   -> {'chunk': {'bytes': b''}}
    
  2. chauvuusvn commented on Oct 16, 2026

    @chauvuusvn
    Author

    Thanks for the great feedback and the thorough analysis, @kimnamu!

    I've updated PR #1976 following your suggestion:

    • Guarded raw = chunk.get("bytes") at the call site with if not raw: return None, cleanly dropping both None and empty b"".
    • Restored the original clean signature for _chunk_bytes_to_sse(raw: bytes) without any pyright: ignore comments.
    • Added a regression test (test_stream_skips_empty_and_null_bytes_chunk) simulating frames with both empty and null bytes.

    All Bedrock tests and type checks are passing cleanly. Appreciate the guidance!

  3. chauvuusvn commented on Oct 21, 2026

    @chauvuusvn
    Author

    Thanks @kimnamu for the thorough investigation and the clean call-site approach! I've updated #1976 accordingly with the falsy check (if not raw: return None), removing the pyright ignore and adding the regression tests for empty and null byte frames. All tests pass locally clean.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    No type

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions