Skip to content

Conversation

@dcrockwell
Copy link
Contributor

HTTP Client: Simpler Streaming with Callbacks and Type-Safe Headers

What This Does

Makes HTTP streaming dramatically simpler by replacing manual selector loops with callbacks, while adding type safety for headers throughout the module.

Why These Changes

The Problem:

When streaming HTTP responses, users had to write 40+ lines of boilerplate:

  • Build selectors manually
  • Write recursive receive loops
  • Handle request ID filtering
  • Mix HTTP messages with app messages in their mailbox

This violated our ergonomics principles and didn't follow BEAM best practices (one process per concern).

Additionally, recordings were lost if stop() wasn't called, and headers were untyped tuples throughout.

The Solution:

Process-based streaming with callbacks + type-safe headers.

What Changed

New Streaming API (Breaking)

Before (2.x):

let selector = process.new_selector()
  |> client.select_stream_messages(HttpStream)

let assert Ok(req_id) = client.stream_messages(request)
// 40+ lines of recursive receive loop...

After (3.0):

let assert Ok(stream) = client.new
  |> client.on_stream_chunk(fn(data) { io.print(data) })
  |> client.start_stream()

client.await_stream(stream)  // Optional

New Functions:

  • start_stream() - Spawns dedicated process with callbacks
  • on_stream_start(), on_stream_chunk(), on_stream_end(), on_stream_error() - Builder callbacks
  • await_stream() - Wait for completion
  • cancel_stream_handle(), is_stream_active() - Stream control

Removed:

  • stream_messages() - now internal
  • select_stream_messages() - now internal

Header Type (Breaking)

Added Header(name, value) type for type safety:

  • get_headers() returns List(Header) not List(#(String, String))
  • StreamStart/StreamEnd use List(Header)
  • add_header(name, value) still takes strings (builds Header internally)

Immediate Recording Saves

  • Recordings saved to disk immediately when captured
  • recorder.stop() now optional (just cleanup)
  • Never lose recordings even if process crashes

Documentation

  • All 9 README code examples are tested snippets (following dream_ets pattern)
  • Created test/snippets/ with runnable examples
  • World-class hexdocs for all public functions and types

How It Works

Each stream runs in its own BEAM process:

  1. start_stream() spawns unlinked process
  2. Process calls internal stream_messages() to start HTTP stream
  3. Selects HTTP messages from its own mailbox
  4. Calls user callbacks as messages arrive
  5. Exits when stream completes/errors

Your mailbox stays clean. Your callbacks can send messages back to your actor if needed.

Testing

  • 97 tests passing (was 112)
  • Deleted 25 obsolete selector tests
  • Added 10 new start_stream() tests
  • Added 9 snippet tests
  • All examples verified working

Migration

See CHANGELOG.md for detailed migration guide. Summary:

// Old selector pattern
let selector = process.new_selector() |> client.select_stream_messages(HttpStream)
let assert Ok(req_id) = client.stream_messages(request)
// Recursive loop...

// New callback pattern  
let assert Ok(stream) = client.new |> client.on_stream_chunk(process_chunk) |> client.start_stream()
client.await_stream(stream)

Headers: Use pattern matching with Header(name, value) instead of #(name, value).


Version: 3.0.0 (breaking)
Tests: 97 passing
Branch: feature/http-client-auto-record

Base automatically changed from develop to main December 9, 2025 23:54
… add Header type

## Why This Change Was Made

- The old stream_messages() API exposed selector boilerplate to users, violating ergonomics principles
- Users had to write 40+ lines of recursive receive loops for every stream
- Mailbox pollution: HTTP messages mixed with application messages
- Not following BEAM best practices (one process per concern)
- Header tuples lacked type safety throughout the module
- Recordings were lost if stop() wasn't called (original issue on this branch)

## What Was Changed

Breaking Changes:
- Removed stream_messages() and select_stream_messages() from public API (now internal)
- Added start_stream() with builder-based callbacks (on_stream_start, on_stream_chunk, on_stream_end, on_stream_error)
- Each stream runs in dedicated BEAM process for isolation
- Added Header type replacing List(#(String, String)) throughout module
- get_headers() now returns List(Header)
- StreamStart/StreamEnd use List(Header) for headers
- add_header(name, value) still takes strings (ergonomic builder pattern)

New API:
- start_stream() - spawn stream with callbacks
- await_stream(handle) - wait for completion
- cancel_stream_handle(handle) - cancel stream
- is_stream_active(handle) - check status
- Header(name, value) type for type safety

Recording (original feature):
- Recordings saved immediately when captured
- recorder.stop() now optional
- Added storage.save_recording_immediately()

Documentation:
- All README examples are tested snippets (dream_ets pattern)
- Added test/snippets/ directory with 9 tested examples
- World-class hexdocs for Header and StreamHandle types
- Comprehensive migration guide in CHANGELOG

Tests:
- 97 tests passing (was 112, deleted 25 obsolete selector tests, added 10 new start_stream tests)
- Deleted stream_messages_integration_test.gleam and stream_messages_unit_test.gleam (obsolete)
- Added start_stream_test.gleam with callback API tests

## Note to Future Engineer

- The Header type uses .name and .value fields, not tuples. Convert at FFI boundaries.
- Callbacks execute in the SPAWNED process, not the caller's process. Send messages to parent if needed.
- await_stream() polls with sleep(50) - no timeout, it waits forever. Use cancel_stream_handle() for timeout behavior.
- ETS tables must be initialized before spawning (ensure_ets_tables() in start_stream). Yes, this bit me.
- stream_messages() still exists internally - it's used by start_stream(). Don't delete it.

If you're wondering why we didn't just improve the selector API instead of replacing it entirely: we tried. The fundamental issue is that putting HTTP messages in the user's mailbox is the wrong abstraction. One process per stream is the BEAM way. Should have been designed this way from the start, but better late than never.

Enjoy not writing recursive receive loops anymore. You're welcome.
@dcrockwell dcrockwell force-pushed the feature/http-client-auto-record branch from f9527f3 to fdfc8f6 Compare December 9, 2025 23:56
@dcrockwell dcrockwell changed the base branch from main to develop December 10, 2025 00:00
@dcrockwell dcrockwell merged commit f02b335 into develop Dec 10, 2025
2 checks passed
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.

2 participants