Skip to content

fix(jsonl): do not treat a lone CR as a line ending - #1778

Open
hsusul wants to merge 1 commit into
anthropics:mainfrom
hsusul:fix/jsonl-crlf-split-across-chunks
Open

fix(jsonl): do not treat a lone CR as a line ending#1778
hsusul wants to merge 1 commit into
anthropics:mainfrom
hsusul:fix/jsonl-crlf-split-across-chunks

Conversation

@hsusul

@hsusul hsusul commented Jul 24, 2026

Copy link
Copy Markdown

Summary

  • JSONLDecoder / AsyncJSONLDecoder previously treated a lone \r as a complete line ending.
  • When a CRLF terminator is split across iter_bytes chunks (batch results use chunk_size=64), that left a leftover \n which was parsed as an empty JSONL row and raised JSONDecodeError.
  • Terminate lines only on \n (stripping a preceding \r for CRLF), matching JSON Lines and the TypeScript SDK’s LineDecoder behavior.

Problem

messages.batches.results / beta.messages.batches.results stream JSONL via:

self.http_response.iter_bytes(chunk_size=64)

The previous decoder finalized a line whenever the buffer ended with \r, \n, or \r\n. If a chunk ended at \r and the next began with \n, the decoder:

  1. Parsed the line ending at \r (OK for that object)
  2. Then tried to parse the leftover \n as another line → json.JSONDecodeError: Expecting value

Intact \r\n in a single chunk already worked; only the split case failed.

Minimal reproduction

import httpx
from anthropic._decoders.jsonl import JSONLDecoder

list(
    JSONLDecoder(
        raw_iterator=iter([b'{"foo":true}\r', b'\n{"bar":false}\r\n']),
        line_type=object,
        http_response=httpx.Response(200),
    )
)
# Before: JSONDecodeError
# After: [{"foo": True}, {"bar": False}]

Current behavior

Split CRLF across chunks raises JSONDecodeError while decoding batch results.

Corrected behavior

CRLF is treated as one terminator even when \r and \n arrive in separate chunks. Sync and async decoders behave the same.

Root cause

bytes.splitlines(keepends=True) + buf.endswith((b"\r", b"\n", b"\r\n")) treated a dangling CR as end-of-line. JSON Lines uses \n as the line separator (with \r\n also valid); a lone CR must stay buffered until \n arrives (or EOF flush).

Implementation

  • Buffer incoming chunks and split only on \n.
  • Strip a trailing \r from each line (CRLF).
  • On flush, strip a trailing \r before parsing any remaining bytes.
  • Applied identically in JSONLDecoder and AsyncJSONLDecoder.

This is intentionally small: no public API changes, no dependency changes, and no generated resource changes. _decoders/jsonl.py is a Stainless-persisted module; the change may need re-application across regenerations, same as prior jsonl fixes in this repo.

Why this approach is minimal

  • Does not introduce a shared line-decoder abstraction.
  • Does not skip blank lines or broaden accepted inputs.
  • Mirrors the contract already used by the TypeScript SDK’s LineDecoder (defer trailing CR) without pulling httpx internals into this path.

Sync and async coverage

Both JSONLDecoder and AsyncJSONLDecoder were updated and tested.

Regression tests

Added in tests/decoders/test_jsonl.py (sync + async for each):

  • Intact CRLF line endings
  • CRLF split across chunk boundaries
  • CRLF split after a mid-line chunk boundary

Validation

Pre-fix (expected failures):

UV_PYTHON='>=3.9.0' uv run --isolated --all-extras pytest tests/decoders/test_jsonl.py -n0 -v
# 4 failed (CRLF split cases), 8 passed

Post-fix:

UV_PYTHON='>=3.9.0' uv run --isolated --all-extras pytest tests/decoders/test_jsonl.py -n0 -v
# 12 passed

UV_PYTHON='>=3.9.0' uv run --isolated --all-extras --no-extra=mcp --group=pydantic-v1 pytest tests/decoders/test_jsonl.py -n0 -v
# 12 passed

UV_PYTHON='>=3.14.0' uv run --isolated --all-extras pytest tests/decoders/test_jsonl.py -n0 -v
# 12 passed

UV_PYTHON='>=3.9.0' ./scripts/test tests/decoders/ tests/test_streaming.py tests/test_response.py tests/test_legacy_response.py
# 75 passed (pydantic v2), 75 passed (pydantic v1)

UV_PYTHON='>=3.9.0' ./scripts/test
# 4002 passed, 687 skipped, 1 xfailed (pydantic v2)
# 3885 passed, 805 skipped (pydantic v1)

./scripts/lint
# ruff: All checks passed
# pyright: 0 errors
# mypy: Success: no issues found in 1102 source files

uv build
# Successfully built dist/anthropic-0.119.0.tar.gz and .whl

git diff --check
# clean

No live Anthropic API calls were made.

Generated-code considerations

Edited src/anthropic/_decoders/jsonl.py (persisted across Stainless generations; may conflict on regen). Tests under tests/decoders/ are hand-maintained.

Compatibility

  • Backward compatible for \n-terminated JSONL (unchanged).
  • Improves CRLF handling under chunked reads.
  • Pure \r-only line endings (non-JSONL) are no longer treated as terminators mid-stream; JSON Lines requires \n / \r\n.

Limitations

  • Full ./scripts/test was run for Python 3.9 (min) with both Pydantic major versions; focused JSONL tests were also run on Python 3.14.
  • GitHub code search was rate-limited on the final recheck; earlier searches found no open/closed issues or PRs for this root cause.

Batch results are streamed with small iter_bytes chunks, so a CRLF
terminator can split across chunk boundaries. Finalizing on `\r`
alone left a leftover `\n` that caused JSONDecodeError.
@hsusul
hsusul requested a review from a team as a code owner July 24, 2026 05:29
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant