Skip to content

HTTP Client 5.1.0: Compression support and streaming error resilience#53

Merged
dcrockwell merged 1 commit into
developfrom
fix/stream-complete-response-crash
Mar 1, 2026
Merged

HTTP Client 5.1.0: Compression support and streaming error resilience#53
dcrockwell merged 1 commit into
developfrom
fix/stream-complete-response-crash

Conversation

@dcrockwell
Copy link
Copy Markdown
Contributor

Summary

  • Streaming no longer crashes on error responses — when an upstream server returns a complete HTTP error (401, 403, 429, 500 with a body) instead of starting a stream, the client now surfaces the error through the proper callbacks instead of crashing, hanging, or timing out silently
  • Transparent gzip/deflate compression — the client automatically advertises Accept-Encoding: gzip, deflate and decompresses responses across all three execution modes (send(), stream_yielder(), start_stream()), with no API changes required
  • Corrupted or unknown encodings handled gracefully — unrecognized Content-Encoding values pass through raw bytes with a warning; corrupted compressed data falls back to raw passthrough instead of crashing

Why

Streaming requests would crash or silently time out when the upstream returned a non-streaming error response. Erlang's httpc sends a completely different message format for complete responses vs streamed chunks, and four locations in the shim were not handling the complete response case. This made it impossible to distinguish between a genuine timeout and an upstream auth failure.

Separately, the HTTP client never advertised compression support, so servers were always sending uncompressed payloads even when they supported gzip or deflate. For large response bodies and streaming data, this is a significant bandwidth and latency cost.

What Changed

Streaming crash fix (4 locations in dream_httpc_shim.erl):

  • decode_stream_message_for_selector/1 — routes complete responses to stream_error
  • receive_stream_message/1 — returns stream_error with status and body
  • stream_owner_wait/5 — buffers error for delivery via fetch_next
  • stream_owner_next_message/2 — returns error immediately

Compression (all in dream_httpc_shim.erl + mock server):

  • Auto-injects Accept-Encoding: gzip, deflate unless user provides their own
  • Sync mode: decompresses full response body after receipt
  • Pull-based streaming: threads a zlib inflate context through process state, decompresses chunks on-the-fly
  • Message-based streaming: stores zlib contexts in ETS per stream, same lifecycle management
  • 9 new mock server endpoints for compression testing (6 non-streaming + 3 streaming)

How

  • Added format_complete_response_error/3 and match clauses for the {Ref, {{Version, StatusCode, Reason}, Headers, Body}} tuple in all four streaming locations
  • Added maybe_add_accept_encoding/1, maybe_decompress_response/2, try_decompress/3 for sync decompression
  • Added detect_stream_encoding/1, init_zlib_context/1, decompress_chunk/2, cleanup_zlib/1 for streaming zlib lifecycle
  • Added ETS-based zlib management (maybe_store_stream_zlib/2, maybe_decompress_stream_chunk/2, cleanup_stream_zlib/1) for the message-based path
  • Created compression_ffi.erl and compression.gleam in the mock server for server-side test compression
  • Version bumped to 5.1.0 with consolidated release notes

Test Coverage

34 new tests (168 total). 10 regression tests for the streaming crash fix, 24 compression tests covering the full permutation matrix: gzip/deflate/identity/unknown/corrupted × sync/stream-callback/stream-pull × header injection scenarios × zlib cleanup verification.

See release notes for full details.

… streaming crash on non-streaming responses

## Why This Change Was Made
- Streaming HTTP requests (start_stream / stream_yielder) crashed, hung, or
  silently timed out when the upstream returned a complete HTTP error response
  (401, 403, 429, 500 with a body) instead of starting an SSE stream. Erlang's
  httpc sends a complete response tuple that was unhandled in four locations in
  dream_httpc_shim.erl. The on_stream_error callback was never invoked, the
  caller timed out after 30 seconds (504), and the upstream error details were
  lost entirely.
- The HTTP client did not support compressed responses. Servers that support
  gzip/deflate compression were always sending uncompressed payloads because
  the client never advertised Accept-Encoding.

## What Was Changed
- Added format_complete_response_error/3 helper and a match clause for the
  complete response tuple {Ref, {{Version, StatusCode, Reason}, Headers, Body}}
  in all four affected streaming locations:
  1. decode_stream_message_for_selector/1 — routes to stream_error
  2. receive_stream_message/1 — returns stream_error with status and body
  3. stream_owner_wait/5 — buffers error for delivery via fetch_next
  4. stream_owner_next_message/2 — returns error immediately
- Added transparent gzip/deflate decompression across all three execution modes:
  - request_sync/5 (send) — decompresses full response body
  - stream_owner_wait/5 (stream_yielder) — zlib inflate context threaded through
    process state, decompresses chunks on-the-fly, cleaned up on end/error
  - decode_stream_message_for_selector/1 and receive_stream_message/1
    (start_stream) — ETS-stored zlib contexts per stream, same lifecycle
- Added maybe_add_accept_encoding/1 that injects "Accept-Encoding: gzip, deflate"
  unless the user already set their own
- Added maybe_decompress_response/2 with try_decompress/3 for sync decompression
  with graceful error handling on corrupted data
- Added detect_stream_encoding/1, maybe_init_stream_zlib/1, init_zlib_context/1,
  decompress_chunk/2, cleanup_zlib/1 for streaming zlib lifecycle
- Added ETS-based zlib management: maybe_store_stream_zlib/2,
  maybe_decompress_stream_chunk/2, cleanup_stream_zlib/1
- Added 9 mock server endpoints (6 non-streaming + 3 streaming) for compression
  testing, with compression_ffi.erl Erlang FFI for zlib:gzip/compress/streaming
- Added 34 new tests (10 regression + 24 compression permutation matrix)
- Bumped version to 5.1.0, consolidated release notes

## Note to Future Engineer
- The complete response tuple from httpc is {Ref, {{Version, StatusCode, Reason},
  Headers, Body}} which looks nothing like the streaming tuples
  {Ref, stream_start, Headers} etc. httpc decides which format to use based on
  whether the server actually streams — you don't get to choose. Surprise!
- stream_owner_wait is now /5 (was /4) and stream_owner_next_message is now /2
  (was /1) because they thread a ZlibCtx parameter. If you add a new message
  handler to either function, don't forget to pass ZlibCtx through or you'll
  lose the decompression context and get garbage bytes. You're welcome.
- The stream_yielder tests in stream_non_streaming_response_test.gleam use
  yielder.take(1) instead of yielder.to_list because the yielder retries
  infinitely on start errors (owner never gets set to Some). If you switch to
  to_list, your test will run until the heat death of the universe.
- Decompression of corrupted gzip data falls back to raw passthrough with a
  warning instead of crashing. This is intentional — we'd rather give you
  garbage bytes you can debug than kill your process with no explanation.
- All four streaming locations need the complete response fix. If you only patch
  decode_stream_message_for_selector you'll fix start_stream but leave
  stream_yielder hanging forever. Ask me how I know.
@dcrockwell dcrockwell self-assigned this Mar 1, 2026
@dcrockwell dcrockwell added bug Something isn't working enhancement New feature or request labels Mar 1, 2026
@dcrockwell dcrockwell merged commit 2c4642b into develop Mar 1, 2026
2 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

bug Something isn't working enhancement New feature or request

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant