Skip to content

HTTP Client Release 5.1.1: Prevent stream errors from being silently swallowed#56

Merged
dcrockwell merged 2 commits into
mainfrom
develop
Mar 3, 2026
Merged

HTTP Client Release 5.1.1: Prevent stream errors from being silently swallowed#56
dcrockwell merged 2 commits into
mainfrom
develop

Conversation

@dcrockwell
Copy link
Copy Markdown
Contributor

Summary

This release fixes a bug in dream_http_client where transport-level streaming errors (connection refused, socket drops, DNS failures) were silently swallowed or replaced with unhelpful generic messages. After this fix, all error reasons -- regardless of what the server sends back -- surface as readable, meaningful strings.

Version: 5.1.0 -> 5.1.1 (patch)

Why

When a streaming HTTP request encounters a transport-level failure (as opposed to an HTTP error response), Erlang's httpc returns error reasons as raw atoms or tuples. These couldn't be decoded by Gleam's strict UTF-8 string decoder, so the real error was lost:

  • Pull-based streaming (stream_yielder): Raw Erlang terms passed through unformatted, causing decode failures. The error was replaced with "Unknown stream error".
  • Message-based streaming (start_stream): Errors were formatted but could produce Latin-1 bytes, which Gleam rejected with a cryptic DecodeError.

This meant developers had no way to diagnose why their streaming requests failed in production.

What

  • Added ensure_utf8_binary/1 helper in the Erlang FFI layer that guarantees valid UTF-8 from any input
  • Updated all 5 error formatting functions to produce valid UTF-8
  • Fixed pull-based streaming to format raw error reasons before crossing the FFI boundary
  • Added three-tier fallback decoders on the Gleam side (string -> bit_array -> string.inspect)
  • Added 2 new mock server endpoints (/stream/drop, /non-utf8-error) for testing edge cases
  • Added 9 new tests covering connection refused, mid-stream drops, and non-UTF-8 error bodies
  • Bumped to 5.1.1 with changelog and release notes

How

Uses a dual-layer defense strategy: errors are sanitized to valid UTF-8 on the Erlang side and decoded with robust fallbacks on the Gleam side. This ensures that even if a future Erlang OTP update changes the error format, the client will still produce useful error messages.

All 177 tests pass with no regressions.

dcrockwell and others added 2 commits March 3, 2026 10:56
…ll Erlang term types

## Why This Change Was Made
- Erlang's `httpc` returns transport-level error reasons (e.g., `socket_closed_remotely`,
  `{failed_connect, ...}`, `econnrefused`) as raw atoms or tuples, not strings. The
  pull-based streaming path passed these through unformatted, causing `d.string` decode
  failures in Gleam. The message-based path formatted them via `io_lib:format("~p", ...)`,
  which can produce Latin-1 binaries that Gleam's UTF-8-strict string decoder rejects.
  In both cases, the actual error information was lost and replaced with generic messages.

## What Was Changed
- Added `ensure_utf8_binary/1` in `dream_httpc_shim.erl` that validates UTF-8, falls
  back to Latin-1 reinterpretation, then to `~w` (pure ASCII) as a last resort
- Updated all error formatting functions (`format_error`, `format_complete_response_error`,
  `format_exit_reason`, `to_binary`, `ref_to_string`) to use `ensure_utf8_binary`
- Fixed `stream_owner_wait` and `stream_owner_next_message` to call `format_error(Reason)`
  instead of passing raw Erlang terms through
- Added three-tier fallback decoders in `decode_error_reason` (client.gleam) and
  `receive_next` (internal.gleam): try d.string -> try d.bit_array -> string.inspect
- Added `/non-utf8-error` and `/stream/drop` mock server endpoints
- Added 9 new tests covering connection refused, mid-stream drops, and non-UTF-8 bodies
- Bumped version to 5.1.1 with changelog and release notes

## Note to Future Engineer
- The "belt and suspenders" strategy (format on Erlang side AND fallback on Gleam side) is
  intentional -- Erlang's type system is about as strict as a speed limit sign at 3 AM, so
  we defend at both layers. If you're wondering why `ensure_utf8_binary` handles binaries,
  lists, AND arbitrary terms: congratulations, you've discovered that `io_lib:format` returns
  an iolist (a deeply nested list of integers and binaries), not a binary. Yes, really.
- The `/stream/drop` test endpoint literally panics on purpose. If you see "intentional crash"
  in the logs during tests, that's working as designed, not a cry for help.
Prevent stream errors from being silently swallowed when servers misbehave
@dcrockwell dcrockwell self-assigned this Mar 3, 2026
@dcrockwell dcrockwell added bug Something isn't working release Official public releases module Change to a dream module labels Mar 3, 2026
@dcrockwell dcrockwell merged commit ac5a7f7 into main Mar 3, 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 module Change to a dream module release Official public releases

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant