From b8449bbafec15b8b55b088dabf46b9ec9ce6023a Mon Sep 17 00:00:00 2001 From: Dara Rockwell Date: Wed, 25 Mar 2026 14:22:15 -0600 Subject: [PATCH 1/7] feat: add full configurability to dream_http_client MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## Why This Change Was Made - The HTTP client had 6 hardcoded httpc configuration values baked into the Erlang shim (connect_timeout, autoredirect, max_sessions, max_pipeline_length, keep_alive_timeout, max_keep_alive_length). Users had no way to tune TCP connection timeouts, disable redirect following, or adjust connection pool settings for their workload. This is a published library — users shouldn't have to fork it to change a timeout. ## What Was Changed - Added `connect_timeout(ms)` and `auto_redirect(enabled)` builder functions on ClientRequest for per-request settings, with corresponding getters and private resolve functions that apply the previous hardcoded defaults when not set - Added `TransportConfig` opaque type with `transport_config()` factory, 4 builder functions (max_sessions, max_pipeline_length, keep_alive_timeout, max_keep_alive_length), 4 getters, and `configure_transport()` to apply settings globally via ETS + httpc:set_options - Added `dream_http_client_transport_config` ETS table creation to dream_http_client_app:start/2 - Updated configure_httpc/0 to read from ETS (falling back to hardcoded defaults if configure_transport was never called) - Updated all 3 Erlang shim request functions (request_sync/7, request_stream/8, request_stream_messages/8) and stream_owner_loop/6 to accept and use the new parameters - Updated internal.gleam FFI signatures and start_httpc_stream to pass connect_timeout_ms and autoredirect - Updated YielderState, RecordingYielderState, and all 7 caller functions in client.gleam to resolve and propagate the new parameters - 14 new tests, 3 test snippets, README Configuration section, CHANGELOG 5.2.0 entry, release notes, version bump 5.1.3 → 5.2.0 ## Note to Future Engineer - The Erlang FFI arity chain is: Gleam external declaration → Erlang export list → Erlang function head. If you change one, you must change all three or you get a runtime badarity crash that won't show up until an actual HTTP request is made. The compiler won't catch FFI arity mismatches. - configure_transport writes to ETS AND calls httpc:set_options immediately. configure_httpc reads from ETS on every request. Yes, this means the settings are applied twice on the first request after configure_transport is called. No, this is not a bug — it's the price of making runtime reconfiguration work without a restart. You're welcome. - The resolve functions (resolve_connect_timeout, resolve_auto_redirect) are where the defaults live. If you want to change a default, change it there AND in the ETS fallback branch of configure_httpc AND in the TransportConfig factory. Three places. We know. It's Erlang's fault. --- .cursor/commands/gleam.md | 87 +++++ modules/http_client/CHANGELOG.md | 33 ++ modules/http_client/README.md | 57 ++- modules/http_client/gleam.toml | 2 +- modules/http_client/releases/release-5.2.0.md | 220 +++++++++++ .../src/dream_http_client/client.gleam | 360 +++++++++++++++++- .../dream_http_client_app.erl | 1 + .../dream_http_client/dream_httpc_shim.erl | 63 +-- .../src/dream_http_client/internal.gleam | 17 +- modules/http_client/test/client_test.gleam | 60 +++ .../snippets/connect_timeout_config.gleam | 15 + .../test/snippets/redirect_config.gleam | 15 + .../snippets/transport_config_example.gleam | 8 + .../test/transport_config_test.gleam | 95 +++++ 14 files changed, 999 insertions(+), 34 deletions(-) create mode 100644 .cursor/commands/gleam.md create mode 100644 modules/http_client/releases/release-5.2.0.md create mode 100644 modules/http_client/test/snippets/connect_timeout_config.gleam create mode 100644 modules/http_client/test/snippets/redirect_config.gleam create mode 100644 modules/http_client/test/snippets/transport_config_example.gleam create mode 100644 modules/http_client/test/transport_config_test.gleam diff --git a/.cursor/commands/gleam.md b/.cursor/commands/gleam.md new file mode 100644 index 0000000..147259d --- /dev/null +++ b/.cursor/commands/gleam.md @@ -0,0 +1,87 @@ +# Gleam on the BEAM + +This is a Gleam application running on the BEAM (Erlang VM). Every solution must use the correct patterns for this platform. Solutions designed for other architectures (Node.js event loops, Go goroutines, Rust async, JVM threading, etc.) are not acceptable, even if they "work." + +## The Standard + +There is one correct way to solve most problems on the BEAM. Find it. Use it. Do not settle for a solution that merely functions — it must be idiomatically correct for Gleam/Erlang/OTP. + +Before proposing any solution that involves concurrency, state management, fault tolerance, or inter-process communication, ask yourself: **"How would an experienced Erlang/OTP engineer solve this?"** Then find the Gleam equivalent. + +## Platform Primitives (Use These) + +### Processes and Actors + +The BEAM's unit of concurrency is the lightweight process. State lives in processes, not in shared mutable memory. + +- **Use `gleam_otp` actors** (`gleam/otp/actor`) for stateful services. Actors are the correct way to hold mutable state — not closures, not mutable references, not global variables. +- **Use `gleam_erlang` subjects and selectors** for message passing between processes. +- **Every long-lived stateful component should be an actor.** Caches, connection pools, rate limiters, session stores, background workers — these are all actors. +- **Never share state between processes via anything other than message passing.** No shared memory. No locks. No mutexes. This is not that kind of platform. + +### Supervision Trees + +Processes crash. That is expected and correct on the BEAM. + +- **Use supervisors** (`gleam/otp/supervisor`) to manage process lifecycles. Every actor that matters should be supervised. +- **Design for failure.** "Let it crash" is not negligence — it is the architecture. Supervisors restart failed processes with known-good state. +- **Think about restart strategies.** One-for-one, one-for-all, rest-for-one — pick the right one for the failure domain. +- **Never try/catch your way out of a process crash.** Let the supervisor handle it. Recovery logic belongs in `init`, not in error handlers wrapped around every call. + +### ETS (Erlang Term Storage) + +ETS tables are the correct solution for shared read-heavy state that multiple processes need to access concurrently. + +- **Use ETS for caches, lookup tables, and read-heavy shared state.** ETS provides concurrent reads without bottlenecking through a single actor's mailbox. +- **Do not use ETS as a general-purpose database.** It is in-memory and not persisted across restarts (unless you specifically set that up with DETS or manual persistence). +- **Owner process matters.** An ETS table is owned by the process that created it. If that process dies, the table is destroyed. Plan accordingly — typically the supervisor or a dedicated table-owner process should create tables. + +### Process Links and Monitors + +- **Use monitors** when you need to know if another process dies but don't want to die with it. +- **Use links** when processes should fail together. +- Understand the difference. Using the wrong one causes either silent failures or unnecessary cascading crashes. + +## Erlang FFI + +Gleam runs on the BEAM and can call Erlang directly. This is a strength, not a workaround. + +- **Use Erlang FFI when Gleam lacks a wrapper** for BEAM functionality you need (e.g., `:ets`, `:timer`, `:crypto`, specific OTP behaviors). +- **Write thin FFI wrappers** — the Erlang code should be minimal, with the logic and types living in Gleam. +- **Always provide type-safe Gleam interfaces** over raw FFI calls. Never expose raw Erlang terms to calling code. +- **FFI is for platform access, not for bypassing Gleam's type system.** If you find yourself using FFI to work around Gleam's constraints, you are likely solving the wrong problem. + +## Anti-Patterns (Never Do These) + +- **Never use polling loops** where you should use process messaging or monitors. +- **Never use a single process as a bottleneck** for state that could live in ETS for concurrent reads. +- **Never spawn unsupervised processes** for anything that matters. If a process does real work, it belongs in a supervision tree. +- **Never store state in module-level variables or closures** pretending to be singletons. State belongs in actors. +- **Never implement your own process registry** when Erlang's built-in registry or `gproc`-style solutions exist. +- **Never use GenServer-style synchronous calls** when a cast (async message) would suffice and the caller doesn't need the result. +- **Never treat BEAM processes like OS threads.** They are cheap. Spawn thousands. Don't pool them like heavyweight threads. +- **Never reach for an external dependency** (Redis, a message queue, an in-memory cache library) when the BEAM already provides the primitive. ETS is your cache. Processes are your workers. Message passing is your queue. + +## Decision Framework + +When solving a problem, evaluate in this order: + +1. **Can a pure function solve this?** No state, no processes needed. Just transform data. This is always preferred. +2. **Does this need state?** → Actor. Use `gleam/otp/actor`. +3. **Does this state need to survive crashes?** → Supervised actor with init-time recovery. +4. **Do multiple processes need to read this state concurrently?** → ETS table, owned by a supervised process. +5. **Does this need to react to process lifecycle events?** → Monitors or links. +6. **Does this need periodic work?** → Actor with `erlang.send_after` or `:timer` via FFI. Not a polling loop. +7. **Does this need to coordinate multiple actors?** → Supervisor tree with appropriate restart strategy. +8. **Does Gleam lack a wrapper for the BEAM feature I need?** → Erlang FFI with a type-safe Gleam wrapper. + +## What "Correct" Means + +A correct solution on this platform: + +- Uses processes for isolation and concurrency, not threads or async/await +- Uses message passing for communication, not shared memory +- Uses supervisors for fault tolerance, not try/catch +- Uses ETS for shared read-heavy state, not a cache actor bottleneck +- Uses the existing BEAM ecosystem before reaching for external tools +- Compiles to idiomatic BEAM bytecode that an Erlang veteran would recognize as reasonable diff --git a/modules/http_client/CHANGELOG.md b/modules/http_client/CHANGELOG.md index d3a02ac..4754275 100644 --- a/modules/http_client/CHANGELOG.md +++ b/modules/http_client/CHANGELOG.md @@ -5,6 +5,39 @@ All notable changes to `dream_http_client` will be documented in this file. The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/), and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). +## 5.2.0 - 2026-03-25 + +### Added + +- **Per-request TCP connection timeout.** `connect_timeout(ms)` controls how long + to wait for the TCP connection to be established, separate from the existing + `timeout()` which controls the entire request/response cycle. Defaults to + 15000ms (matching the previous hardcoded value). Useful for failing fast against + unreachable hosts without shortening the overall request timeout. +- **Per-request redirect control.** `auto_redirect(enabled)` controls whether + 3xx redirects are followed automatically. When disabled, the 3xx response is + returned as `Ok(HttpResponse(...))` with the status code and `Location` header + visible, allowing manual redirect handling. Defaults to `True` (matching + previous behavior). +- **Global transport configuration.** `TransportConfig` opaque type with builder + functions for connection pool tuning: + - `max_sessions(count)` — concurrent TCP connections per host (default: 100) + - `max_pipeline_length(length)` — HTTP pipelining depth, 0 = disabled (default: 0) + - `keep_alive_timeout(ms)` — idle connection lifetime (default: 60000ms) + - `max_keep_alive_length(count)` — requests per keep-alive connection (default: 100) + + Create with `transport_config()`, configure with builders, apply with + `configure_transport()`. Settings are global (applied to the httpc default + profile) and affect all subsequent requests. Stored in ETS for concurrent + read access, created during OTP application startup alongside the existing + tables. +- **Getter functions** for all new fields: `get_connect_timeout()`, + `get_auto_redirect()`, `get_max_sessions()`, `get_max_pipeline_length()`, + `get_keep_alive_timeout()`, `get_max_keep_alive_length()`. +- **14 new tests** covering builder/getter round-trips, default values, edge + cases (zero values), builder chaining, and transport application. 3 new + test snippets for documentation examples. + ## 5.1.3 - 2026-03-17 ### Fixed diff --git a/modules/http_client/README.md b/modules/http_client/README.md index 367467e..8d78a0f 100644 --- a/modules/http_client/README.md +++ b/modules/http_client/README.md @@ -210,6 +210,60 @@ pub fn stream_and_print() -> Result(Nil, String) { --- +## Configuration + +### Per-Request Settings + +Configure connection behavior on individual requests: + +```gleam +import dream_http_client/client + +// Set TCP connection timeout (default: 15000ms) +client.new() +|> client.host("api.example.com") +|> client.connect_timeout(5000) +|> client.send() + +// Disable automatic redirect following (default: True) +client.new() +|> client.host("api.example.com") +|> client.path("/old-endpoint") +|> client.auto_redirect(False) +|> client.send() +``` + +Tested sources: [connect_timeout](test/snippets/connect_timeout_config.gleam), [auto_redirect](test/snippets/redirect_config.gleam) + +### Transport Settings + +Configure the underlying connection pool (global, affects all requests): + +```gleam +import dream_http_client/client + +client.transport_config() +|> client.max_sessions(200) +|> client.keep_alive_timeout(120_000) +|> client.configure_transport() +``` + +Tested source: [transport config](test/snippets/transport_config_example.gleam) + +### Defaults + +| Setting | Default | Scope | +| ---------------------- | ------- | ----------- | +| `timeout` | 30000ms | Per-request | +| `connect_timeout` | 15000ms | Per-request | +| `auto_redirect` | True | Per-request | +| `max_sessions` | 100 | Global | +| `max_pipeline_length` | 0 | Global | +| `keep_alive_timeout` | 60000ms | Global | +| `max_keep_alive_length`| 100 | Global | + +--- + ## Recording & Playback Record HTTP requests/responses for testing, debugging, and offline development. @@ -462,7 +516,8 @@ mocks/api/POST_localhost__text_c7d8e9_4f22bc.json ```gleam import dream_http_client/client.{ - add_header, body, host, method, path, port, query, scheme, send, timeout, + add_header, auto_redirect, body, connect_timeout, host, method, path, port, + query, scheme, send, timeout, } import gleam/http diff --git a/modules/http_client/gleam.toml b/modules/http_client/gleam.toml index a3f6580..681684a 100644 --- a/modules/http_client/gleam.toml +++ b/modules/http_client/gleam.toml @@ -1,5 +1,5 @@ name = "dream_http_client" -version = "5.1.3" +version = "5.2.0" description = "Type-safe HTTP client for Gleam with streaming support" licences = ["MIT"] repository = { type = "github", user = "TrustBound", repo = "dream" } diff --git a/modules/http_client/releases/release-5.2.0.md b/modules/http_client/releases/release-5.2.0.md new file mode 100644 index 0000000..0e14737 --- /dev/null +++ b/modules/http_client/releases/release-5.2.0.md @@ -0,0 +1,220 @@ +# dream_http_client v5.2.0 + +**Release Date:** March 25, 2026 + +This release exposes the 6 previously hardcoded `httpc` configuration values +as user-configurable options. Per-request settings (`connect_timeout`, +`auto_redirect`) are added to the `ClientRequest` builder. Profile-level +transport settings (`max_sessions`, `max_pipeline_length`, `keep_alive_timeout`, +`max_keep_alive_length`) are managed through a new `TransportConfig` type. +All defaults match the previous hardcoded values — existing code behaves +identically without changes. + +No breaking changes. Minor version bump (5.1.3 → 5.2.0). + +--- + +## Feature: Per-request connection timeout + +`connect_timeout(ms)` controls how long to wait for the TCP connection to be +established. This is separate from `timeout()`, which controls the entire +request/response cycle. + +```gleam +client.new() +|> client.host("api.example.com") +|> client.connect_timeout(5000) +|> client.send() +``` + +**Default:** 15000ms (matches previous hardcoded value). + +**Use case:** Fail fast against unreachable hosts without shortening the +overall request timeout. For example, set `connect_timeout(2000)` with +`timeout(60_000)` to detect connection failures quickly while still allowing +slow responses. + +Maps directly to Erlang httpc's `{connect_timeout, Ms}` HTTP option. + +--- + +## Feature: Per-request redirect control + +`auto_redirect(enabled)` controls whether 3xx redirects are followed +automatically. + +```gleam +// Disable auto-redirect to inspect the 3xx response +client.new() +|> client.host("api.example.com") +|> client.path("/old-endpoint") +|> client.auto_redirect(False) +|> client.send() +``` + +**Default:** `True` (matches previous hardcoded value). + +When disabled, the 3xx response is returned as `Ok(HttpResponse(...))` with +the redirect status code and `Location` header visible. Since `response_result` +only returns `Error(ResponseError(...))` for status >= 400, 3xx responses +come through as `Ok` — the correct behavior for manual redirect handling. + +Maps directly to Erlang httpc's `{autoredirect, Bool}` HTTP option. + +--- + +## Feature: Global transport configuration + +`TransportConfig` is a new opaque type for tuning the httpc connection pool. +These settings are global (applied to the httpc default profile) and affect +all subsequent HTTP requests. + +```gleam +client.transport_config() +|> client.max_sessions(200) +|> client.max_pipeline_length(0) +|> client.keep_alive_timeout(120_000) +|> client.max_keep_alive_length(50) +|> client.configure_transport() +``` + +### Settings + +| Builder | Default | What it controls | +|---------|---------|-----------------| +| `max_sessions(count)` | 100 | Concurrent TCP connections per host | +| `max_pipeline_length(length)` | 0 | HTTP pipelining depth (0 = disabled) | +| `keep_alive_timeout(ms)` | 60000 | Idle connection lifetime before close | +| `max_keep_alive_length(count)` | 100 | Max requests per keep-alive connection | + +### How it works + +`configure_transport()` does two things: + +1. Writes the config to a named ETS table (`dream_http_client_transport_config`) + so it persists across requests +2. Immediately calls `httpc:set_options/2` on the `default` profile + +The ETS table is created during OTP application startup (in +`dream_http_client_app:start/2`) alongside the existing ref mapping and +recorder tables. `configure_httpc/0`, which runs before every HTTP request, +reads from this table — if populated, it uses the stored values; otherwise, +it falls back to the hardcoded defaults. + +This means: +- Call `configure_transport()` once at startup and all requests use those settings +- Call it again at runtime to update settings without restart +- If never called, behavior is identical to previous versions + +### Why ETS, not an actor + +Transport config is read on every request by `configure_httpc/0` but written +rarely (typically once at startup). ETS provides concurrent reads without +bottlenecking through a single actor process. This follows the same pattern +used by the existing `dream_http_client_ref_mapping` table. + +--- + +## Propagation chain + +The new per-request parameters flow through the full call chain: + +``` +ClientRequest builder (Gleam) + → resolve_connect_timeout / resolve_auto_redirect (Gleam, applies defaults) + → send_sync FFI / start_httpc_stream / start_stream_messages (Gleam → Erlang) + → request_sync/7 / request_stream/8 / request_stream_messages/8 (Erlang shim) + → httpc:request/4 HttpOpts [{connect_timeout, Ms}, {autoredirect, Bool}] +``` + +All three request paths (`send()`, `stream_yielder()`, `start_stream()`) +propagate both parameters. The intermediate types `YielderState` and +`RecordingYielderState` were updated to carry `connect_timeout_ms` and +`auto_redirect` alongside the existing `timeout_ms`. + +--- + +## Test coverage + +14 new tests (206 total across the module): + +| Category | Count | Coverage | +|----------|-------|----------| +| `connect_timeout` builder/getter | 3 | set value, default None, accepts zero | +| `auto_redirect` builder/getter | 3 | set False, default None, set True | +| `TransportConfig` defaults | 1 | all 4 fields match expected defaults | +| `TransportConfig` builders | 4 | one per field with value verification | +| Builder chaining | 1 | all 4 builders chained, all 4 getters verified | +| `configure_transport` | 1 | applies without error (returns Nil) | +| Edge case: zero values | 1 | `max_sessions(0)` accepted | + +3 new test snippets for documentation examples: +- `test/snippets/connect_timeout_config.gleam` +- `test/snippets/redirect_config.gleam` +- `test/snippets/transport_config_example.gleam` + +--- + +## Files changed + +- `modules/http_client/src/dream_http_client/client.gleam` — Added + `connect_timeout` and `auto_redirect` fields to `ClientRequest`, updated + `new()`, added builder/getter/resolve functions, added `TransportConfig` + type with factory/builders/getters, added `configure_transport` and its + FFI declaration +- `modules/http_client/src/dream_http_client/internal.gleam` — Updated + `request_stream` FFI signature to `/8`, `start_stream_messages` to `/8`, + `start_httpc_stream` to accept and pass `connect_timeout_ms` and + `autoredirect` +- `modules/http_client/src/dream_http_client/dream_httpc_shim.erl` — Updated + `request_sync/5` → `/7`, `request_stream/6` → `/8`, + `request_stream_messages/6` → `/8`, `stream_owner_loop/4` → `/6`; + added `configure_transport/4`; updated `configure_httpc/0` to read from ETS +- `modules/http_client/src/dream_http_client/dream_http_client_app.erl` — + Added `dream_http_client_transport_config` ETS table creation +- `modules/http_client/test/client_test.gleam` — 6 new tests for + `connect_timeout` and `auto_redirect` builders/getters +- `modules/http_client/test/transport_config_test.gleam` — New file, 8 tests + for `TransportConfig` builders/getters/apply +- `modules/http_client/test/snippets/connect_timeout_config.gleam` — New snippet +- `modules/http_client/test/snippets/redirect_config.gleam` — New snippet +- `modules/http_client/test/snippets/transport_config_example.gleam` — New snippet +- `modules/http_client/CHANGELOG.md` — 5.2.0 entry +- `modules/http_client/README.md` — Added Configuration section +- `modules/http_client/gleam.toml` — Version bump 5.1.3 → 5.2.0 + +## Upgrading + +Update your dependency: + +```toml +[dependencies] +dream_http_client = ">= 5.2.0 and < 6.0.0" +``` + +Then run: + +```bash +gleam deps download +``` + +No breaking changes. All existing code works without modification. The 6 +previously hardcoded httpc values now use the same defaults but can be +overridden via the new builder functions. + +## Documentation + +- [dream_http_client hexdocs](https://hexdocs.pm/dream_http_client) -- v5.2.0 +- [README](https://github.com/TrustBound/dream/tree/main/modules/http_client) +- [CHANGELOG](https://github.com/TrustBound/dream/blob/main/modules/http_client/CHANGELOG.md) + +## Community + +- [Full Documentation](https://github.com/TrustBound/dream/tree/main/modules/http_client) +- [Discussions](https://github.com/TrustBound/dream/discussions) +- [Report Issues](https://github.com/TrustBound/dream/issues) +- [Contributing Guide](https://github.com/TrustBound/dream/blob/main/CONTRIBUTING.md) + +--- + +**Full Changelog:** [CHANGELOG.md](https://github.com/TrustBound/dream/blob/main/modules/http_client/CHANGELOG.md) diff --git a/modules/http_client/src/dream_http_client/client.gleam b/modules/http_client/src/dream_http_client/client.gleam index 8c3cff5..2350d3a 100644 --- a/modules/http_client/src/dream_http_client/client.gleam +++ b/modules/http_client/src/dream_http_client/client.gleam @@ -221,6 +221,8 @@ pub opaque type ClientRequest { headers: List(Header), body: String, timeout: Option(Int), + connect_timeout: Option(Int), + auto_redirect: Option(Bool), recorder: Option(recorder.Recorder), on_stream_start: Option(fn(List(Header)) -> Nil), on_stream_chunk: Option(fn(BitArray) -> Nil), @@ -266,6 +268,8 @@ pub fn new() -> ClientRequest { headers: [], body: "", timeout: None, + connect_timeout: None, + auto_redirect: None, recorder: None, on_stream_start: None, on_stream_chunk: None, @@ -561,6 +565,263 @@ pub fn timeout(client_request: ClientRequest, timeout_ms: Int) -> ClientRequest ClientRequest(..client_request, timeout: option.Some(timeout_ms)) } +/// Set TCP connection timeout in milliseconds +/// +/// Controls how long to wait for the TCP connection to be established. +/// This is separate from the request `timeout()`, which controls the total +/// time for the entire HTTP request/response cycle. +/// +/// ## Parameters +/// +/// - `client_request`: The request to modify +/// - `ms`: Connection timeout in milliseconds (default: 15000) +/// +/// ## Example +/// +/// ```gleam +/// import dream_http_client/client +/// +/// client.new() +/// |> client.host("api.example.com") +/// |> client.connect_timeout(5000) +/// |> client.send() +/// ``` +pub fn connect_timeout(client_request: ClientRequest, ms: Int) -> ClientRequest { + ClientRequest(..client_request, connect_timeout: option.Some(ms)) +} + +/// Set whether HTTP redirects are followed automatically +/// +/// When enabled (default), the client follows 3xx redirects automatically +/// and returns the final response. When disabled, the 3xx response is +/// returned as `Ok(HttpResponse(...))` with the redirect status code and +/// Location header visible, allowing manual redirect handling. +/// +/// ## Parameters +/// +/// - `client_request`: The request to modify +/// - `enabled`: Whether to follow redirects automatically (default: True) +/// +/// ## Example +/// +/// ```gleam +/// import dream_http_client/client +/// +/// // Disable auto-redirect to inspect the 3xx response +/// client.new() +/// |> client.host("api.example.com") +/// |> client.path("/old-endpoint") +/// |> client.auto_redirect(False) +/// |> client.send() +/// ``` +pub fn auto_redirect( + client_request: ClientRequest, + enabled: Bool, +) -> ClientRequest { + ClientRequest(..client_request, auto_redirect: option.Some(enabled)) +} + +// ============================================================================ +// Transport Configuration +// ============================================================================ + +/// Configuration for the HTTP transport layer +/// +/// Controls connection pool behavior for the underlying httpc client. +/// These settings are global (applied to the httpc default profile) and +/// affect all subsequent HTTP requests. +/// +/// Create with `transport_config()`, configure with builder functions, +/// and apply with `configure_transport()`. +/// +/// ## Example +/// +/// ```gleam +/// import dream_http_client/client +/// +/// client.transport_config() +/// |> client.max_sessions(200) +/// |> client.keep_alive_timeout(120_000) +/// |> client.configure_transport() +/// ``` +pub opaque type TransportConfig { + TransportConfig( + max_sessions: Int, + max_pipeline_length: Int, + keep_alive_timeout: Int, + max_keep_alive_length: Int, + ) +} + +/// Create a transport configuration with default values +/// +/// Returns a `TransportConfig` with Dream's default settings: +/// - max_sessions: 100 (concurrent TCP connections per host) +/// - max_pipeline_length: 0 (pipelining disabled) +/// - keep_alive_timeout: 60000ms (idle connection lifetime) +/// - max_keep_alive_length: 100 (requests per keep-alive connection) +pub fn transport_config() -> TransportConfig { + TransportConfig( + max_sessions: 100, + max_pipeline_length: 0, + keep_alive_timeout: 60_000, + max_keep_alive_length: 100, + ) +} + +/// Set maximum concurrent TCP connections per host +/// +/// Controls how many simultaneous TCP connections can be open to a single +/// host. Increase for high-concurrency workloads; decrease to limit +/// resource usage. +/// +/// ## Parameters +/// +/// - `config`: The transport config to modify +/// - `count`: Maximum connections per host (default: 100) +/// +/// ## Example +/// +/// ```gleam +/// import dream_http_client/client +/// +/// client.transport_config() +/// |> client.max_sessions(200) +/// |> client.configure_transport() +/// ``` +pub fn max_sessions(config: TransportConfig, count: Int) -> TransportConfig { + TransportConfig(..config, max_sessions: count) +} + +/// Set HTTP pipelining depth +/// +/// Controls how many requests can be pipelined on a single TCP connection. +/// Set to 0 to disable pipelining (default and recommended for streaming). +/// Enabling pipelining can improve throughput for many small sequential +/// requests to the same host. +/// +/// ## Parameters +/// +/// - `config`: The transport config to modify +/// - `length`: Pipeline depth, 0 = disabled (default: 0) +/// +/// ## Example +/// +/// ```gleam +/// import dream_http_client/client +/// +/// client.transport_config() +/// |> client.max_pipeline_length(5) +/// |> client.configure_transport() +/// ``` +pub fn max_pipeline_length( + config: TransportConfig, + length: Int, +) -> TransportConfig { + TransportConfig(..config, max_pipeline_length: length) +} + +/// Set idle connection timeout in milliseconds +/// +/// Controls how long an idle keep-alive connection is held open before +/// being closed. Longer timeouts improve connection reuse but consume +/// resources. +/// +/// ## Parameters +/// +/// - `config`: The transport config to modify +/// - `ms`: Idle timeout in milliseconds (default: 60000) +/// +/// ## Example +/// +/// ```gleam +/// import dream_http_client/client +/// +/// client.transport_config() +/// |> client.keep_alive_timeout(120_000) +/// |> client.configure_transport() +/// ``` +pub fn keep_alive_timeout(config: TransportConfig, ms: Int) -> TransportConfig { + TransportConfig(..config, keep_alive_timeout: ms) +} + +/// Set maximum requests per keep-alive connection +/// +/// Controls how many requests can be sent on a single keep-alive +/// connection before it is closed and a new one opened. This limits +/// the lifetime of individual TCP connections. +/// +/// ## Parameters +/// +/// - `config`: The transport config to modify +/// - `count`: Maximum requests per connection (default: 100) +/// +/// ## Example +/// +/// ```gleam +/// import dream_http_client/client +/// +/// client.transport_config() +/// |> client.max_keep_alive_length(50) +/// |> client.configure_transport() +/// ``` +pub fn max_keep_alive_length( + config: TransportConfig, + count: Int, +) -> TransportConfig { + TransportConfig(..config, max_keep_alive_length: count) +} + +/// Get the configured maximum concurrent TCP connections per host +pub fn get_max_sessions(config: TransportConfig) -> Int { + config.max_sessions +} + +/// Get the configured HTTP pipelining depth +pub fn get_max_pipeline_length(config: TransportConfig) -> Int { + config.max_pipeline_length +} + +/// Get the configured idle connection timeout in milliseconds +pub fn get_keep_alive_timeout(config: TransportConfig) -> Int { + config.keep_alive_timeout +} + +/// Get the configured maximum requests per keep-alive connection +pub fn get_max_keep_alive_length(config: TransportConfig) -> Int { + config.max_keep_alive_length +} + +/// Apply transport configuration to the HTTP client +/// +/// Sets the httpc profile options for all subsequent HTTP requests. +/// Call this once during application startup. Can be called again to +/// update settings at runtime. +/// +/// ## Example +/// +/// ```gleam +/// client.transport_config() +/// |> client.max_sessions(200) +/// |> client.configure_transport() +/// ``` +pub fn configure_transport(config: TransportConfig) -> Nil { + configure_transport_ffi( + config.max_sessions, + config.max_pipeline_length, + config.keep_alive_timeout, + config.max_keep_alive_length, + ) +} + +@external(erlang, "dream_httpc_shim", "configure_transport") +fn configure_transport_ffi( + max_sessions: Int, + max_pipeline_length: Int, + keep_alive_timeout: Int, + max_keep_alive_length: Int, +) -> Nil + /// Set callback for stream start event /// /// Sets a function to be called when a stream starts and headers are received. @@ -869,6 +1130,20 @@ pub fn get_timeout(client_request: ClientRequest) -> Option(Int) { client_request.timeout } +/// Get the configured TCP connection timeout +/// +/// Returns `None` if the default (15000ms) will be used. +pub fn get_connect_timeout(client_request: ClientRequest) -> Option(Int) { + client_request.connect_timeout +} + +/// Get the configured auto-redirect setting +/// +/// Returns `None` if the default (True) will be used. +pub fn get_auto_redirect(client_request: ClientRequest) -> Option(Bool) { + client_request.auto_redirect +} + /// Get the recorder from a request /// /// Returns the optional recorder attached to the request for recording or playback. @@ -1174,9 +1449,19 @@ fn send_client_request_to_httpc_with_meta( let method_dynamic = atom.to_dynamic(method_atom) let body = <> let timeout_value = resolve_timeout(client_request) + let connect_timeout_value = resolve_connect_timeout(client_request) + let auto_redirect_value = resolve_auto_redirect(client_request) case - send_sync(method_dynamic, url, http_request.headers, body, timeout_value) + send_sync( + method_dynamic, + url, + http_request.headers, + body, + timeout_value, + connect_timeout_value, + auto_redirect_value, + ) { Ok(#(status, headers, response_body)) -> { response_body @@ -1216,6 +1501,20 @@ fn resolve_timeout(client_request: ClientRequest) -> Int { } } +fn resolve_connect_timeout(client_request: ClientRequest) -> Int { + case client_request.connect_timeout { + Some(ms) -> ms + None -> 15_000 + } +} + +fn resolve_auto_redirect(client_request: ClientRequest) -> Bool { + case client_request.auto_redirect { + Some(enabled) -> enabled + None -> True + } +} + @external(erlang, "dream_httpc_shim", "request_sync") fn send_sync( method: d.Dynamic, @@ -1223,6 +1522,8 @@ fn send_sync( headers: List(#(String, String)), body: BitArray, timeout_ms: Int, + connect_timeout_ms: Int, + autoredirect: Bool, ) -> Result(#(Int, List(#(String, String)), BitArray), String) /// Stream HTTP response chunks using a yielder @@ -1376,6 +1677,8 @@ fn create_stream_yielder_from_client_request( ) -> yielder.Yielder(Result(bytes_tree.BytesTree, String)) { let http_request = to_http_request(client_request) let timeout_value = resolve_timeout(client_request) + let connect_timeout_value = resolve_connect_timeout(client_request) + let auto_redirect_value = resolve_auto_redirect(client_request) case client_request.recorder { option.Some(recorder_instance) -> @@ -1384,8 +1687,16 @@ fn create_stream_yielder_from_client_request( recorder_instance, http_request, timeout_value, + connect_timeout_value, + auto_redirect_value, + ) + option.None -> + create_plain_yielder( + http_request, + timeout_value, + connect_timeout_value, + auto_redirect_value, ) - option.None -> create_plain_yielder(http_request, timeout_value) } } @@ -1394,16 +1705,19 @@ fn stream_yielder_with_record_mode( recorder_instance: recorder.Recorder, http_request: request.Request(String), timeout_value: Int, + connect_timeout_value: Int, + auto_redirect_value: Bool, ) -> yielder.Yielder(Result(bytes_tree.BytesTree, String)) { case recorder.is_record_mode(recorder_instance) { True -> { - // Recording mode - wrap yielder to capture chunks let recorded_request = client_request_to_recorded_request(client_request) let initial_state = RecordingYielderState( owner: None, http_req: http_request, timeout_ms: timeout_value, + connect_timeout_ms: connect_timeout_value, + auto_redirect: auto_redirect_value, recorder: recorder_instance, recorded_request: recorded_request, start_headers: [], @@ -1413,17 +1727,29 @@ fn stream_yielder_with_record_mode( yielder.unfold(initial_state, handle_recording_yielder_unfold) } False -> - // Playback mode was already handled, use normal yielder - create_plain_yielder(http_request, timeout_value) + create_plain_yielder( + http_request, + timeout_value, + connect_timeout_value, + auto_redirect_value, + ) } } fn create_plain_yielder( http_request: request.Request(String), timeout_value: Int, + connect_timeout_value: Int, + auto_redirect_value: Bool, ) -> yielder.Yielder(Result(bytes_tree.BytesTree, String)) { let initial_state = - YielderState(owner: None, http_req: http_request, timeout_ms: timeout_value) + YielderState( + owner: None, + http_req: http_request, + timeout_ms: timeout_value, + connect_timeout_ms: connect_timeout_value, + auto_redirect: auto_redirect_value, + ) yielder.unfold(initial_state, handle_yielder_unfold_with_deps) } @@ -1448,6 +1774,8 @@ type YielderState { owner: Option(d.Dynamic), http_req: request.Request(String), timeout_ms: Int, + connect_timeout_ms: Int, + auto_redirect: Bool, ) } @@ -1456,6 +1784,8 @@ type RecordingYielderState { owner: Option(d.Dynamic), http_req: request.Request(String), timeout_ms: Int, + connect_timeout_ms: Int, + auto_redirect: Bool, recorder: recorder.Recorder, recorded_request: recording.RecordedRequest, start_headers: List(#(String, String)), @@ -1515,7 +1845,12 @@ fn handle_yielder_start_with_state( state: YielderState, ) -> yielder.Step(Result(bytes_tree.BytesTree, String), YielderState) { let request_result = - internal.start_httpc_stream(state.http_req, state.timeout_ms) + internal.start_httpc_stream( + state.http_req, + state.timeout_ms, + state.connect_timeout_ms, + state.auto_redirect, + ) let owner = internal.extract_owner_pid(request_result) case internal.receive_next(owner, state.timeout_ms) { Ok(option.Some(bin)) -> @@ -1567,7 +1902,12 @@ fn handle_recording_yielder_start( state: RecordingYielderState, ) -> yielder.Step(Result(bytes_tree.BytesTree, String), RecordingYielderState) { let request_result = - internal.start_httpc_stream(state.http_req, state.timeout_ms) + internal.start_httpc_stream( + state.http_req, + state.timeout_ms, + state.connect_timeout_ms, + state.auto_redirect, + ) let owner = internal.extract_owner_pid(request_result) let start_headers = case internal.get_stream_start_headers(owner, state.timeout_ms) @@ -1733,6 +2073,8 @@ fn send_stream_messages_to_httpc( let body = <> let caller_process = process.self() let timeout_value = resolve_timeout(client_request) + let connect_timeout_value = resolve_connect_timeout(client_request) + let auto_redirect_value = resolve_auto_redirect(client_request) let start_result = internal.start_stream_messages( @@ -1742,6 +2084,8 @@ fn send_stream_messages_to_httpc( body, caller_process, timeout_value, + connect_timeout_value, + auto_redirect_value, ) case parse_stream_start_result(start_result) { diff --git a/modules/http_client/src/dream_http_client/dream_http_client_app.erl b/modules/http_client/src/dream_http_client/dream_http_client_app.erl index d5b5b0a..dea0872 100644 --- a/modules/http_client/src/dream_http_client/dream_http_client_app.erl +++ b/modules/http_client/src/dream_http_client/dream_http_client_app.erl @@ -5,6 +5,7 @@ start(_Type, _Args) -> ets:new(dream_http_client_ref_mapping, [set, public, named_table]), ets:new(dream_http_client_stream_recorders, [set, public, named_table]), + ets:new(dream_http_client_transport_config, [set, public, named_table]), dream_http_client_sup:start_link(). stop(_State) -> diff --git a/modules/http_client/src/dream_http_client/dream_httpc_shim.erl b/modules/http_client/src/dream_http_client/dream_httpc_shim.erl index db3bf81..124187d 100644 --- a/modules/http_client/src/dream_http_client/dream_httpc_shim.erl +++ b/modules/http_client/src/dream_http_client/dream_httpc_shim.erl @@ -1,8 +1,9 @@ -module(dream_httpc_shim). --export([request_stream/6, fetch_next/2, fetch_start_headers/2, request_stream_messages/6, +-export([request_stream/8, fetch_next/2, fetch_start_headers/2, request_stream_messages/8, cancel_stream/1, cancel_stream_by_string/1, receive_stream_message/1, - decode_stream_message_for_selector/1, normalize_headers/1, request_sync/5, + decode_stream_message_for_selector/1, normalize_headers/1, request_sync/7, + configure_transport/4, ets_table_exists/1, ets_new/2, ets_insert/7, ets_lookup/2, ets_delete/2]). %% @doc Start a streaming HTTP request with pull-based chunk retrieval @@ -40,7 +41,7 @@ %% - `fetch_next` will detect the dead process and return an error %% - Ensures `ssl` and `inets` applications are started before making requests %% - Configures httpc with streaming-optimized settings (no pipelining, high session cap) -request_stream(Method, Url, Headers, Body, _Receiver, TimeoutMs) -> +request_stream(Method, Url, Headers, Body, _Receiver, TimeoutMs, ConnectTimeoutMs, AutoRedirect) -> ok = ensure_started(ssl), ok = ensure_started(inets), ok = configure_httpc(), @@ -48,7 +49,8 @@ request_stream(Method, Url, Headers, Body, _Receiver, TimeoutMs) -> NUrl = to_list(Url), NHeaders = maybe_add_accept_encoding(to_headers(Headers)), Req = build_req(NUrl, NHeaders, Body), - Owner = spawn(fun() -> stream_owner_loop(Method, Req, NUrl, TimeoutMs) end), + Owner = spawn(fun() -> stream_owner_loop(Method, Req, NUrl, TimeoutMs, + ConnectTimeoutMs, AutoRedirect) end), {ok, Owner}. %% @doc Fetch the next chunk from a streaming HTTP request @@ -129,8 +131,9 @@ fetch_start_headers(OwnerPid, TimeoutMs) -> end. %% Stream owner process: starts httpc in continuous mode and services fetch_next requests -stream_owner_loop(Method, Req, _Url, TimeoutMs) -> - HttpOpts = [{timeout, TimeoutMs}, {connect_timeout, 15000}, {autoredirect, true}], +stream_owner_loop(Method, Req, _Url, TimeoutMs, ConnectTimeoutMs, AutoRedirect) -> + HttpOpts = [{timeout, TimeoutMs}, {connect_timeout, ConnectTimeoutMs}, + {autoredirect, AutoRedirect}], Opts = [{stream, self}, {sync, false}], case httpc:request(Method, Req, HttpOpts, Opts) of {ok, RequestId} -> @@ -292,20 +295,30 @@ ensure_started(App) -> ok end. -%% Configure httpc with appropriate settings for streaming +%% Configure httpc with appropriate settings for streaming. +%% Reads transport config from ETS if configure_transport/4 has been called, +%% otherwise uses defaults. configure_httpc() -> - %% Increase parallelism and avoid head-of-line blocking with streaming - %% - Disable HTTP pipelining so long-lived streams don't block queued requests - %% - Raise session cap so concurrent streams can use separate connections - %% - Keep-alive tuning to allow reuse for non-streaming while not limiting concurrency - ok = - httpc:set_options([{max_sessions, 100}, - {max_pipeline_length, 0}, - {keep_alive_timeout, 60000}, - {max_keep_alive_length, 100}], - default), + Config = case ets:lookup(dream_http_client_transport_config, config) of + [{config, MaxS, MaxP, KeepT, MaxK}] -> + [{max_sessions, MaxS}, {max_pipeline_length, MaxP}, + {keep_alive_timeout, KeepT}, {max_keep_alive_length, MaxK}]; + [] -> + [{max_sessions, 100}, {max_pipeline_length, 0}, + {keep_alive_timeout, 60000}, {max_keep_alive_length, 100}] + end, + ok = httpc:set_options(Config, default), ok. +configure_transport(MaxSessions, MaxPipelineLength, KeepAliveTimeout, MaxKeepAliveLength) -> + ets:insert(dream_http_client_transport_config, + {config, MaxSessions, MaxPipelineLength, KeepAliveTimeout, MaxKeepAliveLength}), + ok = httpc:set_options([ + {max_sessions, MaxSessions}, {max_pipeline_length, MaxPipelineLength}, + {keep_alive_timeout, KeepAliveTimeout}, {max_keep_alive_length, MaxKeepAliveLength} + ], default), + nil. + %% Convert various types to string lists to_list(S) when is_binary(S) -> unicode:characters_to_list(S); @@ -497,7 +510,8 @@ build_req(Url, Headers, Body) -> %% - Stores bidirectional mapping: `StringId <-> HttpcRef` for cancellation %% - String ID is derived from httpc ref's string representation (guaranteed unique) %% - Ensures `ssl` and `inets` applications are started before making requests -request_stream_messages(Method, Url, Headers, Body, _ReceiverPid, TimeoutMs) -> +request_stream_messages(Method, Url, Headers, Body, _ReceiverPid, TimeoutMs, + ConnectTimeoutMs, AutoRedirect) -> ok = ensure_started(ssl), ok = ensure_started(inets), ok = configure_httpc(), @@ -506,7 +520,8 @@ request_stream_messages(Method, Url, Headers, Body, _ReceiverPid, TimeoutMs) -> NHeaders = maybe_add_accept_encoding(to_headers(Headers)), Req = build_req(NUrl, NHeaders, Body), - HttpOpts = [{timeout, TimeoutMs}, {connect_timeout, 15000}, {autoredirect, true}], + HttpOpts = [{timeout, TimeoutMs}, {connect_timeout, ConnectTimeoutMs}, + {autoredirect, AutoRedirect}], StreamOpts = [{stream, self}, {sync, false}], case httpc:request(Method, Req, HttpOpts, StreamOpts) of @@ -822,6 +837,8 @@ ensure_utf8_binary(Other) -> %% - `Headers`: List of `{Key, Value}` tuples where both are strings or binaries %% - `Body`: Request body as a binary (empty binary `<<>>` for requests without body) %% - `TimeoutMs`: Request timeout in milliseconds +%% - `ConnectTimeoutMs`: TCP connection timeout in milliseconds +%% - `AutoRedirect`: Whether to follow 3xx redirects automatically (boolean) %% %% ## Returns %% @@ -835,7 +852,7 @@ ensure_utf8_binary(Other) -> %% ## Examples %% %% ```erlang -%% {ok, {Status, Headers, Body}} = request_sync(get, "https://api.example.com/users", [], <<>>, 30000), +%% {ok, {Status, Headers, Body}} = request_sync(get, "https://api.example.com/users", [], <<>>, 30000, 15000, true), %% ``` %% %% ## Notes @@ -846,7 +863,7 @@ ensure_utf8_binary(Other) -> %% - Ensures `ssl` and `inets` applications are started before making requests %% - Configures httpc with appropriate timeout and redirect settings %% - Error reasons are formatted as binaries for Gleam compatibility -request_sync(Method, Url, Headers, Body, TimeoutMs) -> +request_sync(Method, Url, Headers, Body, TimeoutMs, ConnectTimeoutMs, AutoRedirect) -> ok = ensure_started(ssl), ok = ensure_started(inets), ok = configure_httpc(), @@ -855,8 +872,8 @@ request_sync(Method, Url, Headers, Body, TimeoutMs) -> NHeaders = maybe_add_accept_encoding(to_headers(Headers)), Req = build_req(NUrl, NHeaders, Body), - %% Use synchronous mode WITHOUT streaming - this is what send() should use - HttpOpts = [{timeout, TimeoutMs}, {connect_timeout, 15000}, {autoredirect, true}], + HttpOpts = [{timeout, TimeoutMs}, {connect_timeout, ConnectTimeoutMs}, + {autoredirect, AutoRedirect}], Opts = [{sync, true}, {body_format, binary}], case httpc:request(Method, Req, HttpOpts, Opts) of diff --git a/modules/http_client/src/dream_http_client/internal.gleam b/modules/http_client/src/dream_http_client/internal.gleam index 3dd95e1..a01fb75 100644 --- a/modules/http_client/src/dream_http_client/internal.gleam +++ b/modules/http_client/src/dream_http_client/internal.gleam @@ -30,6 +30,8 @@ fn request_stream( body: BitArray, receiver: process.Pid, timeout_ms: Int, + connect_timeout_ms: Int, + autoredirect: Bool, ) -> d.Dynamic @external(erlang, "dream_httpc_shim", "fetch_next") @@ -85,6 +87,8 @@ pub fn atomize_method(method: http.Method) -> atom.Atom { pub fn start_httpc_stream( request: Request(String), timeout_ms: Int, + connect_timeout_ms: Int, + autoredirect: Bool, ) -> d.Dynamic { let port_string = case request.port { option.Some(port) -> ":" <> int.to_string(port) @@ -104,7 +108,16 @@ pub fn start_httpc_stream( let method_atom = atomize_method(request.method) let body = <> let receiver = process.self() - request_stream(method_atom, url, request.headers, body, receiver, timeout_ms) + request_stream( + method_atom, + url, + request.headers, + body, + receiver, + timeout_ms, + connect_timeout_ms, + autoredirect, + ) } /// Extract the owner PID from the request result @@ -280,6 +293,8 @@ pub fn start_stream_messages( body: BitArray, receiver: process.Pid, timeout_ms: Int, + connect_timeout_ms: Int, + autoredirect: Bool, ) -> d.Dynamic /// Cancel a streaming request diff --git a/modules/http_client/test/client_test.gleam b/modules/http_client/test/client_test.gleam index 22366ec..ca5be3c 100644 --- a/modules/http_client/test/client_test.gleam +++ b/modules/http_client/test/client_test.gleam @@ -110,6 +110,66 @@ pub fn timeout_sets_request_timeout_test() { client.get_timeout(updated) |> should.equal(option.Some(timeout_value)) } +pub fn connect_timeout_sets_request_connect_timeout_test() { + // Arrange + let request = client.new() + + // Act + let updated = client.connect_timeout(request, 5000) + + // Assert + client.get_connect_timeout(updated) |> should.equal(option.Some(5000)) +} + +pub fn connect_timeout_defaults_to_none_test() { + // Arrange & Act + let request = client.new() + + // Assert + client.get_connect_timeout(request) |> should.equal(option.None) +} + +pub fn connect_timeout_accepts_zero_test() { + // Arrange + let request = client.new() + + // Act + let updated = client.connect_timeout(request, 0) + + // Assert + client.get_connect_timeout(updated) |> should.equal(option.Some(0)) +} + +pub fn auto_redirect_sets_request_auto_redirect_test() { + // Arrange + let request = client.new() + + // Act + let updated = client.auto_redirect(request, False) + + // Assert + client.get_auto_redirect(updated) |> should.equal(option.Some(False)) +} + +pub fn auto_redirect_defaults_to_none_test() { + // Arrange & Act + let request = client.new() + + // Assert + client.get_auto_redirect(request) |> should.equal(option.None) +} + +pub fn auto_redirect_can_be_set_to_true_test() { + // Arrange + let request = client.new() + + // Act + let updated = client.auto_redirect(request, True) + + // Assert + client.get_auto_redirect(updated) |> should.equal(option.Some(True)) +} + pub fn add_header_adds_header_to_request_test() { // Arrange let request = client.new() diff --git a/modules/http_client/test/snippets/connect_timeout_config.gleam b/modules/http_client/test/snippets/connect_timeout_config.gleam new file mode 100644 index 0000000..4298607 --- /dev/null +++ b/modules/http_client/test/snippets/connect_timeout_config.gleam @@ -0,0 +1,15 @@ +import dream_http_client/client.{ + type HttpResponse, type SendError, connect_timeout, host, path, port, scheme, + send, +} +import gleam/http + +pub fn fast_timeout() -> Result(HttpResponse, SendError) { + client.new() + |> scheme(http.Http) + |> host("localhost") + |> port(9876) + |> path("/text") + |> connect_timeout(5000) + |> send() +} diff --git a/modules/http_client/test/snippets/redirect_config.gleam b/modules/http_client/test/snippets/redirect_config.gleam new file mode 100644 index 0000000..2d5afbc --- /dev/null +++ b/modules/http_client/test/snippets/redirect_config.gleam @@ -0,0 +1,15 @@ +import dream_http_client/client.{ + type HttpResponse, type SendError, auto_redirect, host, path, port, scheme, + send, +} +import gleam/http + +pub fn no_auto_redirect() -> Result(HttpResponse, SendError) { + client.new() + |> scheme(http.Http) + |> host("localhost") + |> port(9876) + |> path("/text") + |> auto_redirect(False) + |> send() +} diff --git a/modules/http_client/test/snippets/transport_config_example.gleam b/modules/http_client/test/snippets/transport_config_example.gleam new file mode 100644 index 0000000..c85e3cc --- /dev/null +++ b/modules/http_client/test/snippets/transport_config_example.gleam @@ -0,0 +1,8 @@ +import dream_http_client/client + +pub fn configure_high_concurrency() -> Nil { + client.transport_config() + |> client.max_sessions(200) + |> client.keep_alive_timeout(120_000) + |> client.configure_transport() +} diff --git a/modules/http_client/test/transport_config_test.gleam b/modules/http_client/test/transport_config_test.gleam new file mode 100644 index 0000000..12d29a1 --- /dev/null +++ b/modules/http_client/test/transport_config_test.gleam @@ -0,0 +1,95 @@ +import dream_http_client/client +import gleeunit/should + +pub fn transport_config_has_correct_defaults_test() { + // Arrange & Act + let config = client.transport_config() + + // Assert + client.get_max_sessions(config) |> should.equal(100) + client.get_max_pipeline_length(config) |> should.equal(0) + client.get_keep_alive_timeout(config) |> should.equal(60_000) + client.get_max_keep_alive_length(config) |> should.equal(100) +} + +pub fn max_sessions_sets_value_test() { + // Arrange + let config = client.transport_config() + + // Act + let updated = client.max_sessions(config, 200) + + // Assert + client.get_max_sessions(updated) |> should.equal(200) +} + +pub fn max_sessions_accepts_zero_test() { + // Arrange + let config = client.transport_config() + + // Act + let updated = client.max_sessions(config, 0) + + // Assert + client.get_max_sessions(updated) |> should.equal(0) +} + +pub fn max_pipeline_length_sets_value_test() { + // Arrange + let config = client.transport_config() + + // Act + let updated = client.max_pipeline_length(config, 5) + + // Assert + client.get_max_pipeline_length(updated) |> should.equal(5) +} + +pub fn keep_alive_timeout_sets_value_test() { + // Arrange + let config = client.transport_config() + + // Act + let updated = client.keep_alive_timeout(config, 120_000) + + // Assert + client.get_keep_alive_timeout(updated) |> should.equal(120_000) +} + +pub fn max_keep_alive_length_sets_value_test() { + // Arrange + let config = client.transport_config() + + // Act + let updated = client.max_keep_alive_length(config, 50) + + // Assert + client.get_max_keep_alive_length(updated) |> should.equal(50) +} + +pub fn transport_config_builder_chain_sets_all_values_test() { + // Arrange & Act + let config = + client.transport_config() + |> client.max_sessions(200) + |> client.max_pipeline_length(5) + |> client.keep_alive_timeout(120_000) + |> client.max_keep_alive_length(50) + + // Assert + client.get_max_sessions(config) |> should.equal(200) + client.get_max_pipeline_length(config) |> should.equal(5) + client.get_keep_alive_timeout(config) |> should.equal(120_000) + client.get_max_keep_alive_length(config) |> should.equal(50) +} + +pub fn configure_transport_applies_without_error_test() { + // Arrange + let config = client.transport_config() + + // Act + let result = client.configure_transport(config) + + // Assert + result |> should.equal(Nil) +} From d7bce4a8be739d2059c6f2945ee0a248e18cb13c Mon Sep 17 00:00:00 2001 From: Dara Rockwell Date: Wed, 25 Mar 2026 17:54:34 -0600 Subject: [PATCH 2/7] feat: replace httpc backend with gun for HTTP/2 multiplexing support MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## Why This Change Was Made - The application needs to make tens of thousands of concurrent HTTP requests to HTTP/2 servers (OpenAI, Anthropic, etc.). Erlang's built-in httpc is limited to HTTP/1.1 with one-connection-per-request, which means 10k requests = 10k TCP connections. Gun supports HTTP/2 multiplexing, allowing thousands of concurrent streams over a handful of connections. - TransportConfig was redesigned with 13 gun-native fields (connection pooling, timeouts, HTTP/2 flow control, keepalive) to expose full configurability rather than the 4 httpc-specific fields that would have been immediately obsolete. ## What Was Changed - Replaced dream_httpc_shim.erl (1177 lines) with dream_http_shim.erl using gun:open/await_up/get/post/await/await_body for sync, pull-stream, and message-stream request modes - Added dream_http_conn_manager.erl: a gen_server managing gun connections via an ETS bag table with per-host round-robin selection, idle reaping, dead connection cleanup, and crash recovery in init/1 - Redesigned TransportConfig from 4 httpc fields to 13 gun-native fields with full builder/getter API - Implemented manual auto-redirect (301/302/303/307/308, max 5 hops) with relative URL resolution since gun does not follow redirects natively - Added stale connection retry: requests that hit a server-closed connection are retried once on a fresh connection - Added mock server redirect endpoints and 11 integration tests covering all redirect status codes across send(), stream_yielder(), and start_stream() - Updated all FFI references from dream_httpc_shim to dream_http_shim - Updated CHANGELOG, README, release notes, test snippets ## Note to Future Engineer - Gun sends connection-level messages (gun_up/gun_down) to the owner process (the gen_server) and stream-level messages (gun_response/gun_data) to the reply_to process (the request caller). This split is load-bearing — do not try to consolidate message handling into the gen_server or you'll create a bottleneck that serializes all HTTP traffic through one process. - The ETS table is a `bag` (not `set`) because we store multiple connections per host. This means ets:select_replace doesn't work — touch/1 does match_delete + insert instead. Yes, we learned this the hard way. - drain_stream uses a hardcoded 5000ms timeout, not the request timeout. This is intentional — it's draining a redirect response body, not waiting for user data. If you change it to the request timeout, you'll wonder why redirects occasionally take 30 seconds. You're welcome. - gun:await defaults to 5 seconds if you don't pass a timeout. Every call site explicitly passes TimeoutMs. If you add a new gun:await call and forget the timeout, you'll get mysterious 5-second failures in production. Ask me how I know. --- modules/http_client/CHANGELOG.md | 60 +- modules/http_client/README.md | 40 +- modules/http_client/gleam.toml | 1 + modules/http_client/manifest.toml | 19 +- modules/http_client/releases/release-5.2.0.md | 209 +-- .../src/dream_http_client/client.gleam | 388 ++++-- .../dream_http_client_app.erl | 1 + .../dream_http_client_sup.erl | 8 +- .../dream_http_conn_manager.erl | 256 ++++ .../src/dream_http_client/dream_http_shim.erl | 949 +++++++++++++ .../dream_http_client/dream_httpc_shim.erl | 1177 ----------------- .../src/dream_http_client/internal.gleam | 40 +- .../test/error_handling_test.gleam | 5 +- .../test/ets_table_ownership_test.gleam | 5 +- modules/http_client/test/redirect_test.gleam | 183 +++ .../snippets/transport_config_example.gleam | 5 +- .../http_client/test/start_stream_test.gleam | 4 +- .../test/stream_error_decode_test.gleam | 11 +- .../stream_non_streaming_response_test.gleam | 6 +- .../test/transport_config_test.gleam | 169 ++- .../controllers/api_controller.gleam | 88 ++ .../src/dream_mock_server/router.gleam | 58 + 22 files changed, 2151 insertions(+), 1531 deletions(-) create mode 100644 modules/http_client/src/dream_http_client/dream_http_conn_manager.erl create mode 100644 modules/http_client/src/dream_http_client/dream_http_shim.erl delete mode 100644 modules/http_client/src/dream_http_client/dream_httpc_shim.erl create mode 100644 modules/http_client/test/redirect_test.gleam diff --git a/modules/http_client/CHANGELOG.md b/modules/http_client/CHANGELOG.md index 4754275..3eef4aa 100644 --- a/modules/http_client/CHANGELOG.md +++ b/modules/http_client/CHANGELOG.md @@ -7,36 +7,60 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## 5.2.0 - 2026-03-25 +### Changed + +- **HTTP backend replaced: `httpc` → `gun`.** The underlying HTTP client has + been swapped from Erlang's `httpc` to [gun](https://github.com/ninenines/gun), + a production-grade HTTP/1.1 and HTTP/2 client. This enables native HTTP/2 + multiplexing for high-concurrency workloads (10k+ concurrent requests over + a single TCP connection to HTTP/2 servers). All public API contracts are + preserved — `send()`, `stream_yielder()`, and `start_stream()` behave + identically from the caller's perspective. + ### Added - **Per-request TCP connection timeout.** `connect_timeout(ms)` controls how long to wait for the TCP connection to be established, separate from the existing `timeout()` which controls the entire request/response cycle. Defaults to - 15000ms (matching the previous hardcoded value). Useful for failing fast against - unreachable hosts without shortening the overall request timeout. + 15000ms. Useful for failing fast against unreachable hosts without shortening + the overall request timeout. - **Per-request redirect control.** `auto_redirect(enabled)` controls whether 3xx redirects are followed automatically. When disabled, the 3xx response is returned as `Ok(HttpResponse(...))` with the status code and `Location` header visible, allowing manual redirect handling. Defaults to `True` (matching - previous behavior). + previous behavior). Note: gun does not handle redirects natively; the shim + implements manual redirect following (up to 5 hops). - **Global transport configuration.** `TransportConfig` opaque type with builder - functions for connection pool tuning: - - `max_sessions(count)` — concurrent TCP connections per host (default: 100) - - `max_pipeline_length(length)` — HTTP pipelining depth, 0 = disabled (default: 0) - - `keep_alive_timeout(ms)` — idle connection lifetime (default: 60000ms) - - `max_keep_alive_length(count)` — requests per keep-alive connection (default: 100) + functions for gun connection pool tuning (13 fields): + - `max_connections(count)` — TCP connections per host (default: 50) + - `idle_timeout(ms)` — idle connection lifetime (default: 60000ms) + - `default_connect_timeout(ms)` — TCP connect timeout (default: 15000ms) + - `domain_lookup_timeout(ms)` — DNS resolution timeout (default: 5000ms) + - `tls_handshake_timeout(ms)` — TLS negotiation timeout (default: 10000ms) + - `retry(count)` — connection retry attempts (default: 3) + - `retry_timeout(ms)` — delay between retries (default: 1000ms) + - `keepalive(ms)` — HTTP/2 PING interval (default: 30000ms) + - `keepalive_tolerance(count)` — missed PINGs before close (default: 3) + - `max_concurrent_streams(count)` — HTTP/2 streams per connection (default: 100) + - `initial_connection_window_size(bytes)` — HTTP/2 flow control (default: 65535) + - `initial_stream_window_size(bytes)` — HTTP/2 stream flow control (default: 65535) + - `closing_timeout(ms)` — graceful shutdown timeout (default: 15000ms) Create with `transport_config()`, configure with builders, apply with - `configure_transport()`. Settings are global (applied to the httpc default - profile) and affect all subsequent requests. Stored in ETS for concurrent - read access, created during OTP application startup alongside the existing - tables. -- **Getter functions** for all new fields: `get_connect_timeout()`, - `get_auto_redirect()`, `get_max_sessions()`, `get_max_pipeline_length()`, - `get_keep_alive_timeout()`, `get_max_keep_alive_length()`. -- **14 new tests** covering builder/getter round-trips, default values, edge - cases (zero values), builder chaining, and transport application. 3 new - test snippets for documentation examples. + `configure_transport()`. Settings are global and affect all subsequent + requests. Stored in ETS for concurrent read access. +- **Connection pool manager.** `dream_http_conn_manager` gen_server manages + a per-host connection pool backed by an ETS `bag` table. Features round-robin + selection, automatic dead-connection cleanup, idle connection reaping, and + crash recovery on restart. +- **Stale connection retry.** Requests that hit a server-closed connection + (`{stream_error, closed}`) are automatically retried once on a fresh + connection, preventing spurious failures when connection pool entries outlive + the server-side keep-alive. +- **Getter functions** for all 13 `TransportConfig` fields. +- **18 new tests** covering builder/getter round-trips, default values, edge + cases (zero values), builder chaining, transport application, and concurrent + streaming scenarios. ## 5.1.3 - 2026-03-17 diff --git a/modules/http_client/README.md b/modules/http_client/README.md index 8d78a0f..5685f8b 100644 --- a/modules/http_client/README.md +++ b/modules/http_client/README.md @@ -25,7 +25,7 @@ **Type-safe HTTP client for Gleam with recording + streaming support.** -A standalone HTTP/HTTPS client built on Erlang's battle-tested `httpc`. Supports blocking requests, yielder streaming, and process-based streaming via callbacks. Built with the same quality standards as [Dream](https://github.com/TrustBound/dream), but completely independent—use it in any Gleam project. +A standalone HTTP/HTTPS client built on [gun](https://github.com/ninenines/gun), supporting HTTP/1.1 and HTTP/2 with native multiplexing. Provides blocking requests, yielder streaming, and process-based streaming via callbacks. Built with the same quality standards as [Dream](https://github.com/TrustBound/dream), but completely independent—use it in any Gleam project. --- @@ -49,7 +49,7 @@ A standalone HTTP/HTTPS client built on Erlang's battle-tested `httpc`. Supports | **OTP-first design** | Process-based streams work great with OTP | | **Recording/playback** | Record HTTP calls for tests, debug production, work offline | | **Type-safe** | `Result` types force error handling—no silent failures | -| **Battle-tested** | Built on Erlang's `httpc`—proven in production for decades | +| **HTTP/2 ready** | Native HTTP/2 multiplexing via gun for high concurrency | | **Framework-independent** | Zero dependencies on Dream or other frameworks | | **Concurrent streams** | Handle multiple HTTP streams in a single actor | | **Stream cancellation** | Cancel in-flight requests cleanly | @@ -237,14 +237,15 @@ client.new() ### Transport Settings -Configure the underlying connection pool (global, affects all requests): +Configure the gun connection pool (global, affects all requests): ```gleam import dream_http_client/client client.transport_config() -|> client.max_sessions(200) -|> client.keep_alive_timeout(120_000) +|> client.max_connections(200) +|> client.idle_timeout(120_000) +|> client.max_concurrent_streams(500) |> client.configure_transport() ``` @@ -252,15 +253,24 @@ client.transport_config() ### Defaults -| Setting | Default | Scope | -| ---------------------- | ------- | ----------- | -| `timeout` | 30000ms | Per-request | -| `connect_timeout` | 15000ms | Per-request | -| `auto_redirect` | True | Per-request | -| `max_sessions` | 100 | Global | -| `max_pipeline_length` | 0 | Global | -| `keep_alive_timeout` | 60000ms | Global | -| `max_keep_alive_length`| 100 | Global | +| Setting | Default | Scope | +| -------------------------------- | ------- | ----------- | +| `timeout` | 30000ms | Per-request | +| `connect_timeout` | 15000ms | Per-request | +| `auto_redirect` | True | Per-request | +| `max_connections` | 50 | Global | +| `idle_timeout` | 60000ms | Global | +| `default_connect_timeout` | 15000ms | Global | +| `domain_lookup_timeout` | 5000ms | Global | +| `tls_handshake_timeout` | 10000ms | Global | +| `retry` | 3 | Global | +| `retry_timeout` | 1000ms | Global | +| `keepalive` | 30000ms | Global | +| `keepalive_tolerance` | 3 | Global | +| `max_concurrent_streams` | 100 | Global | +| `initial_connection_window_size` | 65535 | Global | +| `initial_stream_window_size` | 65535 | Global | +| `closing_timeout` | 15000ms | Global | --- @@ -646,7 +656,7 @@ This module follows the same quality standards as [Dream](https://github.com/Tru - **Type safety** - `Result` types force error handling at compile time - **OTP-first design** - Process-based streaming designed for supervision trees - **Comprehensive testing** - Unit tests (no network) + integration tests (real HTTP) -- **Battle-tested foundation** - Built on Erlang's production-proven `httpc` +- **HTTP/2 ready** - Built on gun for native HTTP/2 multiplexing --- diff --git a/modules/http_client/gleam.toml b/modules/http_client/gleam.toml index 681684a..944942e 100644 --- a/modules/http_client/gleam.toml +++ b/modules/http_client/gleam.toml @@ -17,6 +17,7 @@ gleam_yielder = ">= 1.1.0 and < 2.0.0" simplifile = ">= 2.3.0 and < 3.0.0" gleam_otp = ">= 1.2.0 and < 2.0.0" gleam_crypto = ">= 1.3.0 and < 2.0.0" +gun = ">= 2.2.0 and < 3.0.0" [erlang] application_start_module = "dream_http_client_app" diff --git a/modules/http_client/manifest.toml b/modules/http_client/manifest.toml index 15f2949..7ebda6a 100644 --- a/modules/http_client/manifest.toml +++ b/modules/http_client/manifest.toml @@ -2,8 +2,9 @@ # You typically do not need to edit this file packages = [ - { name = "dream", version = "2.3.1", build_tools = ["gleam"], requirements = ["gleam_erlang", "gleam_http", "gleam_json", "gleam_otp", "gleam_stdlib", "gleam_time", "gleam_yielder", "marceau", "mist", "simplifile"], source = "local", path = "../.." }, - { name = "dream_mock_server", version = "1.0.0", build_tools = ["gleam"], requirements = ["dream", "gleam_erlang", "gleam_http", "gleam_json", "gleam_otp", "gleam_stdlib", "gleam_yielder"], source = "local", path = "../mock_server" }, + { name = "cowlib", version = "2.16.0", build_tools = ["make", "rebar3"], requirements = [], otp_app = "cowlib", source = "hex", outer_checksum = "7F478D80D66B747344F0EA7708C187645CFCC08B11AA424632F78E25BF05DB51" }, + { name = "dream", version = "2.4.1", build_tools = ["gleam"], requirements = ["gleam_erlang", "gleam_http", "gleam_json", "gleam_otp", "gleam_stdlib", "gleam_time", "gleam_yielder", "marceau", "mist", "simplifile"], source = "local", path = "../.." }, + { name = "dream_mock_server", version = "1.1.1", build_tools = ["gleam"], requirements = ["dream", "gleam_erlang", "gleam_http", "gleam_json", "gleam_otp", "gleam_stdlib", "gleam_yielder"], source = "local", path = "../mock_server" }, { name = "exception", version = "2.1.0", build_tools = ["gleam"], requirements = ["gleam_stdlib"], otp_app = "exception", source = "hex", outer_checksum = "329D269D5C2A314F7364BD2711372B6F2C58FA6F39981572E5CA68624D291F8C" }, { name = "filepath", version = "1.1.2", build_tools = ["gleam"], requirements = ["gleam_stdlib"], otp_app = "filepath", source = "hex", outer_checksum = "B06A9AF0BF10E51401D64B98E4B627F1D2E48C154967DA7AF4D0914780A6D40A" }, { name = "gleam_crypto", version = "1.5.1", build_tools = ["gleam"], requirements = ["gleam_stdlib"], otp_app = "gleam_crypto", source = "hex", outer_checksum = "50774BAFFF1144E7872814C566C5D653D83A3EBF23ACC3156B757A1B6819086E" }, @@ -11,20 +12,21 @@ packages = [ { name = "gleam_http", version = "4.3.0", build_tools = ["gleam"], requirements = ["gleam_stdlib"], otp_app = "gleam_http", source = "hex", outer_checksum = "82EA6A717C842456188C190AFB372665EA56CE13D8559BF3B1DD9E40F619EE0C" }, { name = "gleam_json", version = "3.1.0", build_tools = ["gleam"], requirements = ["gleam_stdlib"], otp_app = "gleam_json", source = "hex", outer_checksum = "44FDAA8847BE8FC48CA7A1C089706BD54BADCC4C45B237A992EDDF9F2CDB2836" }, { name = "gleam_otp", version = "1.2.0", build_tools = ["gleam"], requirements = ["gleam_erlang", "gleam_stdlib"], otp_app = "gleam_otp", source = "hex", outer_checksum = "BA6A294E295E428EC1562DC1C11EA7530DCB981E8359134BEABC8493B7B2258E" }, - { name = "gleam_stdlib", version = "0.67.1", build_tools = ["gleam"], requirements = [], otp_app = "gleam_stdlib", source = "hex", outer_checksum = "6CE3E4189A8B8EC2F73AB61A2FBDE49F159D6C9C61C49E3B3082E439F260D3D0" }, - { name = "gleam_time", version = "1.6.0", build_tools = ["gleam"], requirements = ["gleam_stdlib"], otp_app = "gleam_time", source = "hex", outer_checksum = "0DF3834D20193F0A38D0EB21F0A78D48F2EC276C285969131B86DF8D4EF9E762" }, + { name = "gleam_stdlib", version = "0.70.0", build_tools = ["gleam"], requirements = [], otp_app = "gleam_stdlib", source = "hex", outer_checksum = "86949BF5D1F0E4AC0AB5B06F235D8A5CC11A2DFC33BF22F752156ED61CA7D0FF" }, + { name = "gleam_time", version = "1.7.0", build_tools = ["gleam"], requirements = ["gleam_stdlib"], otp_app = "gleam_time", source = "hex", outer_checksum = "56DB0EF9433826D3B99DB0B4AF7A2BFED13D09755EC64B1DAAB46F804A9AD47D" }, { name = "gleam_yielder", version = "1.1.0", build_tools = ["gleam"], requirements = ["gleam_stdlib"], otp_app = "gleam_yielder", source = "hex", outer_checksum = "8E4E4ECFA7982859F430C57F549200C7749823C106759F4A19A78AEA6687717A" }, { name = "gleeunit", version = "1.9.0", build_tools = ["gleam"], requirements = ["gleam_stdlib"], otp_app = "gleeunit", source = "hex", outer_checksum = "DA9553CE58B67924B3C631F96FE3370C49EB6D6DC6B384EC4862CC4AAA718F3C" }, - { name = "glisten", version = "8.0.1", build_tools = ["gleam"], requirements = ["gleam_erlang", "gleam_otp", "gleam_stdlib", "logging", "telemetry"], otp_app = "glisten", source = "hex", outer_checksum = "534BB27C71FB9E506345A767C0D76B17A9E9199934340C975DC003C710E3692D" }, + { name = "glisten", version = "8.0.3", build_tools = ["gleam"], requirements = ["gleam_erlang", "gleam_otp", "gleam_stdlib", "logging", "telemetry"], otp_app = "glisten", source = "hex", outer_checksum = "86B838196592D9EBDE7A1D2369AE3A51E568F7DD2D168706C463C42D17B95312" }, { name = "gramps", version = "6.0.0", build_tools = ["gleam"], requirements = ["gleam_crypto", "gleam_erlang", "gleam_http", "gleam_stdlib"], otp_app = "gramps", source = "hex", outer_checksum = "8B7195978FBFD30B43DF791A8A272041B81E45D245314D7A41FC57237AA882A0" }, + { name = "gun", version = "2.2.0", build_tools = ["make", "rebar3"], requirements = ["cowlib"], otp_app = "gun", source = "hex", outer_checksum = "76022700C64287FEB4DF93A1795CFF6741B83FB37415C40C34C38D2A4645261A" }, { name = "hpack_erl", version = "0.3.0", build_tools = ["rebar3"], requirements = [], otp_app = "hpack", source = "hex", outer_checksum = "D6137D7079169D8C485C6962DFE261AF5B9EF60FBC557344511C1E65E3D95FB0" }, { name = "logging", version = "1.3.0", build_tools = ["gleam"], requirements = ["gleam_stdlib"], otp_app = "logging", source = "hex", outer_checksum = "1098FBF10B54B44C2C7FDF0B01C1253CAFACDACABEFB4B0D027803246753E06D" }, { name = "marceau", version = "1.3.0", build_tools = ["gleam"], requirements = [], otp_app = "marceau", source = "hex", outer_checksum = "2D1C27504BEF45005F5DFB18591F8610FB4BFA91744878210BDC464412EC44E9" }, { name = "meck", version = "1.0.0", build_tools = ["rebar3"], requirements = [], otp_app = "meck", source = "hex", outer_checksum = "680A9BCFE52764350BEB9FB0335FB75FEE8E7329821416CEE0A19FEC35433882" }, - { name = "mist", version = "5.0.3", build_tools = ["gleam"], requirements = ["exception", "gleam_erlang", "gleam_http", "gleam_otp", "gleam_stdlib", "gleam_yielder", "glisten", "gramps", "hpack_erl", "logging"], otp_app = "mist", source = "hex", outer_checksum = "7C4BE717A81305323C47C8A591E6B9BA4AC7F56354BF70B4D3DF08CC01192668" }, + { name = "mist", version = "5.0.4", build_tools = ["gleam"], requirements = ["exception", "gleam_erlang", "gleam_http", "gleam_otp", "gleam_stdlib", "gleam_yielder", "glisten", "gramps", "hpack_erl", "logging"], otp_app = "mist", source = "hex", outer_checksum = "7CED4B2D81FD547ADB093D97B9928B9419A7F58B8562A30A6CC17A252B31AD05" }, { name = "mockth", version = "2.0.0", build_tools = ["gleam"], requirements = ["gleam_erlang", "gleam_stdlib", "gleeunit", "meck"], source = "git", repo = "https://github.com/bondiano/mockth.git", commit = "bacecbc7cd7ffac806d84154b07360c627d235ec" }, - { name = "simplifile", version = "2.3.2", build_tools = ["gleam"], requirements = ["filepath", "gleam_stdlib"], otp_app = "simplifile", source = "hex", outer_checksum = "E049B4DACD4D206D87843BCF4C775A50AE0F50A52031A2FFB40C9ED07D6EC70A" }, - { name = "telemetry", version = "1.3.0", build_tools = ["rebar3"], requirements = [], otp_app = "telemetry", source = "hex", outer_checksum = "7015FC8919DBE63764F4B4B87A95B7C0996BD539E0D499BE6EC9D7F3875B79E6" }, + { name = "simplifile", version = "2.4.0", build_tools = ["gleam"], requirements = ["filepath", "gleam_stdlib"], otp_app = "simplifile", source = "hex", outer_checksum = "7C18AFA4FED0B4CE1FA5B0B4BAC1FA1744427054EA993565F6F3F82E5453170D" }, + { name = "telemetry", version = "1.4.1", build_tools = ["rebar3"], requirements = [], otp_app = "telemetry", source = "hex", outer_checksum = "2172E05A27531D3D31DD9782841065C50DD5C3C7699D95266B2EDD54C2DAFA1C" }, ] [requirements] @@ -38,5 +40,6 @@ gleam_otp = { version = ">= 1.2.0 and < 2.0.0" } gleam_stdlib = { version = ">= 0.60.0 and < 1.0.0" } gleam_yielder = { version = ">= 1.1.0 and < 2.0.0" } gleeunit = { version = ">= 1.0.0 and < 2.0.0" } +gun = { version = ">= 2.2.0 and < 3.0.0" } mockth = { git = "https://github.com/bondiano/mockth.git", ref = "master" } simplifile = { version = ">= 2.3.0 and < 3.0.0" } diff --git a/modules/http_client/releases/release-5.2.0.md b/modules/http_client/releases/release-5.2.0.md index 0e14737..80f8bb2 100644 --- a/modules/http_client/releases/release-5.2.0.md +++ b/modules/http_client/releases/release-5.2.0.md @@ -2,15 +2,35 @@ **Release Date:** March 25, 2026 -This release exposes the 6 previously hardcoded `httpc` configuration values -as user-configurable options. Per-request settings (`connect_timeout`, -`auto_redirect`) are added to the `ClientRequest` builder. Profile-level -transport settings (`max_sessions`, `max_pipeline_length`, `keep_alive_timeout`, -`max_keep_alive_length`) are managed through a new `TransportConfig` type. -All defaults match the previous hardcoded values — existing code behaves -identically without changes. +This release replaces the underlying HTTP backend from Erlang's `httpc` to +[gun](https://github.com/ninenines/gun), enabling native HTTP/2 multiplexing +for high-concurrency workloads. Per-request settings (`connect_timeout`, +`auto_redirect`) are added to the `ClientRequest` builder. Connection pool +settings are managed through a redesigned `TransportConfig` type with 13 +gun-native fields. All defaults are production-reasonable — existing code +behaves identically without changes. -No breaking changes. Minor version bump (5.1.3 → 5.2.0). +No breaking changes to the public API. Minor version bump (5.1.3 → 5.2.0). + +--- + +## Backend change: httpc → gun + +The HTTP client backend has been replaced from Erlang's `httpc` to +[gun](https://github.com/ninenines/gun). This brings: + +- **HTTP/2 support** — native multiplexing over a single TCP connection + when connecting to HTTP/2-capable servers (e.g., OpenAI, Anthropic) +- **Connection pooling** — managed by a new `dream_http_conn_manager` + gen_server with per-host multi-connection pools, round-robin selection, + and automatic dead-connection cleanup +- **Stale connection retry** — requests that hit a server-closed connection + are automatically retried once on a fresh connection +- **Protocol negotiation** — TLS connections negotiate HTTP/2 via ALPN, + falling back to HTTP/1.1; plain TCP uses HTTP/1.1 + +All public API contracts are preserved. `send()`, `stream_yielder()`, and +`start_stream()` behave identically from the caller's perspective. --- @@ -27,14 +47,10 @@ client.new() |> client.send() ``` -**Default:** 15000ms (matches previous hardcoded value). +**Default:** 15000ms. **Use case:** Fail fast against unreachable hosts without shortening the -overall request timeout. For example, set `connect_timeout(2000)` with -`timeout(60_000)` to detect connection failures quickly while still allowing -slow responses. - -Maps directly to Erlang httpc's `{connect_timeout, Ms}` HTTP option. +overall request timeout. --- @@ -44,7 +60,6 @@ Maps directly to Erlang httpc's `{connect_timeout, Ms}` HTTP option. automatically. ```gleam -// Disable auto-redirect to inspect the 3xx response client.new() |> client.host("api.example.com") |> client.path("/old-endpoint") @@ -52,29 +67,25 @@ client.new() |> client.send() ``` -**Default:** `True` (matches previous hardcoded value). +**Default:** `True`. When disabled, the 3xx response is returned as `Ok(HttpResponse(...))` with -the redirect status code and `Location` header visible. Since `response_result` -only returns `Error(ResponseError(...))` for status >= 400, 3xx responses -come through as `Ok` — the correct behavior for manual redirect handling. - -Maps directly to Erlang httpc's `{autoredirect, Bool}` HTTP option. +the redirect status code and `Location` header visible. gun does not handle +redirects natively; the shim implements manual redirect following (up to 5 +hops). --- ## Feature: Global transport configuration -`TransportConfig` is a new opaque type for tuning the httpc connection pool. -These settings are global (applied to the httpc default profile) and affect -all subsequent HTTP requests. +`TransportConfig` is redesigned with 13 gun-native fields for full +connection pool control. ```gleam client.transport_config() -|> client.max_sessions(200) -|> client.max_pipeline_length(0) -|> client.keep_alive_timeout(120_000) -|> client.max_keep_alive_length(50) +|> client.max_connections(200) +|> client.idle_timeout(120_000) +|> client.max_concurrent_streams(500) |> client.configure_transport() ``` @@ -82,106 +93,96 @@ client.transport_config() | Builder | Default | What it controls | |---------|---------|-----------------| -| `max_sessions(count)` | 100 | Concurrent TCP connections per host | -| `max_pipeline_length(length)` | 0 | HTTP pipelining depth (0 = disabled) | -| `keep_alive_timeout(ms)` | 60000 | Idle connection lifetime before close | -| `max_keep_alive_length(count)` | 100 | Max requests per keep-alive connection | +| `max_connections(count)` | 50 | TCP connections per host | +| `idle_timeout(ms)` | 60000 | Idle connection lifetime before close | +| `default_connect_timeout(ms)` | 15000 | TCP connect timeout | +| `domain_lookup_timeout(ms)` | 5000 | DNS resolution timeout | +| `tls_handshake_timeout(ms)` | 10000 | TLS negotiation timeout | +| `retry(count)` | 3 | Connection retry attempts | +| `retry_timeout(ms)` | 1000 | Delay between retries | +| `keepalive(ms)` | 30000 | HTTP/2 PING interval | +| `keepalive_tolerance(count)` | 3 | Missed PINGs before close | +| `max_concurrent_streams(count)` | 100 | HTTP/2 streams per connection | +| `initial_connection_window_size(bytes)` | 65535 | HTTP/2 connection flow control | +| `initial_stream_window_size(bytes)` | 65535 | HTTP/2 stream flow control | +| `closing_timeout(ms)` | 15000 | Graceful shutdown timeout | ### How it works -`configure_transport()` does two things: +`configure_transport()` writes the config to a named ETS table +(`dream_http_client_transport_config`). The shim reads from this table +before every connection, falling back to defaults if no config is stored. -1. Writes the config to a named ETS table (`dream_http_client_transport_config`) - so it persists across requests -2. Immediately calls `httpc:set_options/2` on the `default` profile +The connection pool is managed by `dream_http_conn_manager`, a gen_server +supervised by `dream_http_client_sup`. It uses an ETS `bag` table +(`dream_http_client_connections`) for per-host multi-connection pooling with: -The ETS table is created during OTP application startup (in -`dream_http_client_app:start/2`) alongside the existing ref mapping and -recorder tables. `configure_httpc/0`, which runs before every HTTP request, -reads from this table — if populated, it uses the stored values; otherwise, -it falls back to the hardcoded defaults. - -This means: -- Call `configure_transport()` once at startup and all requests use those settings -- Call it again at runtime to update settings without restart -- If never called, behavior is identical to previous versions - -### Why ETS, not an actor - -Transport config is read on every request by `configure_httpc/0` but written -rarely (typically once at startup). ETS provides concurrent reads without -bottlenecking through a single actor process. This follows the same pattern -used by the existing `dream_http_client_ref_mapping` table. +- Round-robin connection selection +- Proactive dead-connection cleanup +- Idle connection reaping +- Crash recovery (re-monitors connections on gen_server restart) --- -## Propagation chain +## Architecture -The new per-request parameters flow through the full call chain: +### Connection lifecycle ``` -ClientRequest builder (Gleam) - → resolve_connect_timeout / resolve_auto_redirect (Gleam, applies defaults) - → send_sync FFI / start_httpc_stream / start_stream_messages (Gleam → Erlang) - → request_sync/7 / request_stream/8 / request_stream_messages/8 (Erlang shim) - → httpc:request/4 HttpOpts [{connect_timeout, Ms}, {autoredirect, Bool}] +request → get_or_open_connection → ensure_connection (gen_server) + → if below max_connections: gun:open + gun:await_up → new connection + → if at max: round-robin from pool + → if stale connection error: close + retry once with new connection ``` -All three request paths (`send()`, `stream_yielder()`, `start_stream()`) -propagate both parameters. The intermediate types `YielderState` and -`RecordingYielderState` were updated to carry `connect_timeout_ms` and -`auto_redirect` alongside the existing `timeout_ms`. +### Message routing + +- **Sync requests:** `gun:await` / `gun:await_body` in the calling process +- **Pull streaming:** Owner process receives `gun_data` messages directly +- **Message streaming:** Translator process receives gun messages, forwards + as `{http, ...}` tuples to the stream process + +### Auto-redirect + +gun does not handle HTTP redirects natively. The shim implements manual +redirect following for all three request paths, supporting up to 5 hops +with proper method/body handling for 301/302/303/307/308. --- ## Test coverage -14 new tests (206 total across the module): - -| Category | Count | Coverage | -|----------|-------|----------| -| `connect_timeout` builder/getter | 3 | set value, default None, accepts zero | -| `auto_redirect` builder/getter | 3 | set False, default None, set True | -| `TransportConfig` defaults | 1 | all 4 fields match expected defaults | -| `TransportConfig` builders | 4 | one per field with value verification | -| Builder chaining | 1 | all 4 builders chained, all 4 getters verified | -| `configure_transport` | 1 | applies without error (returns Nil) | -| Edge case: zero values | 1 | `max_sessions(0)` accepted | +218 tests (218 total across the module): -3 new test snippets for documentation examples: -- `test/snippets/connect_timeout_config.gleam` -- `test/snippets/redirect_config.gleam` -- `test/snippets/transport_config_example.gleam` +All existing tests pass without modification. New tests cover: +- All 13 `TransportConfig` builder/getter round-trips +- Default values, edge cases (zero/one values), builder chaining +- `configure_transport` application +- Concurrent streaming scenarios with connection pool management --- ## Files changed -- `modules/http_client/src/dream_http_client/client.gleam` — Added - `connect_timeout` and `auto_redirect` fields to `ClientRequest`, updated - `new()`, added builder/getter/resolve functions, added `TransportConfig` - type with factory/builders/getters, added `configure_transport` and its - FFI declaration +- `modules/http_client/gleam.toml` — Added `gun >= 2.2.0` dependency +- `modules/http_client/src/dream_http_client/dream_http_shim.erl` — New file: + gun-based FFI shim replacing `dream_httpc_shim.erl` +- `modules/http_client/src/dream_http_client/dream_http_conn_manager.erl` — + New file: connection pool gen_server +- `modules/http_client/src/dream_http_client/dream_httpc_shim.erl` — Deleted +- `modules/http_client/src/dream_http_client/client.gleam` — Redesigned + `TransportConfig` with 13 gun-native fields, updated FFI references - `modules/http_client/src/dream_http_client/internal.gleam` — Updated - `request_stream` FFI signature to `/8`, `start_stream_messages` to `/8`, - `start_httpc_stream` to accept and pass `connect_timeout_ms` and - `autoredirect` -- `modules/http_client/src/dream_http_client/dream_httpc_shim.erl` — Updated - `request_sync/5` → `/7`, `request_stream/6` → `/8`, - `request_stream_messages/6` → `/8`, `stream_owner_loop/4` → `/6`; - added `configure_transport/4`; updated `configure_httpc/0` to read from ETS + external function references from `dream_httpc_shim` to `dream_http_shim` - `modules/http_client/src/dream_http_client/dream_http_client_app.erl` — - Added `dream_http_client_transport_config` ETS table creation -- `modules/http_client/test/client_test.gleam` — 6 new tests for - `connect_timeout` and `auto_redirect` builders/getters -- `modules/http_client/test/transport_config_test.gleam` — New file, 8 tests - for `TransportConfig` builders/getters/apply -- `modules/http_client/test/snippets/connect_timeout_config.gleam` — New snippet -- `modules/http_client/test/snippets/redirect_config.gleam` — New snippet -- `modules/http_client/test/snippets/transport_config_example.gleam` — New snippet + Added `dream_http_client_connections` ETS table creation +- `modules/http_client/src/dream_http_client/dream_http_client_sup.erl` — + Added `dream_http_conn_manager` as supervised child +- `modules/http_client/test/transport_config_test.gleam` — Rewritten for + 13 gun-native TransportConfig fields +- `modules/http_client/test/snippets/transport_config_example.gleam` — Updated - `modules/http_client/CHANGELOG.md` — 5.2.0 entry -- `modules/http_client/README.md` — Added Configuration section -- `modules/http_client/gleam.toml` — Version bump 5.1.3 → 5.2.0 +- `modules/http_client/README.md` — Updated for gun backend ## Upgrading @@ -198,9 +199,9 @@ Then run: gleam deps download ``` -No breaking changes. All existing code works without modification. The 6 -previously hardcoded httpc values now use the same defaults but can be -overridden via the new builder functions. +No breaking changes to the public API. The HTTP backend has been swapped +from `httpc` to `gun` but all public functions, types, and behaviors are +preserved. ## Documentation diff --git a/modules/http_client/src/dream_http_client/client.gleam b/modules/http_client/src/dream_http_client/client.gleam index 2350d3a..e183ba2 100644 --- a/modules/http_client/src/dream_http_client/client.gleam +++ b/modules/http_client/src/dream_http_client/client.gleam @@ -1,7 +1,8 @@ //// Type-safe HTTP client with recording + streaming support //// -//// Gleam doesn't ship with an HTTPS client, so this module wraps Erlang's -//// battle‑hardened `httpc` and adds a friendly builder API, streaming helpers, +//// Gleam doesn't ship with an HTTPS client, so this module wraps +//// [gun](https://github.com/ninenines/gun) (a high-performance Erlang HTTP/1.1 +//// and HTTP/2 client) and adds a friendly builder API, streaming helpers, //// and optional record/playback via `dream_http_client/recorder`. //// //// ## Quick Example — blocking request @@ -627,9 +628,10 @@ pub fn auto_redirect( /// Configuration for the HTTP transport layer /// -/// Controls connection pool behavior for the underlying httpc client. -/// These settings are global (applied to the httpc default profile) and -/// affect all subsequent HTTP requests. +/// Controls connection pool behavior and gun client options. +/// These settings are global and affect all subsequent HTTP requests. +/// Gun negotiates HTTP/2 automatically when available (via ALPN), +/// falling back to HTTP/1.1. /// /// Create with `transport_config()`, configure with builder functions, /// and apply with `configure_transport()`. @@ -640,161 +642,321 @@ pub fn auto_redirect( /// import dream_http_client/client /// /// client.transport_config() -/// |> client.max_sessions(200) -/// |> client.keep_alive_timeout(120_000) +/// |> client.max_connections(200) +/// |> client.idle_timeout(120_000) /// |> client.configure_transport() /// ``` pub opaque type TransportConfig { TransportConfig( - max_sessions: Int, - max_pipeline_length: Int, - keep_alive_timeout: Int, - max_keep_alive_length: Int, + max_connections: Int, + idle_timeout: Int, + default_connect_timeout: Int, + domain_lookup_timeout: Int, + tls_handshake_timeout: Int, + retry: Int, + retry_timeout: Int, + keepalive: Int, + keepalive_tolerance: Int, + max_concurrent_streams: Int, + initial_connection_window_size: Int, + initial_stream_window_size: Int, + closing_timeout: Int, ) } /// Create a transport configuration with default values /// -/// Returns a `TransportConfig` with Dream's default settings: -/// - max_sessions: 100 (concurrent TCP connections per host) -/// - max_pipeline_length: 0 (pipelining disabled) -/// - keep_alive_timeout: 60000ms (idle connection lifetime) -/// - max_keep_alive_length: 100 (requests per keep-alive connection) +/// Returns a `TransportConfig` with sensible defaults: +/// - max_connections: 50 (TCP connections per host) +/// - idle_timeout: 60_000ms (close idle connections after 60s) +/// - default_connect_timeout: 15_000ms (TCP connection timeout) +/// - domain_lookup_timeout: 5_000ms (DNS resolution timeout) +/// - tls_handshake_timeout: 10_000ms (TLS negotiation timeout) +/// - retry: 3 (reconnection attempts) +/// - retry_timeout: 1_000ms (between retries) +/// - keepalive: 30_000ms (HTTP/2 ping interval) +/// - keepalive_tolerance: 3 (unack'd pings before disconnect) +/// - max_concurrent_streams: 100 (HTTP/2 streams per connection) +/// - initial_connection_window_size: 65_535 (HTTP/2 connection flow control) +/// - initial_stream_window_size: 65_535 (HTTP/2 per-stream flow control) +/// - closing_timeout: 15_000ms (graceful shutdown wait) pub fn transport_config() -> TransportConfig { TransportConfig( - max_sessions: 100, - max_pipeline_length: 0, - keep_alive_timeout: 60_000, - max_keep_alive_length: 100, + max_connections: 50, + idle_timeout: 60_000, + default_connect_timeout: 15_000, + domain_lookup_timeout: 5000, + tls_handshake_timeout: 10_000, + retry: 3, + retry_timeout: 1000, + keepalive: 30_000, + keepalive_tolerance: 3, + max_concurrent_streams: 100, + initial_connection_window_size: 65_535, + initial_stream_window_size: 65_535, + closing_timeout: 15_000, ) } -/// Set maximum concurrent TCP connections per host +/// Set maximum TCP connections per host /// /// Controls how many simultaneous TCP connections can be open to a single -/// host. Increase for high-concurrency workloads; decrease to limit -/// resource usage. +/// host. With HTTP/2, a single connection supports multiplexed streams, +/// so fewer connections are needed than with HTTP/1.1. /// /// ## Parameters /// /// - `config`: The transport config to modify -/// - `count`: Maximum connections per host (default: 100) +/// - `count`: Maximum connections per host (default: 50) /// /// ## Example /// /// ```gleam -/// import dream_http_client/client -/// /// client.transport_config() -/// |> client.max_sessions(200) +/// |> client.max_connections(200) /// |> client.configure_transport() /// ``` -pub fn max_sessions(config: TransportConfig, count: Int) -> TransportConfig { - TransportConfig(..config, max_sessions: count) +pub fn max_connections(config: TransportConfig, count: Int) -> TransportConfig { + TransportConfig(..config, max_connections: count) } -/// Set HTTP pipelining depth +/// Set idle connection timeout in milliseconds /// -/// Controls how many requests can be pipelined on a single TCP connection. -/// Set to 0 to disable pipelining (default and recommended for streaming). -/// Enabling pipelining can improve throughput for many small sequential -/// requests to the same host. +/// Controls how long an idle connection is held open before being closed. +/// Longer timeouts improve connection reuse but consume resources. /// /// ## Parameters /// /// - `config`: The transport config to modify -/// - `length`: Pipeline depth, 0 = disabled (default: 0) +/// - `ms`: Idle timeout in milliseconds (default: 60_000) /// /// ## Example /// /// ```gleam -/// import dream_http_client/client -/// /// client.transport_config() -/// |> client.max_pipeline_length(5) +/// |> client.idle_timeout(120_000) /// |> client.configure_transport() /// ``` -pub fn max_pipeline_length( +pub fn idle_timeout(config: TransportConfig, ms: Int) -> TransportConfig { + TransportConfig(..config, idle_timeout: ms) +} + +/// Set default TCP connection timeout in milliseconds +/// +/// The default timeout for establishing a TCP connection when not +/// overridden per-request via `connect_timeout()`. +/// +/// ## Parameters +/// +/// - `config`: The transport config to modify +/// - `ms`: Connection timeout in milliseconds (default: 15_000) +pub fn default_connect_timeout( config: TransportConfig, - length: Int, + ms: Int, ) -> TransportConfig { - TransportConfig(..config, max_pipeline_length: length) + TransportConfig(..config, default_connect_timeout: ms) } -/// Set idle connection timeout in milliseconds +/// Set DNS resolution timeout in milliseconds /// -/// Controls how long an idle keep-alive connection is held open before -/// being closed. Longer timeouts improve connection reuse but consume -/// resources. +/// ## Parameters +/// +/// - `config`: The transport config to modify +/// - `ms`: DNS lookup timeout in milliseconds (default: 5_000) +pub fn domain_lookup_timeout( + config: TransportConfig, + ms: Int, +) -> TransportConfig { + TransportConfig(..config, domain_lookup_timeout: ms) +} + +/// Set TLS handshake timeout in milliseconds /// /// ## Parameters /// /// - `config`: The transport config to modify -/// - `ms`: Idle timeout in milliseconds (default: 60000) +/// - `ms`: TLS negotiation timeout in milliseconds (default: 10_000) +pub fn tls_handshake_timeout( + config: TransportConfig, + ms: Int, +) -> TransportConfig { + TransportConfig(..config, tls_handshake_timeout: ms) +} + +/// Set number of reconnection attempts /// -/// ## Example +/// How many times gun will attempt to reconnect after a connection is lost. /// -/// ```gleam -/// import dream_http_client/client +/// ## Parameters /// -/// client.transport_config() -/// |> client.keep_alive_timeout(120_000) -/// |> client.configure_transport() -/// ``` -pub fn keep_alive_timeout(config: TransportConfig, ms: Int) -> TransportConfig { - TransportConfig(..config, keep_alive_timeout: ms) +/// - `config`: The transport config to modify +/// - `count`: Number of retry attempts (default: 3) +pub fn retry(config: TransportConfig, count: Int) -> TransportConfig { + TransportConfig(..config, retry: count) } -/// Set maximum requests per keep-alive connection +/// Set delay between reconnection attempts in milliseconds /// -/// Controls how many requests can be sent on a single keep-alive -/// connection before it is closed and a new one opened. This limits -/// the lifetime of individual TCP connections. +/// ## Parameters +/// +/// - `config`: The transport config to modify +/// - `ms`: Delay between retries in milliseconds (default: 1_000) +pub fn retry_timeout(config: TransportConfig, ms: Int) -> TransportConfig { + TransportConfig(..config, retry_timeout: ms) +} + +/// Set HTTP/2 keepalive ping interval in milliseconds +/// +/// How often to send HTTP/2 PING frames to keep the connection alive +/// and detect dead connections. /// /// ## Parameters /// /// - `config`: The transport config to modify -/// - `count`: Maximum requests per connection (default: 100) +/// - `ms`: Ping interval in milliseconds (default: 30_000) +pub fn keepalive(config: TransportConfig, ms: Int) -> TransportConfig { + TransportConfig(..config, keepalive: ms) +} + +/// Set HTTP/2 keepalive tolerance /// -/// ## Example +/// How many unacknowledged PING frames are allowed before the connection +/// is considered dead and closed. /// -/// ```gleam -/// import dream_http_client/client +/// ## Parameters /// -/// client.transport_config() -/// |> client.max_keep_alive_length(50) -/// |> client.configure_transport() -/// ``` -pub fn max_keep_alive_length( +/// - `config`: The transport config to modify +/// - `count`: Max unacknowledged pings (default: 3) +pub fn keepalive_tolerance( config: TransportConfig, count: Int, ) -> TransportConfig { - TransportConfig(..config, max_keep_alive_length: count) + TransportConfig(..config, keepalive_tolerance: count) } -/// Get the configured maximum concurrent TCP connections per host -pub fn get_max_sessions(config: TransportConfig) -> Int { - config.max_sessions +/// Set maximum concurrent HTTP/2 streams per connection +/// +/// Controls the maximum number of concurrent streams allowed on a single +/// HTTP/2 connection. Increase for highly concurrent workloads. +/// +/// ## Parameters +/// +/// - `config`: The transport config to modify +/// - `count`: Max concurrent streams (default: 100) +pub fn max_concurrent_streams( + config: TransportConfig, + count: Int, +) -> TransportConfig { + TransportConfig(..config, max_concurrent_streams: count) +} + +/// Set HTTP/2 connection-level flow control window size +/// +/// ## Parameters +/// +/// - `config`: The transport config to modify +/// - `size`: Window size in bytes (default: 65_535) +pub fn initial_connection_window_size( + config: TransportConfig, + size: Int, +) -> TransportConfig { + TransportConfig(..config, initial_connection_window_size: size) +} + +/// Set HTTP/2 per-stream flow control window size +/// +/// ## Parameters +/// +/// - `config`: The transport config to modify +/// - `size`: Window size in bytes (default: 65_535) +pub fn initial_stream_window_size( + config: TransportConfig, + size: Int, +) -> TransportConfig { + TransportConfig(..config, initial_stream_window_size: size) +} + +/// Set graceful shutdown timeout in milliseconds +/// +/// How long to wait for in-flight requests to complete when closing +/// a connection. +/// +/// ## Parameters +/// +/// - `config`: The transport config to modify +/// - `ms`: Shutdown wait in milliseconds (default: 15_000) +pub fn closing_timeout(config: TransportConfig, ms: Int) -> TransportConfig { + TransportConfig(..config, closing_timeout: ms) } -/// Get the configured HTTP pipelining depth -pub fn get_max_pipeline_length(config: TransportConfig) -> Int { - config.max_pipeline_length +/// Get the configured maximum TCP connections per host +pub fn get_max_connections(config: TransportConfig) -> Int { + config.max_connections } /// Get the configured idle connection timeout in milliseconds -pub fn get_keep_alive_timeout(config: TransportConfig) -> Int { - config.keep_alive_timeout +pub fn get_idle_timeout(config: TransportConfig) -> Int { + config.idle_timeout +} + +/// Get the configured default TCP connection timeout in milliseconds +pub fn get_default_connect_timeout(config: TransportConfig) -> Int { + config.default_connect_timeout +} + +/// Get the configured DNS resolution timeout in milliseconds +pub fn get_domain_lookup_timeout(config: TransportConfig) -> Int { + config.domain_lookup_timeout +} + +/// Get the configured TLS handshake timeout in milliseconds +pub fn get_tls_handshake_timeout(config: TransportConfig) -> Int { + config.tls_handshake_timeout +} + +/// Get the configured number of reconnection attempts +pub fn get_retry(config: TransportConfig) -> Int { + config.retry +} + +/// Get the configured delay between reconnection attempts in milliseconds +pub fn get_retry_timeout(config: TransportConfig) -> Int { + config.retry_timeout } -/// Get the configured maximum requests per keep-alive connection -pub fn get_max_keep_alive_length(config: TransportConfig) -> Int { - config.max_keep_alive_length +/// Get the configured HTTP/2 keepalive ping interval in milliseconds +pub fn get_keepalive(config: TransportConfig) -> Int { + config.keepalive +} + +/// Get the configured HTTP/2 keepalive tolerance +pub fn get_keepalive_tolerance(config: TransportConfig) -> Int { + config.keepalive_tolerance +} + +/// Get the configured maximum concurrent HTTP/2 streams per connection +pub fn get_max_concurrent_streams(config: TransportConfig) -> Int { + config.max_concurrent_streams +} + +/// Get the configured HTTP/2 connection-level flow control window size +pub fn get_initial_connection_window_size(config: TransportConfig) -> Int { + config.initial_connection_window_size +} + +/// Get the configured HTTP/2 per-stream flow control window size +pub fn get_initial_stream_window_size(config: TransportConfig) -> Int { + config.initial_stream_window_size +} + +/// Get the configured graceful shutdown timeout in milliseconds +pub fn get_closing_timeout(config: TransportConfig) -> Int { + config.closing_timeout } /// Apply transport configuration to the HTTP client /// -/// Sets the httpc profile options for all subsequent HTTP requests. +/// Stores transport settings for use by all subsequent HTTP requests. /// Call this once during application startup. Can be called again to /// update settings at runtime. /// @@ -802,25 +964,15 @@ pub fn get_max_keep_alive_length(config: TransportConfig) -> Int { /// /// ```gleam /// client.transport_config() -/// |> client.max_sessions(200) +/// |> client.max_connections(200) /// |> client.configure_transport() /// ``` pub fn configure_transport(config: TransportConfig) -> Nil { - configure_transport_ffi( - config.max_sessions, - config.max_pipeline_length, - config.keep_alive_timeout, - config.max_keep_alive_length, - ) + configure_transport_ffi(config) } -@external(erlang, "dream_httpc_shim", "configure_transport") -fn configure_transport_ffi( - max_sessions: Int, - max_pipeline_length: Int, - keep_alive_timeout: Int, - max_keep_alive_length: Int, -) -> Nil +@external(erlang, "dream_http_shim", "configure_transport") +fn configure_transport_ffi(config: TransportConfig) -> Nil /// Set callback for stream start event /// @@ -1202,7 +1354,7 @@ pub opaque type RequestId { /// Stream message types emitted by internal streaming machinery /// /// `start_stream()` runs a stream loop in a dedicated process; that process -/// receives and decodes httpc messages into these variants. +/// receives and decodes gun messages into these variants. /// /// ## Message Flow /// @@ -1214,7 +1366,7 @@ pub opaque type RequestId { /// ## DecodeError /// /// `DecodeError` indicates the Erlang→Gleam FFI boundary received a malformed -/// message from `httpc`. This is **not a normal HTTP error** - it means either: +/// message from `gun`. This is **not a normal HTTP error** - it means either: /// /// - Erlang/OTP version incompatibility with this library /// - Memory corruption or other serious runtime issue @@ -1352,7 +1504,7 @@ pub fn send(client_request: ClientRequest) -> Result(HttpResponse, SendError) { fn send_without_recorder( client_request: ClientRequest, ) -> Result(HttpResponse, SendError) { - send_client_request_to_httpc(client_request) + send_client_request_via_gun(client_request) } fn send_with_recorder( @@ -1388,7 +1540,7 @@ fn send_and_maybe_record( recorder_instance: recorder.Recorder, recorded_request: recording.RecordedRequest, ) -> Result(HttpResponse, SendError) { - case send_client_request_to_httpc_with_meta(client_request) { + case send_client_request_via_gun_with_meta(client_request) { Ok(#(status, headers, body)) -> { let recorded_response = recording.BlockingResponse(status: status, headers: headers, body: body) @@ -1431,16 +1583,16 @@ fn record_response_if_needed( } } -fn send_client_request_to_httpc( +fn send_client_request_via_gun( client_request: ClientRequest, ) -> Result(HttpResponse, SendError) { - case send_client_request_to_httpc_with_meta(client_request) { + case send_client_request_via_gun_with_meta(client_request) { Ok(#(status, headers, body)) -> response_result(status, headers, body) Error(error_message) -> Error(RequestError(message: error_message)) } } -fn send_client_request_to_httpc_with_meta( +fn send_client_request_via_gun_with_meta( client_request: ClientRequest, ) -> Result(#(Int, List(#(String, String)), String), String) { let http_request = to_http_request(client_request) @@ -1515,7 +1667,7 @@ fn resolve_auto_redirect(client_request: ClientRequest) -> Bool { } } -@external(erlang, "dream_httpc_shim", "request_sync") +@external(erlang, "dream_http_shim", "request_sync") fn send_sync( method: d.Dynamic, url: String, @@ -1567,7 +1719,7 @@ fn send_sync( /// /// Possible error reasons (actual errors only): /// - `"timeout"` - Request timed out -/// - Connection errors from `httpc` +/// - Connection errors from `gun` /// /// ## Parameters /// @@ -1845,7 +1997,7 @@ fn handle_yielder_start_with_state( state: YielderState, ) -> yielder.Step(Result(bytes_tree.BytesTree, String), YielderState) { let request_result = - internal.start_httpc_stream( + internal.start_gun_stream( state.http_req, state.timeout_ms, state.connect_timeout_ms, @@ -1902,7 +2054,7 @@ fn handle_recording_yielder_start( state: RecordingYielderState, ) -> yielder.Step(Result(bytes_tree.BytesTree, String), RecordingYielderState) { let request_result = - internal.start_httpc_stream( + internal.start_gun_stream( state.http_req, state.timeout_ms, state.connect_timeout_ms, @@ -1995,7 +2147,7 @@ fn save_streaming_recording( // Reverse chunks to get correct order (we prepended them) let ordered_chunks = list.reverse(chunks) - // httpc only streams body chunks for successful responses (200/206). We + // gun only streams body chunks for successful responses (200/206). We // infer 206 if Content-Range is present; otherwise default to 200. let status = case list.any(state.start_headers, fn(h) { @@ -2020,7 +2172,7 @@ fn save_streaming_recording( } // Internal: Start a message-based streaming HTTP request -// Used by start_stream() to initiate the low-level HTTP stream via httpc. +// Used by start_stream() to initiate the low-level HTTP stream via gun. // Note: start_stream() already handles playback via maybe_replay_from_recording() // before reaching this function. This path is for live HTTP requests only. fn stream_messages(client_request: ClientRequest) -> Result(RequestId, String) { @@ -2041,12 +2193,12 @@ fn stream_messages_with_recorder( Ok(option.Some(_recording)) -> // Safety net: start_stream() replays via maybe_replay_from_recording() // before reaching here. If we land here anyway, it means the low-level - // httpc message path cannot replay recordings. + // gun message path cannot replay recordings. Error( "Unexpected: recording found in stream_messages path. This should have been handled by start_stream() playback.", ) Ok(option.None) -> - send_stream_messages_to_httpc( + send_stream_messages_via_gun( client_request, option.Some(recorder_instance), recorded_request, @@ -2059,10 +2211,10 @@ fn stream_messages_without_recorder( client_request: ClientRequest, ) -> Result(RequestId, String) { let recorded_request = client_request_to_recorded_request(client_request) - send_stream_messages_to_httpc(client_request, option.None, recorded_request) + send_stream_messages_via_gun(client_request, option.None, recorded_request) } -fn send_stream_messages_to_httpc( +fn send_stream_messages_via_gun( client_request: ClientRequest, recorder_option: Option(recorder.Recorder), recorded_request: recording.RecordedRequest, @@ -2144,7 +2296,7 @@ fn parse_stream_start_result(result: d.Dynamic) -> Result(RequestId, String) { case tag_result { Ok(tag_dyn) -> parse_stream_start_tag(tag_dyn, result) Error(decode_errors) -> - Error("Failed to parse httpc response: " <> string.inspect(decode_errors)) + Error("Failed to parse gun response: " <> string.inspect(decode_errors)) } } @@ -2156,7 +2308,7 @@ fn parse_stream_start_tag( case tag { "ok" -> extract_request_id(result) "error" -> extract_error_reason(result) - _ -> Error("Unknown response from httpc") + _ -> Error("Unknown response from gun") } } @@ -2213,7 +2365,7 @@ fn apply_mapper_to_dynamic( dyn: d.Dynamic, mapper: fn(StreamMessage) -> msg, ) -> msg { - // Erlang does the heavy lifting: converts raw httpc messages to clean format + // Erlang does the heavy lifting: converts raw gun messages to clean format // We just decode the simple {Tag, RequestId, Data} tuple let simplified = internal.decode_stream_message_for_selector(dyn) let stream_msg = decode_simplified_message(simplified) @@ -2776,7 +2928,7 @@ type MessageStreamRecorderState { // ETS table name for recorder state const recorder_table_name = "dream_http_client_stream_recorders" -@external(erlang, "dream_httpc_shim", "ets_insert") +@external(erlang, "dream_http_shim", "ets_insert") fn ets_insert( table: String, key: String, @@ -2787,10 +2939,10 @@ fn ets_insert( last_chunk_time: Option(Int), ) -> Nil -@external(erlang, "dream_httpc_shim", "ets_lookup") +@external(erlang, "dream_http_shim", "ets_lookup") fn ets_lookup(table: String, key: String) -> Option(MessageStreamRecorderState) -@external(erlang, "dream_httpc_shim", "ets_delete") +@external(erlang, "dream_http_shim", "ets_delete") fn ets_delete(table: String, key: String) -> Bool fn store_message_stream_recorder( diff --git a/modules/http_client/src/dream_http_client/dream_http_client_app.erl b/modules/http_client/src/dream_http_client/dream_http_client_app.erl index dea0872..2ed1e40 100644 --- a/modules/http_client/src/dream_http_client/dream_http_client_app.erl +++ b/modules/http_client/src/dream_http_client/dream_http_client_app.erl @@ -6,6 +6,7 @@ start(_Type, _Args) -> ets:new(dream_http_client_ref_mapping, [set, public, named_table]), ets:new(dream_http_client_stream_recorders, [set, public, named_table]), ets:new(dream_http_client_transport_config, [set, public, named_table]), + ets:new(dream_http_client_connections, [bag, public, named_table]), dream_http_client_sup:start_link(). stop(_State) -> diff --git a/modules/http_client/src/dream_http_client/dream_http_client_sup.erl b/modules/http_client/src/dream_http_client/dream_http_client_sup.erl index 8221780..812d8ef 100644 --- a/modules/http_client/src/dream_http_client/dream_http_client_sup.erl +++ b/modules/http_client/src/dream_http_client/dream_http_client_sup.erl @@ -6,4 +6,10 @@ start_link() -> supervisor:start_link({local, ?MODULE}, ?MODULE, []). init([]) -> - {ok, {#{strategy => one_for_one, intensity => 5, period => 10}, []}}. + ConnManager = #{ + id => dream_http_conn_manager, + start => {dream_http_conn_manager, start_link, []}, + restart => permanent, + type => worker + }, + {ok, {#{strategy => one_for_one, intensity => 5, period => 10}, [ConnManager]}}. diff --git a/modules/http_client/src/dream_http_client/dream_http_conn_manager.erl b/modules/http_client/src/dream_http_client/dream_http_conn_manager.erl new file mode 100644 index 0000000..4549f30 --- /dev/null +++ b/modules/http_client/src/dream_http_client/dream_http_conn_manager.erl @@ -0,0 +1,256 @@ +-module(dream_http_conn_manager). +-behaviour(gen_server). + +-export([start_link/0, get_connection/3, ensure_connection/4]). +-export([init/1, handle_call/3, handle_cast/2, handle_info/2, terminate/2]). + +-define(TABLE, dream_http_client_connections). + +start_link() -> + gen_server:start_link({local, ?MODULE}, ?MODULE, [], []). + +%% Hot path: lock-free ETS lookup with round-robin selection. +%% Returns {ok, ConnPid, Protocol} | none. +get_connection(Scheme, Host, Port) -> + case ets:lookup(?TABLE, {Scheme, Host, Port}) of + [] -> + none; + Entries -> + Len = length(Entries), + Idx = erlang:unique_integer([monotonic, positive]) rem Len, + {_Key, ConnPid, _MonRef, Protocol, _LastUsed} = lists:nth(Idx + 1, Entries), + case erlang:is_process_alive(ConnPid) of + true -> + touch(ConnPid), + {ok, ConnPid, Protocol}; + false -> + %% Dead connection — try the others + Alive = [E || {_, Pid, _, _, _} = E <- Entries, erlang:is_process_alive(Pid)], + case Alive of + [] -> none; + _ -> + Idx2 = erlang:unique_integer([monotonic, positive]) rem length(Alive), + {_, Pid2, _, Proto2, _} = lists:nth(Idx2 + 1, Alive), + touch(Pid2), + {ok, Pid2, Proto2} + end + end + end. + +%% Cold path: create or reuse a connection via the gen_server. +ensure_connection(Scheme, Host, Port, GunOpts) -> + gen_server:call(?MODULE, {ensure_connection, Scheme, Host, Port, GunOpts}, 30000). + +%% Update LastUsed for a connection entry. +%% bag tables don't support select_replace, so we delete+insert. +touch(ConnPid) -> + Now = erlang:monotonic_time(millisecond), + case ets:match_object(?TABLE, {'_', ConnPid, '_', '_', '_'}) of + [{Key, _, MonRef, Protocol, _OldLastUsed}] -> + ets:match_delete(?TABLE, {'_', ConnPid, '_', '_', '_'}), + ets:insert(?TABLE, {Key, ConnPid, MonRef, Protocol, Now}); + [_ | _] = Entries -> + ets:match_delete(?TABLE, {'_', ConnPid, '_', '_', '_'}), + lists:foreach(fun({Key, _, MonRef, Protocol, _}) -> + ets:insert(?TABLE, {Key, ConnPid, MonRef, Protocol, Now}) + end, Entries); + [] -> + ok + end. + +%% ============================================================================ +%% gen_server callbacks +%% ============================================================================ + +init([]) -> + %% Crash recovery: scan ETS for leftover entries from previous incarnation + recover_connections(), + schedule_idle_check(), + {ok, #{}}. + +handle_call({ensure_connection, Scheme, Host, Port, GunOpts}, _From, State) -> + Key = {Scheme, Host, Port}, + Existing = ets:lookup(?TABLE, Key), + %% Clean up dead connections first + Alive = [E || {_, Pid, _, _, _} = E <- Existing, erlang:is_process_alive(Pid)], + Dead = length(Existing) - length(Alive), + case Dead > 0 of + true -> + lists:foreach(fun({_, Pid, _, _, _}) -> + case erlang:is_process_alive(Pid) of + false -> ets:match_delete(?TABLE, {'_', Pid, '_', '_', '_'}); + true -> ok + end + end, Existing); + false -> ok + end, + MaxConns = maps:get(max_connections, GunOpts, 50), + AliveCount = length(Alive), + case AliveCount < MaxConns of + true -> + case open_connection(Scheme, Host, Port, GunOpts) of + {ok, ConnPid, Protocol} -> + MonRef = erlang:monitor(process, ConnPid), + Now = erlang:monotonic_time(millisecond), + ets:insert(?TABLE, {Key, ConnPid, MonRef, Protocol, Now}), + {reply, {ok, ConnPid, Protocol}, State}; + {error, Reason} -> + {reply, {error, Reason}, State} + end; + false -> + %% At max — round-robin an existing one + Idx = erlang:unique_integer([monotonic, positive]) rem AliveCount, + {_, Pid, _, Proto, _} = lists:nth(Idx + 1, Alive), + touch(Pid), + {reply, {ok, Pid, Proto}, State} + end; + +handle_call(_Request, _From, State) -> + {reply, {error, unknown_call}, State}. + +handle_cast(_Msg, State) -> + {noreply, State}. + +handle_info({'DOWN', _MonRef, process, ConnPid, _Reason}, State) -> + ets:match_delete(?TABLE, {'_', ConnPid, '_', '_', '_'}), + {noreply, State}; + +handle_info(check_idle, State) -> + reap_idle_connections(), + schedule_idle_check(), + {noreply, State}; + +handle_info({gun_up, _ConnPid, _Protocol}, State) -> + {noreply, State}; + +handle_info({gun_down, _ConnPid, _Protocol, _Reason, _KilledStreams}, State) -> + {noreply, State}; + +handle_info({gun_down, _ConnPid, _Protocol, _Reason, _KilledStreams, _UnprocessedStreams}, State) -> + {noreply, State}; + +handle_info(_Info, State) -> + {noreply, State}. + +terminate(_Reason, _State) -> + ok. + +%% ============================================================================ +%% Internal +%% ============================================================================ + +open_connection(Scheme, Host, Port, GunOpts) -> + Transport = case Scheme of + https -> tls; + _ -> tcp + end, + ConnectTimeout = maps:get(connect_timeout, GunOpts, 15000), + HostStr = case is_binary(Host) of + true -> binary_to_list(Host); + false -> Host + end, + Protocols = case Transport of + tls -> [http2, http]; + tcp -> [http] + end, + BaseOpts = #{ + transport => Transport, + connect_timeout => ConnectTimeout, + protocols => Protocols, + retry => maps:get(retry, GunOpts, 3), + retry_timeout => maps:get(retry_timeout, GunOpts, 1000), + domain_lookup_timeout => maps:get(domain_lookup_timeout, GunOpts, 5000), + tls_handshake_timeout => maps:get(tls_handshake_timeout, GunOpts, 10000) + }, + Http2Opts = build_http2_opts(GunOpts), + Opts = case maps:size(Http2Opts) of + 0 -> BaseOpts; + _ -> BaseOpts#{http2_opts => Http2Opts} + end, + TlsOpts = case Transport of + tls -> + Opts#{tls_opts => [ + {verify, verify_peer}, + {cacerts, public_key:cacerts_get()}, + {depth, 3}, + {customize_hostname_check, [{match_fun, public_key:pkix_verify_hostname_match_fun(https)}]}, + {alpn_advertised_protocols, [<<"h2">>, <<"http/1.1">>]} + ]}; + tcp -> Opts + end, + case gun:open(HostStr, Port, TlsOpts) of + {ok, ConnPid} -> + case gun:await_up(ConnPid, ConnectTimeout) of + {ok, Protocol} -> + {ok, ConnPid, Protocol}; + {error, {down, {shutdown, Reason}}} -> + {error, Reason}; + {error, timeout} -> + gun:close(ConnPid), + {error, connect_timeout}; + {error, Reason} -> + gun:close(ConnPid), + {error, Reason} + end; + {error, Reason} -> + {error, Reason} + end. + +build_http2_opts(GunOpts) -> + Pairs = [ + {keepalive, maps:get(keepalive, GunOpts, undefined)}, + {keepalive_tolerance, maps:get(keepalive_tolerance, GunOpts, undefined)}, + {max_concurrent_streams, maps:get(max_concurrent_streams, GunOpts, undefined)}, + {initial_connection_window_size, maps:get(initial_connection_window_size, GunOpts, undefined)}, + {initial_stream_window_size, maps:get(initial_stream_window_size, GunOpts, undefined)}, + {closing_timeout, maps:get(closing_timeout, GunOpts, undefined)} + ], + maps:from_list([{K, V} || {K, V} <- Pairs, V =/= undefined]). + +recover_connections() -> + try + All = ets:tab2list(?TABLE), + lists:foreach(fun({Key, ConnPid, OldMonRef, Protocol, LastUsed}) -> + catch erlang:demonitor(OldMonRef, [flush]), + case erlang:is_process_alive(ConnPid) of + true -> + NewMonRef = erlang:monitor(process, ConnPid), + ets:match_delete(?TABLE, {Key, ConnPid, '_', '_', '_'}), + ets:insert(?TABLE, {Key, ConnPid, NewMonRef, Protocol, LastUsed}); + false -> + ets:match_delete(?TABLE, {Key, ConnPid, '_', '_', '_'}) + end + end, All) + catch + error:badarg -> ok + end. + +schedule_idle_check() -> + IdleTimeout = get_idle_timeout(), + erlang:send_after(IdleTimeout, self(), check_idle). + +reap_idle_connections() -> + IdleTimeout = get_idle_timeout(), + Now = erlang:monotonic_time(millisecond), + try + All = ets:tab2list(?TABLE), + lists:foreach(fun({_Key, ConnPid, _MonRef, _Protocol, LastUsed}) -> + case Now - LastUsed > IdleTimeout of + true -> + gun:close(ConnPid), + ets:match_delete(?TABLE, {'_', ConnPid, '_', '_', '_'}); + false -> + ok + end + end, All) + catch + error:badarg -> ok + end. + +get_idle_timeout() -> + case ets:lookup(dream_http_client_transport_config, config) of + [{config, Config}] -> + element(3, Config); % idle_timeout is the 3rd field (after tag + max_connections) + [] -> + 60000 + end. diff --git a/modules/http_client/src/dream_http_client/dream_http_shim.erl b/modules/http_client/src/dream_http_client/dream_http_shim.erl new file mode 100644 index 0000000..44c0ba9 --- /dev/null +++ b/modules/http_client/src/dream_http_client/dream_http_shim.erl @@ -0,0 +1,949 @@ +-module(dream_http_shim). + +-export([request_stream/8, fetch_next/2, fetch_start_headers/2, request_stream_messages/8, + cancel_stream/1, cancel_stream_by_string/1, receive_stream_message/1, + decode_stream_message_for_selector/1, normalize_headers/1, request_sync/7, + configure_transport/1, + ets_table_exists/1, ets_new/2, ets_insert/7, ets_lookup/2, ets_delete/2]). + +-define(REF_MAPPING_TABLE, dream_http_client_ref_mapping). +-define(MAX_REDIRECTS, 5). + +%% ============================================================================ +%% Synchronous (blocking) request +%% ============================================================================ + +request_sync(Method, Url, Headers, Body, TimeoutMs, ConnectTimeoutMs, AutoRedirect) -> + NHeaders = maybe_add_accept_encoding(to_gun_headers(Headers)), + request_sync_impl(Method, Url, NHeaders, Body, TimeoutMs, ConnectTimeoutMs, AutoRedirect, 0, 1). + +request_sync_impl(_Method, _Url, _Headers, _Body, _TimeoutMs, _ConnectTimeoutMs, _AutoRedirect, Redirects, _RetriesLeft) when Redirects >= ?MAX_REDIRECTS -> + {error, <<"too_many_redirects">>}; +request_sync_impl(Method, Url, Headers, Body, TimeoutMs, ConnectTimeoutMs, AutoRedirect, Redirects, RetriesLeft) -> + case parse_url(Url) of + {ok, Scheme, Host, Port, PathQs} -> + GunOpts = build_gun_opts(ConnectTimeoutMs), + case get_or_open_connection(Scheme, Host, Port, GunOpts) of + {ok, ConnPid, _Protocol} -> + MethodAtom = to_method_atom(Method), + StreamRef = send_request(ConnPid, MethodAtom, PathQs, Headers, Body), + case gun:await(ConnPid, StreamRef, TimeoutMs) of + {response, fin, Status, RespHeaders} -> + NormHeaders = normalize_headers(RespHeaders), + handle_sync_response(Status, NormHeaders, <<>>, AutoRedirect, Redirects, + Method, Url, Headers, Body, TimeoutMs, ConnectTimeoutMs, RespHeaders); + {response, nofin, Status, RespHeaders} -> + case gun:await_body(ConnPid, StreamRef, TimeoutMs) of + {ok, RespBody} -> + {DecompBody, CleanHeaders} = maybe_decompress_response(RespBody, RespHeaders), + NormHeaders = normalize_headers(CleanHeaders), + handle_sync_response(Status, NormHeaders, DecompBody, AutoRedirect, Redirects, + Method, Url, Headers, Body, TimeoutMs, ConnectTimeoutMs, RespHeaders); + {ok, RespBody, _Trailers} -> + {DecompBody, CleanHeaders} = maybe_decompress_response(RespBody, RespHeaders), + NormHeaders = normalize_headers(CleanHeaders), + handle_sync_response(Status, NormHeaders, DecompBody, AutoRedirect, Redirects, + Method, Url, Headers, Body, TimeoutMs, ConnectTimeoutMs, RespHeaders); + {error, timeout} -> + {error, <<"timeout">>}; + {error, Reason} -> + {error, format_error(Reason)} + end; + {error, timeout} -> + {error, <<"timeout">>}; + {error, {stream_error, Reason, _HumanReadable}} -> + case RetriesLeft > 0 andalso is_stale_connection_error({stream_error, Reason}) of + true -> + gun:close(ConnPid), + request_sync_impl(Method, Url, Headers, Body, TimeoutMs, + ConnectTimeoutMs, AutoRedirect, Redirects, RetriesLeft - 1); + false -> + {error, format_error(Reason)} + end; + {error, Reason} -> + case RetriesLeft > 0 andalso is_stale_connection_error(Reason) of + true -> + gun:close(ConnPid), + request_sync_impl(Method, Url, Headers, Body, TimeoutMs, + ConnectTimeoutMs, AutoRedirect, Redirects, RetriesLeft - 1); + false -> + {error, format_error(Reason)} + end + end; + {error, Reason} -> + {error, format_connection_error(Reason)} + end; + {error, Reason} -> + {error, format_error(Reason)} + end. + +handle_sync_response(Status, NormHeaders, DecompBody, AutoRedirect, Redirects, + Method, Url, OrigHeaders, OrigBody, TimeoutMs, ConnectTimeoutMs, RawRespHeaders) -> + case AutoRedirect andalso is_redirect(Status) of + true -> + case get_location(RawRespHeaders) of + {ok, Location} -> + ResolvedUrl = resolve_redirect_url(Location, Url), + RedirectMethod = redirect_method(Status, Method), + RedirectBody = redirect_body(Status, OrigBody), + request_sync_impl(RedirectMethod, ResolvedUrl, OrigHeaders, RedirectBody, + TimeoutMs, ConnectTimeoutMs, AutoRedirect, Redirects + 1, 1); + error -> + {ok, {Status, NormHeaders, DecompBody}} + end; + false -> + {ok, {Status, NormHeaders, DecompBody}} + end. + +%% ============================================================================ +%% Pull-based streaming +%% ============================================================================ + +request_stream(Method, Url, Headers, Body, _Receiver, TimeoutMs, ConnectTimeoutMs, AutoRedirect) -> + NHeaders = maybe_add_accept_encoding(to_gun_headers(Headers)), + Owner = spawn(fun() -> + stream_owner_init(Method, Url, NHeaders, Body, TimeoutMs, ConnectTimeoutMs, AutoRedirect) + end), + {ok, Owner}. + +stream_owner_init(Method, Url, Headers, Body, TimeoutMs, ConnectTimeoutMs, AutoRedirect) -> + case start_gun_stream(Method, Url, Headers, Body, TimeoutMs, ConnectTimeoutMs, AutoRedirect, 0) of + {ok, ConnPid, StreamRef, _Status, RespHeaders} -> + ZlibCtx = maybe_init_stream_zlib(RespHeaders), + NormHeaders = normalize_headers(RespHeaders), + stream_owner_wait(ConnPid, StreamRef, [], NormHeaders, [], ZlibCtx, TimeoutMs); + {error, Reason} -> + exit({stream_start_failed, Reason}) + end. + +%% Follows redirects, then returns {ok, ConnPid, StreamRef, Status, Headers} for a +%% streaming response (status 2xx), or {error, Reason} for non-2xx / failure. +start_gun_stream(Method, Url, Headers, Body, TimeoutMs, ConnectTimeoutMs, _AutoRedirect, Redirects) when Redirects >= ?MAX_REDIRECTS -> + start_gun_stream_final(Method, Url, Headers, Body, TimeoutMs, ConnectTimeoutMs); +start_gun_stream(Method, Url, Headers, Body, TimeoutMs, ConnectTimeoutMs, AutoRedirect, Redirects) -> + case start_gun_stream_final(Method, Url, Headers, Body, TimeoutMs, ConnectTimeoutMs) of + {ok, ConnPid, StreamRef, Status, RespHeaders} -> + case AutoRedirect andalso is_redirect(Status) of + true -> + %% Drain the body so the stream is clean + drain_stream(ConnPid, StreamRef), + case get_location(RespHeaders) of + {ok, Location} -> + ResolvedUrl = resolve_redirect_url(Location, Url), + RedirectMethod = redirect_method(Status, Method), + RedirectBody = redirect_body(Status, Body), + start_gun_stream(RedirectMethod, ResolvedUrl, Headers, RedirectBody, + TimeoutMs, ConnectTimeoutMs, AutoRedirect, Redirects + 1); + error -> + {ok, ConnPid, StreamRef, Status, RespHeaders} + end; + false -> + case Status >= 200 andalso Status < 300 of + true -> + {ok, ConnPid, StreamRef, Status, RespHeaders}; + false -> + FullBody = collect_body(ConnPid, StreamRef, TimeoutMs), + StatusBin = integer_to_binary(Status), + ReasonPhrase = status_reason(Status), + SafeBody = ensure_utf8_binary(FullBody), + ErrorMsg = <<"HTTP ", StatusBin/binary, " ", ReasonPhrase/binary, ": ", SafeBody/binary>>, + {error, ErrorMsg} + end + end; + {error, Reason} -> + {error, Reason} + end. + +start_gun_stream_final(Method, Url, Headers, Body, TimeoutMs, ConnectTimeoutMs) -> + start_gun_stream_final(Method, Url, Headers, Body, TimeoutMs, ConnectTimeoutMs, 1). + +start_gun_stream_final(Method, Url, Headers, Body, TimeoutMs, ConnectTimeoutMs, RetriesLeft) -> + case parse_url(Url) of + {ok, Scheme, Host, Port, PathQs} -> + GunOpts = build_gun_opts(ConnectTimeoutMs), + case get_or_open_connection(Scheme, Host, Port, GunOpts) of + {ok, ConnPid, _Protocol} -> + MethodAtom = to_method_atom(Method), + StreamRef = send_request(ConnPid, MethodAtom, PathQs, Headers, Body), + case gun:await(ConnPid, StreamRef, TimeoutMs) of + {response, fin, Status, RespHeaders} -> + {ok, ConnPid, StreamRef, Status, RespHeaders}; + {response, nofin, Status, RespHeaders} -> + {ok, ConnPid, StreamRef, Status, RespHeaders}; + {error, timeout} -> + {error, <<"timeout">>}; + {error, Reason} -> + case RetriesLeft > 0 andalso is_stale_connection_error(Reason) of + true -> + gun:close(ConnPid), + start_gun_stream_final(Method, Url, Headers, Body, + TimeoutMs, ConnectTimeoutMs, RetriesLeft - 1); + false -> + {error, format_error(Reason)} + end + end; + {error, Reason} -> + {error, format_connection_error(Reason)} + end; + {error, Reason} -> + {error, format_error(Reason)} + end. + +drain_stream(ConnPid, StreamRef) -> + case gun:await_body(ConnPid, StreamRef, 5000) of + {ok, _Body} -> ok; + {ok, _Body, _Trailers} -> ok; + _ -> ok + end. + +collect_body(ConnPid, StreamRef, Timeout) -> + case gun:await_body(ConnPid, StreamRef, Timeout) of + {ok, Body} -> Body; + {ok, Body, _Trailers} -> Body; + _ -> <<>> + end. + +%% Stream owner loop: services fetch_next requests using gun messages +stream_owner_wait(ConnPid, StreamRef, Buffer, StartHeaders, StartWaiters, ZlibCtx, TimeoutMs) -> + receive + {fetch_next, From} -> + handle_fetch_next(From, ConnPid, StreamRef, Buffer, StartHeaders, StartWaiters, ZlibCtx, TimeoutMs); + {fetch_start_headers, From} -> + case StartHeaders of + undefined -> + stream_owner_wait(ConnPid, StreamRef, Buffer, StartHeaders, [From | StartWaiters], ZlibCtx, TimeoutMs); + _ -> + From ! {stream_start_headers, normalize_headers_default(StartHeaders)}, + stream_owner_wait(ConnPid, StreamRef, Buffer, StartHeaders, StartWaiters, ZlibCtx, TimeoutMs) + end; + {gun_data, ConnPid, StreamRef, nofin, Data} -> + DecompData = case ZlibCtx of + undefined -> Data; + _ -> decompress_chunk(ZlibCtx, Data) + end, + stream_owner_wait(ConnPid, StreamRef, Buffer ++ [{chunk, DecompData}], StartHeaders, StartWaiters, ZlibCtx, TimeoutMs); + {gun_data, ConnPid, StreamRef, fin, Data} -> + DecompData = case ZlibCtx of + undefined -> Data; + _ -> decompress_chunk(ZlibCtx, Data) + end, + cleanup_zlib(ZlibCtx), + FinalBuffer = case byte_size(DecompData) > 0 of + true -> Buffer ++ [{chunk, DecompData}, {finished, []}]; + false -> Buffer ++ [{finished, []}] + end, + stream_owner_wait(ConnPid, StreamRef, FinalBuffer, StartHeaders, StartWaiters, undefined, TimeoutMs); + {gun_trailers, ConnPid, StreamRef, Trailers} -> + cleanup_zlib(ZlibCtx), + stream_owner_wait(ConnPid, StreamRef, Buffer ++ [{finished, normalize_headers(Trailers)}], + StartHeaders, StartWaiters, undefined, TimeoutMs); + {gun_error, ConnPid, StreamRef, Reason} -> + cleanup_zlib(ZlibCtx), + stream_owner_wait(ConnPid, StreamRef, Buffer ++ [{error, format_error(Reason)}], + StartHeaders, StartWaiters, undefined, TimeoutMs); + {gun_error, ConnPid, Reason} -> + cleanup_zlib(ZlibCtx), + stream_owner_wait(ConnPid, StreamRef, Buffer ++ [{error, format_error(Reason)}], + StartHeaders, StartWaiters, undefined, TimeoutMs); + _Other -> + stream_owner_wait(ConnPid, StreamRef, Buffer, StartHeaders, StartWaiters, ZlibCtx, TimeoutMs) + end. + +handle_fetch_next(From, ConnPid, StreamRef, [], StartHeaders, StartWaiters, ZlibCtx, TimeoutMs) -> + %% Buffer empty — wait for next gun message + receive + {gun_data, ConnPid, StreamRef, nofin, Data} -> + DecompData = case ZlibCtx of + undefined -> Data; + _ -> decompress_chunk(ZlibCtx, Data) + end, + From ! {stream_chunk, DecompData}, + stream_owner_wait(ConnPid, StreamRef, [], StartHeaders, StartWaiters, ZlibCtx, TimeoutMs); + {gun_data, ConnPid, StreamRef, fin, Data} -> + DecompData = case ZlibCtx of + undefined -> Data; + _ -> decompress_chunk(ZlibCtx, Data) + end, + cleanup_zlib(ZlibCtx), + case byte_size(DecompData) > 0 of + true -> + From ! {stream_chunk, DecompData}, + stream_owner_wait(ConnPid, StreamRef, [{finished, []}], StartHeaders, StartWaiters, undefined, TimeoutMs); + false -> + From ! {stream_end, []}, + ok + end; + {gun_trailers, ConnPid, StreamRef, Trailers} -> + cleanup_zlib(ZlibCtx), + From ! {stream_end, normalize_headers(Trailers)}, + ok; + {gun_error, ConnPid, StreamRef, Reason} -> + cleanup_zlib(ZlibCtx), + From ! {stream_error, format_error(Reason)}, + ok; + {gun_error, ConnPid, Reason} -> + cleanup_zlib(ZlibCtx), + From ! {stream_error, format_error(Reason)}, + ok + after TimeoutMs -> + From ! {stream_error, timeout}, + stream_owner_wait(ConnPid, StreamRef, [], StartHeaders, StartWaiters, ZlibCtx, TimeoutMs) + end; +handle_fetch_next(From, ConnPid, StreamRef, [Item | Rest], StartHeaders, StartWaiters, ZlibCtx, TimeoutMs) -> + deliver_message(From, Item), + case Item of + {finished, _} -> ok; + {error, _} -> ok; + _ -> stream_owner_wait(ConnPid, StreamRef, Rest, StartHeaders, StartWaiters, ZlibCtx, TimeoutMs) + end. + +deliver_message(From, {chunk, Bin}) -> + From ! {stream_chunk, Bin}; +deliver_message(From, {finished, Headers}) -> + From ! {stream_end, Headers}; +deliver_message(From, {error, Reason}) -> + From ! {stream_error, Reason}. + +fetch_next(OwnerPid, TimeoutMs) -> + MonitorRef = erlang:monitor(process, OwnerPid), + OwnerPid ! {fetch_next, self()}, + receive + {stream_chunk, Bin} -> + erlang:demonitor(MonitorRef, [flush]), + {chunk, Bin}; + {stream_end, Headers} -> + erlang:demonitor(MonitorRef, [flush]), + {finished, Headers}; + {stream_error, Reason} -> + erlang:demonitor(MonitorRef, [flush]), + {error, Reason}; + {'DOWN', MonitorRef, process, OwnerPid, Reason} -> + {error, format_exit_reason(Reason)} + after TimeoutMs -> + erlang:demonitor(MonitorRef, [flush]), + {error, timeout} + end. + +fetch_start_headers(OwnerPid, TimeoutMs) -> + MonitorRef = erlang:monitor(process, OwnerPid), + OwnerPid ! {fetch_start_headers, self()}, + receive + {stream_start_headers, Headers} -> + erlang:demonitor(MonitorRef, [flush]), + {ok, Headers}; + {'DOWN', MonitorRef, process, OwnerPid, Reason} -> + {error, format_exit_reason(Reason)} + after TimeoutMs -> + erlang:demonitor(MonitorRef, [flush]), + {error, timeout} + end. + +normalize_headers_default(undefined) -> []; +normalize_headers_default(Headers) -> Headers. + +%% ============================================================================ +%% Message-based streaming +%% ============================================================================ + +request_stream_messages(Method, Url, Headers, Body, _ReceiverPid, TimeoutMs, + ConnectTimeoutMs, AutoRedirect) -> + NHeaders = maybe_add_accept_encoding(to_gun_headers(Headers)), + CallerPid = self(), + TranslatorPid = spawn(fun() -> + translator_init(Method, Url, NHeaders, Body, CallerPid, TimeoutMs, ConnectTimeoutMs, AutoRedirect) + end), + %% Generate a unique string ID for this stream + StringId = translator_ref_to_string(TranslatorPid), + store_ref_mapping(StringId, TranslatorPid), + {ok, StringId}. + +translator_init(Method, Url, Headers, Body, CallerPid, TimeoutMs, ConnectTimeoutMs, AutoRedirect) -> + case start_gun_stream(Method, Url, Headers, Body, TimeoutMs, ConnectTimeoutMs, AutoRedirect, 0) of + {ok, ConnPid, StreamRef, _Status, RespHeaders} -> + StringId = get_my_string_id(), + %% Store ConnPid and StreamRef for cancellation + store_cancel_info(StringId, ConnPid, StreamRef), + NormHeaders = normalize_headers(RespHeaders), + CallerPid ! {http, {StringId, stream_start, NormHeaders}}, + ZlibCtx = maybe_init_stream_zlib(RespHeaders), + translator_loop(ConnPid, StreamRef, CallerPid, StringId, ZlibCtx, TimeoutMs); + {error, Reason} -> + StringId = get_my_string_id(), + CallerPid ! {http, {StringId, {error, Reason}}}, + ok + end. + +translator_loop(ConnPid, StreamRef, CallerPid, StringId, ZlibCtx, TimeoutMs) -> + receive + {gun_data, ConnPid, StreamRef, nofin, Data} -> + DecompData = case ZlibCtx of + undefined -> Data; + _ -> decompress_chunk(ZlibCtx, Data) + end, + CallerPid ! {http, {StringId, stream, DecompData}}, + translator_loop(ConnPid, StreamRef, CallerPid, StringId, ZlibCtx, TimeoutMs); + {gun_data, ConnPid, StreamRef, fin, Data} -> + case byte_size(Data) > 0 of + true -> + DecompData = case ZlibCtx of + undefined -> Data; + _ -> decompress_chunk(ZlibCtx, Data) + end, + CallerPid ! {http, {StringId, stream, DecompData}}; + false -> ok + end, + cleanup_zlib(ZlibCtx), + CallerPid ! {http, {StringId, stream_end, []}}, + ok; + {gun_trailers, ConnPid, StreamRef, Trailers} -> + cleanup_zlib(ZlibCtx), + CallerPid ! {http, {StringId, stream_end, normalize_headers(Trailers)}}, + ok; + {gun_error, ConnPid, StreamRef, Reason} -> + cleanup_zlib(ZlibCtx), + CallerPid ! {http, {StringId, {error, format_error(Reason)}}}, + ok; + {gun_error, ConnPid, Reason} -> + cleanup_zlib(ZlibCtx), + CallerPid ! {http, {StringId, {error, format_error(Reason)}}}, + ok; + cancel -> + cleanup_zlib(ZlibCtx), + gun:cancel(ConnPid, StreamRef), + ok; + _Other -> + translator_loop(ConnPid, StreamRef, CallerPid, StringId, ZlibCtx, TimeoutMs) + after TimeoutMs -> + cleanup_zlib(ZlibCtx), + CallerPid ! {http, {StringId, {error, <<"timeout">>}}}, + ok + end. + +get_my_string_id() -> + translator_ref_to_string(self()). + +translator_ref_to_string(Pid) -> + ensure_utf8_binary(io_lib:format("~p", [Pid])). + +store_cancel_info(StringId, ConnPid, StreamRef) -> + ets:insert(?REF_MAPPING_TABLE, {{cancel, StringId}, {ConnPid, StreamRef}}). + +%% ============================================================================ +%% Cancellation +%% ============================================================================ + +cancel_stream(RequestId) -> + case ets:lookup(?REF_MAPPING_TABLE, {cancel, RequestId}) of + [{{cancel, _}, {ConnPid, StreamRef}}] -> + gun:cancel(ConnPid, StreamRef), + ok; + [] -> + ok + end. + +cancel_stream_by_string(StringId) -> + case lookup_ref_by_string(StringId) of + {some, TranslatorPid} when is_pid(TranslatorPid) -> + TranslatorPid ! cancel, + remove_ref_mapping(StringId), + nil; + _ -> + %% Try direct cancel via stored info + case ets:lookup(?REF_MAPPING_TABLE, {cancel, StringId}) of + [{{cancel, _}, {ConnPid, StreamRef}}] -> + gun:cancel(ConnPid, StreamRef), + ets:delete(?REF_MAPPING_TABLE, {cancel, StringId}), + remove_ref_mapping(StringId), + nil; + [] -> + nil + end + end. + +%% ============================================================================ +%% Message decoding for selectors +%% ============================================================================ + +receive_stream_message(TimeoutMs) -> + receive + {http, {StringId, stream_start, Headers}} -> + {stream_start, StringId, Headers}; + {http, {StringId, stream, Data}} -> + {chunk, StringId, Data}; + {http, {StringId, stream_end, Headers}} -> + {stream_end, StringId, Headers}; + {http, {StringId, {error, Reason}}} -> + {stream_error, StringId, ensure_binary(Reason)} + after TimeoutMs -> + timeout + end. + +decode_stream_message_for_selector({http, InnerMessage}) -> + case InnerMessage of + {StringId, stream_start, Headers} -> + {stream_start, StringId, Headers}; + {StringId, stream, Data} -> + {chunk, StringId, Data}; + {StringId, stream_end, Headers} -> + remove_ref_mapping(StringId), + {stream_end, StringId, Headers}; + {StringId, {error, Reason}} -> + remove_ref_mapping(StringId), + {stream_error, StringId, ensure_binary(Reason)}; + _ -> + error(badarg) + end. + +ensure_binary(Bin) when is_binary(Bin) -> Bin; +ensure_binary(Other) -> ensure_utf8_binary(io_lib:format("~p", [Other])). + +%% ============================================================================ +%% Header normalization +%% ============================================================================ + +normalize_headers(Headers) when is_list(Headers) -> + lists:map(fun normalize_header_tuple/1, Headers); +normalize_headers(_) -> + []. + +normalize_header_tuple({Name, Value}) -> + {to_binary(Name), to_binary(Value)}; +normalize_header_tuple(_) -> + {<<"">>, <<"">>}. + +%% ============================================================================ +%% Transport configuration +%% ============================================================================ + +configure_transport(Config) -> + %% Config is a Gleam opaque type = Erlang tuple {transport_config, F1, F2, ...} + ets:insert(dream_http_client_transport_config, {config, Config}), + nil. + +get_transport_config() -> + case ets:lookup(dream_http_client_transport_config, config) of + [{config, Config}] -> + %% Gleam TransportConfig tuple: {transport_config, MaxConn, IdleTimeout, DefaultConnTimeout, + %% DomainLookupTimeout, TlsHandshakeTimeout, Retry, RetryTimeout, Keepalive, + %% KeepaliveTolerance, MaxConcurrentStreams, InitConnWindowSize, InitStreamWindowSize, + %% ClosingTimeout} + #{max_connections => element(2, Config), + idle_timeout => element(3, Config), + connect_timeout => element(4, Config), + domain_lookup_timeout => element(5, Config), + tls_handshake_timeout => element(6, Config), + retry => element(7, Config), + retry_timeout => element(8, Config), + keepalive => element(9, Config), + keepalive_tolerance => element(10, Config), + max_concurrent_streams => element(11, Config), + initial_connection_window_size => element(12, Config), + initial_stream_window_size => element(13, Config), + closing_timeout => element(14, Config)}; + [] -> + #{max_connections => 50, + idle_timeout => 60000, + connect_timeout => 15000, + domain_lookup_timeout => 5000, + tls_handshake_timeout => 10000, + retry => 3, + retry_timeout => 1000, + keepalive => 30000, + keepalive_tolerance => 3, + max_concurrent_streams => 100, + initial_connection_window_size => 65535, + initial_stream_window_size => 65535, + closing_timeout => 15000} + end. + +%% ============================================================================ +%% Connection management helpers +%% ============================================================================ + +get_or_open_connection(Scheme, Host, Port, GunOpts) -> + TransportConfig = get_transport_config(), + FullOpts = maps:merge(TransportConfig, GunOpts), + dream_http_conn_manager:ensure_connection(Scheme, Host, Port, FullOpts). + +build_gun_opts(ConnectTimeoutMs) -> + #{connect_timeout => ConnectTimeoutMs}. + +send_request(ConnPid, Method, PathQs, Headers, Body) when Body =:= <<>>; Body =:= undefined -> + gun:Method(ConnPid, PathQs, Headers); +send_request(ConnPid, Method, PathQs, Headers, Body) -> + gun:Method(ConnPid, PathQs, Headers, Body). + +%% ============================================================================ +%% URL parsing +%% ============================================================================ + +parse_url(Url) when is_binary(Url) -> + parse_url(binary_to_list(Url)); +parse_url(Url) when is_list(Url) -> + case uri_string:parse(Url) of + #{scheme := SchemeStr, host := Host} = Parsed -> + Scheme = case SchemeStr of + "https" -> https; + "http" -> http; + <<"https">> -> https; + <<"http">> -> http; + _ -> http + end, + Port = case maps:get(port, Parsed, undefined) of + undefined -> + case Scheme of + https -> 443; + http -> 80 + end; + P -> P + end, + Path = case maps:get(path, Parsed, "/") of + "" -> "/"; + <<>> -> "/"; + P2 -> to_list(P2) + end, + Query = case maps:get(query, Parsed, undefined) of + undefined -> ""; + Q -> to_list(Q) + end, + PathQs = case Query of + "" -> Path; + _ -> Path ++ "?" ++ Query + end, + HostStr = to_list(Host), + {ok, Scheme, HostStr, Port, to_binary(PathQs)}; + {error, Reason, _} -> + {error, Reason}; + _ -> + {error, invalid_url} + end. + +%% ============================================================================ +%% Redirect helpers +%% ============================================================================ + +is_redirect(301) -> true; +is_redirect(302) -> true; +is_redirect(303) -> true; +is_redirect(307) -> true; +is_redirect(308) -> true; +is_redirect(_) -> false. + +get_location(Headers) -> + case lists:keyfind(<<"location">>, 1, Headers) of + {_, Location} -> {ok, Location}; + false -> + %% Try case-insensitive + Result = lists:foldl(fun({K, V}, Acc) -> + case Acc of + error -> + case string:lowercase(to_list(K)) of + "location" -> {ok, V}; + _ -> error + end; + Found -> Found + end + end, error, Headers), + Result + end. + +redirect_method(303, _) -> <<"GET">>; +redirect_method(301, _) -> <<"GET">>; +redirect_method(302, _) -> <<"GET">>; +redirect_method(_, Method) -> Method. + +redirect_body(303, _) -> <<>>; +redirect_body(301, _) -> <<>>; +redirect_body(302, _) -> <<>>; +redirect_body(_, Body) -> Body. + +resolve_redirect_url(Location, OriginalUrl) -> + LocStr = to_list(Location), + case uri_string:parse(LocStr) of + #{scheme := _} -> + Location; + _ -> + OrigStr = to_list(OriginalUrl), + case uri_string:parse(OrigStr) of + #{scheme := S, host := H} = Parsed -> + Port = maps:get(port, Parsed, undefined), + Base = case Port of + undefined -> io_lib:format("~s://~s", [S, H]); + _ -> io_lib:format("~s://~s:~B", [S, H, Port]) + end, + iolist_to_binary([Base, LocStr]); + _ -> + Location + end + end. + +status_reason(200) -> <<"OK">>; +status_reason(201) -> <<"Created">>; +status_reason(204) -> <<"No Content">>; +status_reason(301) -> <<"Moved Permanently">>; +status_reason(302) -> <<"Found">>; +status_reason(303) -> <<"See Other">>; +status_reason(307) -> <<"Temporary Redirect">>; +status_reason(308) -> <<"Permanent Redirect">>; +status_reason(400) -> <<"Bad Request">>; +status_reason(401) -> <<"Unauthorized">>; +status_reason(403) -> <<"Forbidden">>; +status_reason(404) -> <<"Not Found">>; +status_reason(422) -> <<"Unprocessable Entity">>; +status_reason(429) -> <<"Too Many Requests">>; +status_reason(500) -> <<"Internal Server Error">>; +status_reason(502) -> <<"Bad Gateway">>; +status_reason(503) -> <<"Service Unavailable">>; +status_reason(_) -> <<"Unknown">>. + +%% ============================================================================ +%% Method conversion +%% ============================================================================ + +to_method_atom(Method) when is_atom(Method) -> Method; +to_method_atom(Method) when is_binary(Method) -> + case string:lowercase(binary_to_list(Method)) of + "get" -> get; + "post" -> post; + "put" -> put; + "delete" -> delete; + "patch" -> patch; + "head" -> head; + "options" -> options; + Other -> list_to_atom(Other) + end. + +%% ============================================================================ +%% Header conversion +%% ============================================================================ + +to_gun_headers(Hs) when is_list(Hs) -> + lists:map(fun({K, V}) -> {to_binary(K), to_binary(V)} end, Hs); +to_gun_headers(Other) -> + Other. + +maybe_add_accept_encoding(Headers) -> + HasAcceptEncoding = lists:any( + fun({K, _V}) -> + string:lowercase(binary_to_list(K)) =:= "accept-encoding" + end, Headers), + case HasAcceptEncoding of + true -> Headers; + false -> Headers ++ [{<<"accept-encoding">>, <<"gzip, deflate">>}] + end. + +%% ============================================================================ +%% Decompression +%% ============================================================================ + +get_content_encoding(Headers) -> + Val = lists:foldl( + fun({K, V}, Acc) -> + case string:lowercase(to_list(K)) of + "content-encoding" -> string:trim(string:lowercase(to_list(V))); + _ -> Acc + end + end, "", Headers), + Val. + +remove_header(Name, Headers) -> + NormName = string:lowercase(to_list(Name)), + lists:filter( + fun({K, _V}) -> string:lowercase(to_list(K)) =/= NormName end, + Headers). + +maybe_decompress_response(Body, Headers) -> + Encoding = get_content_encoding(Headers), + case Encoding of + "gzip" -> + try_decompress(fun() -> zlib:gunzip(iolist_to_binary(Body)) end, + Body, Headers); + "deflate" -> + try_decompress(fun() -> zlib:uncompress(iolist_to_binary(Body)) end, + Body, Headers); + "identity" -> {Body, Headers}; + "" -> {Body, Headers}; + Other -> + io:format("WARNING: unrecognized Content-Encoding, passing through raw bytes: ~s~n", + [to_binary(Other)]), + {Body, Headers} + end. + +try_decompress(DecompressFn, OrigBody, Headers) -> + try + Decompressed = DecompressFn(), + {Decompressed, remove_header("content-encoding", Headers)} + catch + _:_ -> + io:format("WARNING: decompression failed, passing through raw bytes~n"), + {OrigBody, Headers} + end. + +detect_stream_encoding(Headers) -> + Encoding = get_content_encoding(Headers), + case Encoding of + "gzip" -> {gzip, 31}; + "deflate" -> {deflate, 15}; + "" -> none; + "identity" -> none; + Other -> + io:format("WARNING: unrecognized Content-Encoding for stream, passing through raw bytes: ~s~n", + [to_binary(Other)]), + none + end. + +init_zlib_context(WindowBits) -> + Z = zlib:open(), + ok = zlib:inflateInit(Z, WindowBits), + Z. + +maybe_init_stream_zlib(Headers) -> + case detect_stream_encoding(Headers) of + {_Enc, WindowBits} -> init_zlib_context(WindowBits); + none -> undefined + end. + +decompress_chunk(ZlibCtx, Chunk) -> + iolist_to_binary(zlib:inflate(ZlibCtx, Chunk)). + +cleanup_zlib(undefined) -> ok; +cleanup_zlib(ZlibCtx) -> + try zlib:inflateEnd(ZlibCtx) catch _:_ -> ok end, + try zlib:close(ZlibCtx) catch _:_ -> ok end, + ok. + +%% ============================================================================ +%% Stale connection detection +%% ============================================================================ + +is_stale_connection_error({stream_error, closed}) -> true; +is_stale_connection_error({stream_error, {goaway, _, _, _}}) -> true; +is_stale_connection_error({closed, _}) -> true; +is_stale_connection_error(closed) -> true; +is_stale_connection_error(_) -> false. + +%% ============================================================================ +%% Error formatting +%% ============================================================================ + +format_error(Reason) -> + ensure_utf8_binary(io_lib:format("~p", [Reason])). + +format_connection_error(econnrefused) -> <<"econnrefused">>; +format_connection_error(connect_timeout) -> <<"connect_timeout">>; +format_connection_error(timeout) -> <<"connect_timeout">>; +format_connection_error(nxdomain) -> <<"nxdomain">>; +format_connection_error(Reason) -> + ensure_utf8_binary(io_lib:format("~p", [Reason])). + +format_exit_reason({stream_start_failed, Error}) -> + ensure_binary(Error); +format_exit_reason(normal) -> + <<"Stream process exited normally">>; +format_exit_reason(Reason) -> + ensure_utf8_binary(io_lib:format("Stream process died: ~p", [Reason])). + +%% ============================================================================ +%% Binary/string conversion +%% ============================================================================ + +to_binary(Bin) when is_binary(Bin) -> Bin; +to_binary(List) when is_list(List) -> + unicode:characters_to_binary(List); +to_binary(Other) -> + ensure_utf8_binary(io_lib:format("~p", [Other])). + +to_list(S) when is_binary(S) -> unicode:characters_to_list(S); +to_list(S) when is_list(S) -> S; +to_list(Other) -> io_lib:format("~p", [Other]). + +ensure_utf8_binary(Bin) when is_binary(Bin) -> + case unicode:characters_to_binary(Bin, utf8, utf8) of + Result when is_binary(Result) -> Result; + _ -> + case unicode:characters_to_binary(Bin, latin1, utf8) of + Result2 when is_binary(Result2) -> Result2; + _ -> iolist_to_binary(io_lib:format("~w", [Bin])) + end + end; +ensure_utf8_binary(List) when is_list(List) -> + case unicode:characters_to_binary(List) of + Result when is_binary(Result) -> Result; + _ -> iolist_to_binary(io_lib:format("~w", [List])) + end; +ensure_utf8_binary(Other) -> + iolist_to_binary(io_lib:format("~w", [Other])). + +%% ============================================================================ +%% ETS Functions +%% ============================================================================ + +ets_table_exists(Name) -> + try + NameAtom = binary_to_atom(Name, utf8), + case ets:info(NameAtom) of + undefined -> false; + _ -> true + end + catch + error:badarg -> false + end. + +ets_new(Name, Options) -> + NameAtom = binary_to_atom(Name, utf8), + ets:new(NameAtom, Options). + +ets_insert(TableName, Key, Recorder, RecordedRequest, Headers, Chunks, LastChunkTime) -> + TableAtom = binary_to_atom(TableName, utf8), + Value = {Recorder, RecordedRequest, Headers, Chunks, LastChunkTime}, + ets:insert(TableAtom, {Key, Value}), + nil. + +ets_lookup(TableName, Key) -> + try + TableAtom = binary_to_atom(TableName, utf8), + case ets:lookup(TableAtom, Key) of + [{Key, {Recorder, RecordedRequest, Headers, Chunks, LastChunkTime}}] -> + State = {message_stream_recorder_state, + Recorder, RecordedRequest, Headers, Chunks, LastChunkTime}, + {some, State}; + [] -> none + end + catch + error:badarg -> none + end. + +ets_delete(TableName, Key) -> + try + TableAtom = binary_to_atom(TableName, utf8), + ets:delete(TableAtom, Key) + catch + error:badarg -> false + end. + +%% ============================================================================ +%% Request ID Mapping +%% ============================================================================ + +store_ref_mapping(StringId, Ref) -> + ets:insert(?REF_MAPPING_TABLE, {StringId, Ref}), + ets:insert(?REF_MAPPING_TABLE, {Ref, StringId}), + ok. + +lookup_ref_by_string(StringId) -> + case ets:lookup(?REF_MAPPING_TABLE, StringId) of + [{StringId, Ref}] -> {some, Ref}; + [] -> none + end. + +remove_ref_mapping(StringId) -> + case lookup_ref_by_string(StringId) of + {some, Ref} -> + ets:delete(?REF_MAPPING_TABLE, StringId), + ets:delete(?REF_MAPPING_TABLE, Ref), + ets:delete(?REF_MAPPING_TABLE, {cancel, StringId}), + ok; + none -> + ok + end. + diff --git a/modules/http_client/src/dream_http_client/dream_httpc_shim.erl b/modules/http_client/src/dream_http_client/dream_httpc_shim.erl deleted file mode 100644 index 124187d..0000000 --- a/modules/http_client/src/dream_http_client/dream_httpc_shim.erl +++ /dev/null @@ -1,1177 +0,0 @@ --module(dream_httpc_shim). - --export([request_stream/8, fetch_next/2, fetch_start_headers/2, request_stream_messages/8, - cancel_stream/1, cancel_stream_by_string/1, receive_stream_message/1, - decode_stream_message_for_selector/1, normalize_headers/1, request_sync/7, - configure_transport/4, - ets_table_exists/1, ets_new/2, ets_insert/7, ets_lookup/2, ets_delete/2]). - -%% @doc Start a streaming HTTP request with pull-based chunk retrieval -%% -%% Initiates a streaming HTTP request using Erlang's `httpc` library in continuous -%% streaming mode. Creates an owner process that manages the stream and services -%% `fetch_next` requests. This function returns immediately; chunks are retrieved -%% by calling `fetch_next` with the returned owner PID. -%% -%% ## Parameters -%% -%% - `Method`: HTTP method atom (`get`, `post`, `put`, `delete`, `patch`, `head`, etc.) -%% - `Url`: Full request URL as a string (e.g., `"https://api.example.com/path"`) -%% - `Headers`: List of `{Key, Value}` tuples where both are strings or binaries -%% - `Body`: Request body as a binary (empty binary `<<>>` for requests without body) -%% - `Receiver`: Process ID (unused, kept for API compatibility) -%% - `TimeoutMs`: Request timeout in milliseconds -%% -%% ## Returns -%% -%% `{ok, OwnerPid}` where `OwnerPid` is the process handling the stream. Use this -%% PID with `fetch_next` to retrieve chunks. -%% -%% ## Examples -%% -%% ```erlang -%% {ok, Owner} = request_stream(get, "https://api.example.com/data", [], <<>>, self(), 30000), -%% {chunk, Data} = fetch_next(Owner, 5000), -%% ``` -%% -%% ## Notes -%% -%% - Returns immediately; HTTP errors are detected asynchronously via `fetch_next` -%% - The owner process will exit if the HTTP request fails to start -%% - `fetch_next` will detect the dead process and return an error -%% - Ensures `ssl` and `inets` applications are started before making requests -%% - Configures httpc with streaming-optimized settings (no pipelining, high session cap) -request_stream(Method, Url, Headers, Body, _Receiver, TimeoutMs, ConnectTimeoutMs, AutoRedirect) -> - ok = ensure_started(ssl), - ok = ensure_started(inets), - ok = configure_httpc(), - - NUrl = to_list(Url), - NHeaders = maybe_add_accept_encoding(to_headers(Headers)), - Req = build_req(NUrl, NHeaders, Body), - Owner = spawn(fun() -> stream_owner_loop(Method, Req, NUrl, TimeoutMs, - ConnectTimeoutMs, AutoRedirect) end), - {ok, Owner}. - -%% @doc Fetch the next chunk from a streaming HTTP request -%% -%% Retrieves the next chunk of data from an active streaming request. This function -%% implements a pull-based model where chunks are requested on-demand rather than -%% being pushed to a mailbox. The owner process buffers chunks internally and delivers -%% them when requested. -%% -%% ## Parameters -%% -%% - `OwnerPid`: The owner process PID returned from `request_stream` -%% - `TimeoutMs`: Timeout in milliseconds (0 for non-blocking, -1 for infinite wait) -%% -%% ## Returns -%% -%% - `{chunk, Bin}`: Next chunk of response data as a binary -%% - `{finished, Headers}`: Stream completed successfully with trailing headers -%% - `{error, Reason}`: Error occurred (connection failure, timeout, owner process died, etc.) -%% -%% ## Examples -%% -%% ```erlang -%% {ok, Owner} = request_stream(get, "https://api.example.com/stream", [], <<>>, self(), 30000), -%% case fetch_next(Owner, 5000) of -%% {chunk, Data} -> process_chunk(Data); -%% {finished, Headers} -> process_complete(Headers); -%% {error, Reason} -> handle_error(Reason) -%% end. -%% ``` -%% -%% ## Notes -%% -%% - Blocks until a chunk is available, timeout expires, or an error occurs -%% - Monitors the owner process; returns error if owner dies -%% - Owner process buffers chunks internally for efficient delivery -%% - After `{finished, Headers}` or `{error, Reason}`, the stream is complete -%% - Timeout errors are returned as `{error, timeout}` -fetch_next(OwnerPid, TimeoutMs) -> - MonitorRef = erlang:monitor(process, OwnerPid), - OwnerPid ! {fetch_next, self()}, - receive - {stream_chunk, Bin} -> - erlang:demonitor(MonitorRef, [flush]), - {chunk, Bin}; - {stream_end, Headers} -> - erlang:demonitor(MonitorRef, [flush]), - {finished, Headers}; - {stream_error, Reason} -> - erlang:demonitor(MonitorRef, [flush]), - {error, Reason}; - {'DOWN', MonitorRef, process, OwnerPid, Reason} -> - %% Owner process died - extract the real error - {error, format_exit_reason(Reason)} - after TimeoutMs -> - erlang:demonitor(MonitorRef, [flush]), - {error, timeout} - end. - -%% @doc Fetch the response headers from stream_start -%% -%% Returns the normalized headers received in the initial `stream_start` message. -%% This is used by the recorder to persist response headers for streaming recordings. -%% -%% Note: httpc's streamed response status code is not included in stream_start. -fetch_start_headers(OwnerPid, TimeoutMs) -> - MonitorRef = erlang:monitor(process, OwnerPid), - OwnerPid ! {fetch_start_headers, self()}, - receive - {stream_start_headers, Headers} -> - erlang:demonitor(MonitorRef, [flush]), - {ok, Headers}; - {'DOWN', MonitorRef, process, OwnerPid, Reason} -> - {error, format_exit_reason(Reason)} - after TimeoutMs -> - erlang:demonitor(MonitorRef, [flush]), - {error, timeout} - end. - -%% Stream owner process: starts httpc in continuous mode and services fetch_next requests -stream_owner_loop(Method, Req, _Url, TimeoutMs, ConnectTimeoutMs, AutoRedirect) -> - HttpOpts = [{timeout, TimeoutMs}, {connect_timeout, ConnectTimeoutMs}, - {autoredirect, AutoRedirect}], - Opts = [{stream, self}, {sync, false}], - case httpc:request(Method, Req, HttpOpts, Opts) of - {ok, RequestId} -> - stream_owner_wait(RequestId, [], undefined, [], undefined); - Error -> - exit({stream_start_failed, Error}) - end. - -%% Wait for either a fetch_next request or internal http messages (buffered) -%% State: -%% Buffer - queued {chunk, Bin}/{finished, Headers}/{error, Reason} -%% StartHeaders - normalized headers from stream_start (or undefined) -%% StartWaiters - callers waiting for stream_start headers -%% ZlibCtx - zlib inflate context for decompression (undefined if none) -stream_owner_wait(RequestId, Buffer, StartHeaders, StartWaiters, ZlibCtx) -> - receive - {fetch_next, From} -> - handle_fetch_next(From, RequestId, Buffer, StartHeaders, StartWaiters, ZlibCtx); - {fetch_start_headers, From} -> - case StartHeaders of - undefined -> - stream_owner_wait(RequestId, Buffer, StartHeaders, [From | StartWaiters], ZlibCtx); - _ -> - From ! {stream_start_headers, normalize_headers_default(StartHeaders)}, - stream_owner_wait(RequestId, Buffer, StartHeaders, StartWaiters, ZlibCtx) - end; - {http, {RequestId, stream, Bin}} -> - DecompBin = case ZlibCtx of - undefined -> Bin; - _ -> decompress_chunk(ZlibCtx, Bin) - end, - stream_owner_wait(RequestId, Buffer ++ [{chunk, DecompBin}], StartHeaders, StartWaiters, ZlibCtx); - {http, {RequestId, stream_start, Headers}} -> - Norm = normalize_headers(Headers), - lists:foreach(fun(W) -> W ! {stream_start_headers, Norm} end, StartWaiters), - NewZlib = maybe_init_stream_zlib(Headers), - stream_owner_wait(RequestId, Buffer, Norm, [], NewZlib); - {http, {RequestId, stream_start, Headers, _Pid}} -> - Norm = normalize_headers(Headers), - lists:foreach(fun(W) -> W ! {stream_start_headers, Norm} end, StartWaiters), - NewZlib = maybe_init_stream_zlib(Headers), - stream_owner_wait(RequestId, Buffer, Norm, [], NewZlib); - {http, {RequestId, stream_end, Headers}} -> - cleanup_zlib(ZlibCtx), - stream_owner_wait(RequestId, - Buffer ++ [{finished, normalize_headers(Headers)}], - StartHeaders, - StartWaiters, - undefined); - {http, {RequestId, {error, Reason}}} -> - cleanup_zlib(ZlibCtx), - stream_owner_wait(RequestId, Buffer ++ [{error, format_error(Reason)}], StartHeaders, StartWaiters, undefined); - {http, {RequestId, {{_HttpVersion, StatusCode, ReasonPhrase}, _Headers, Body}}} -> - cleanup_zlib(ZlibCtx), - ErrorMsg = format_complete_response_error(StatusCode, ReasonPhrase, Body), - stream_owner_wait(RequestId, Buffer ++ [{error, ErrorMsg}], StartHeaders, StartWaiters, undefined); - _Other -> - stream_owner_wait(RequestId, Buffer, StartHeaders, StartWaiters, ZlibCtx) - end. - -%% Handle a fetch_next request from the client -handle_fetch_next(From, RequestId, [], StartHeaders, StartWaiters, ZlibCtx) -> - %% Buffer empty - fetch next message from stream - case stream_owner_next_message(RequestId, ZlibCtx) of - {start, _Hs, NewZlib} -> - Norm = normalize_headers(_Hs), - lists:foreach(fun(W) -> W ! {stream_start_headers, Norm} end, StartWaiters), - handle_fetch_next_after_start(From, RequestId, Norm, [], NewZlib); - {Msg, NewZlib} -> - deliver_message(From, Msg, RequestId, StartHeaders, StartWaiters, NewZlib) - end; -handle_fetch_next(From, RequestId, [Item | Rest], StartHeaders, StartWaiters, ZlibCtx) -> - deliver_message(From, Item, RequestId, Rest, StartHeaders, StartWaiters, ZlibCtx). - -%% Handle fetch_next after receiving stream_start (headers) -handle_fetch_next_after_start(From, RequestId, StartHeaders, StartWaiters, ZlibCtx) -> - case stream_owner_next_message(RequestId, ZlibCtx) of - {{chunk, Bin}, NewZlib} -> - From ! {stream_chunk, Bin}, - stream_owner_wait(RequestId, [], StartHeaders, StartWaiters, NewZlib); - {{finished, Headers}, _NewZlib} -> - From ! {stream_end, Headers}, - ok; - {{error, Reason}, _NewZlib} -> - From ! {stream_error, Reason}, - ok - end. - -%% Deliver a message to the client (from live stream, with ZlibCtx) -deliver_message(From, {chunk, Bin}, RequestId, StartHeaders, StartWaiters, ZlibCtx) -> - From ! {stream_chunk, Bin}, - stream_owner_wait(RequestId, [], StartHeaders, StartWaiters, ZlibCtx); -deliver_message(From, {finished, Headers}, _RequestId, _StartHeaders, _StartWaiters, _ZlibCtx) -> - From ! {stream_end, Headers}, - ok; -deliver_message(From, {error, Reason}, _RequestId, _StartHeaders, _StartWaiters, _ZlibCtx) -> - From ! {stream_error, Reason}, - ok. - -%% Deliver a message to the client (from buffer, with ZlibCtx) -deliver_message(From, {chunk, Bin}, RequestId, Rest, StartHeaders, StartWaiters, ZlibCtx) -> - From ! {stream_chunk, Bin}, - stream_owner_wait(RequestId, Rest, StartHeaders, StartWaiters, ZlibCtx); -deliver_message(From, - {finished, Headers}, - _RequestId, - _Rest, - _StartHeaders, - _StartWaiters, - _ZlibCtx) -> - From ! {stream_end, Headers}, - ok; -deliver_message(From, {error, Reason}, _RequestId, _Rest, _StartHeaders, _StartWaiters, _ZlibCtx) -> - From ! {stream_error, Reason}, - ok. - -normalize_headers_default(undefined) -> - []; -normalize_headers_default(Headers) -> - Headers. - -%% Wait for the next HTTP message from httpc. -%% Returns {start, Headers, NewZlibCtx} | {{chunk|finished|error, Data}, NewZlibCtx} -stream_owner_next_message(RequestId, ZlibCtx) -> - receive - {http, {RequestId, stream_start, Headers}} -> - NewZlib = maybe_init_stream_zlib(Headers), - {start, Headers, NewZlib}; - {http, {RequestId, stream_start, Headers, _Pid}} -> - NewZlib = maybe_init_stream_zlib(Headers), - {start, Headers, NewZlib}; - {http, {RequestId, stream, Bin}} -> - DecompBin = case ZlibCtx of - undefined -> Bin; - _ -> decompress_chunk(ZlibCtx, Bin) - end, - {{chunk, DecompBin}, ZlibCtx}; - {http, {RequestId, stream_end, Headers}} -> - cleanup_zlib(ZlibCtx), - {{finished, normalize_headers(Headers)}, undefined}; - {http, {RequestId, {error, Reason}}} -> - cleanup_zlib(ZlibCtx), - {{error, format_error(Reason)}, undefined}; - {http, {RequestId, {{_HttpVersion, StatusCode, ReasonPhrase}, _Headers, Body}}} -> - cleanup_zlib(ZlibCtx), - {{error, format_complete_response_error(StatusCode, ReasonPhrase, Body)}, undefined}; - _Other -> - stream_owner_next_message(RequestId, ZlibCtx) - end. - -%% Ensure an Erlang application is started -ensure_started(App) -> - case application:ensure_all_started(App) of - {ok, _} -> - ok; - {error, {already_started, _}} -> - ok; - {error, _Reason} -> - ok - end. - -%% Configure httpc with appropriate settings for streaming. -%% Reads transport config from ETS if configure_transport/4 has been called, -%% otherwise uses defaults. -configure_httpc() -> - Config = case ets:lookup(dream_http_client_transport_config, config) of - [{config, MaxS, MaxP, KeepT, MaxK}] -> - [{max_sessions, MaxS}, {max_pipeline_length, MaxP}, - {keep_alive_timeout, KeepT}, {max_keep_alive_length, MaxK}]; - [] -> - [{max_sessions, 100}, {max_pipeline_length, 0}, - {keep_alive_timeout, 60000}, {max_keep_alive_length, 100}] - end, - ok = httpc:set_options(Config, default), - ok. - -configure_transport(MaxSessions, MaxPipelineLength, KeepAliveTimeout, MaxKeepAliveLength) -> - ets:insert(dream_http_client_transport_config, - {config, MaxSessions, MaxPipelineLength, KeepAliveTimeout, MaxKeepAliveLength}), - ok = httpc:set_options([ - {max_sessions, MaxSessions}, {max_pipeline_length, MaxPipelineLength}, - {keep_alive_timeout, KeepAliveTimeout}, {max_keep_alive_length, MaxKeepAliveLength} - ], default), - nil. - -%% Convert various types to string lists -to_list(S) when is_binary(S) -> - unicode:characters_to_list(S); -to_list(S) when is_list(S) -> - S; -to_list(Other) -> - io_lib:format("~p", [Other]). - -%% Convert headers to the format expected by httpc -to_headers(Hs) when is_list(Hs) -> - lists:map(fun({K, V}) -> {to_list(K), to_list(V)} end, Hs); -to_headers(Other) -> - Other. - -%% Inject Accept-Encoding: gzip, deflate unless the user already set one -maybe_add_accept_encoding(Headers) -> - HasAcceptEncoding = lists:any( - fun({K, _V}) -> - string:lowercase(to_list(K)) =:= "accept-encoding" - end, Headers), - case HasAcceptEncoding of - true -> Headers; - false -> Headers ++ [{"Accept-Encoding", "gzip, deflate"}] - end. - -%% Get Content-Encoding header value (lowercase, trimmed) -get_content_encoding(Headers) -> - Val = lists:foldl( - fun({K, V}, Acc) -> - case string:lowercase(to_list(K)) of - "content-encoding" -> string:trim(string:lowercase(to_list(V))); - _ -> Acc - end - end, "", Headers), - Val. - -%% Remove a header by name (case-insensitive) -remove_header(Name, Headers) -> - NormName = string:lowercase(Name), - lists:filter( - fun({K, _V}) -> string:lowercase(to_list(K)) =/= NormName end, - Headers). - -%% Decompress a sync response body based on Content-Encoding. -%% Returns {DecompressedBody, CleanedHeaders}. -maybe_decompress_response(Body, Headers) -> - Encoding = get_content_encoding(Headers), - case Encoding of - "gzip" -> - try_decompress(fun() -> zlib:gunzip(iolist_to_binary(Body)) end, - Body, Headers); - "deflate" -> - try_decompress(fun() -> zlib:uncompress(iolist_to_binary(Body)) end, - Body, Headers); - "identity" -> - {Body, Headers}; - "" -> - {Body, Headers}; - Other -> - io:format("WARNING: unrecognized Content-Encoding, passing through raw bytes: ~s~n", - [to_binary(Other)]), - {Body, Headers} - end. - -try_decompress(DecompressFn, OrigBody, Headers) -> - try - Decompressed = DecompressFn(), - {Decompressed, remove_header("content-encoding", Headers)} - catch - _:_ -> - io:format("WARNING: decompression failed, passing through raw bytes~n"), - {OrigBody, Headers} - end. - -%% Detect stream encoding from headers. -%% Returns {gzip, 31} | {deflate, 15} | none -detect_stream_encoding(Headers) -> - Encoding = get_content_encoding(Headers), - case Encoding of - "gzip" -> {gzip, 31}; - "deflate" -> {deflate, 15}; - "" -> none; - "identity" -> none; - Other -> - io:format("WARNING: unrecognized Content-Encoding for stream, passing through raw bytes: ~s~n", - [to_binary(Other)]), - none - end. - -%% Initialize a zlib inflate context for streaming decompression -init_zlib_context(WindowBits) -> - Z = zlib:open(), - ok = zlib:inflateInit(Z, WindowBits), - Z. - -%% Initialize streaming zlib context from headers if Content-Encoding is gzip/deflate -maybe_init_stream_zlib(Headers) -> - case detect_stream_encoding(Headers) of - {_Enc, WindowBits} -> init_zlib_context(WindowBits); - none -> undefined - end. - -%% Decompress a chunk using an existing zlib context -decompress_chunk(ZlibCtx, Chunk) -> - iolist_to_binary(zlib:inflate(ZlibCtx, Chunk)). - -%% Clean up a zlib context -cleanup_zlib(undefined) -> ok; -cleanup_zlib(ZlibCtx) -> - try zlib:inflateEnd(ZlibCtx) catch _:_ -> ok end, - try zlib:close(ZlibCtx) catch _:_ -> ok end, - ok. - -%% Extract Content-Type header value (case-insensitive) and strip it from headers. -%% -%% httpc's request tuple for entity-body requests is `{Url, Headers, ContentType, Body}`. -%% If we leave a `content-type` header in `Headers` while also providing `ContentType`, -%% we can end up sending duplicate/conflicting headers. We therefore: -%% - Prefer an explicitly provided Content-Type header value (last one wins) -%% - Remove all Content-Type headers from the outgoing Headers list -extract_content_type_and_strip_headers(Headers) when is_list(Headers) -> - {ContentType, RevHeaders} = - lists:foldl(fun({K, V}, {Ct0, Acc}) -> - KeyLower = string:lowercase(to_list(K)), - case KeyLower of - "content-type" -> {to_list(V), Acc}; - _ -> {Ct0, [{to_list(K), to_list(V)} | Acc]} - end - end, - {undefined, []}, - Headers), - {ContentType, lists:reverse(RevHeaders)}; -extract_content_type_and_strip_headers(Other) -> - {undefined, Other}. - -%% Build the request tuple for httpc -build_req(Url, Headers, Body) when is_binary(Body), byte_size(Body) =:= 0 -> - {Url, Headers}; -build_req(Url, Headers, Body) when Body =:= undefined; Body =:= <<>> -> - {Url, Headers}; -build_req(Url, Headers, Body) -> - {HeaderContentType, StrippedHeaders} = extract_content_type_and_strip_headers(Headers), - ContentType = - case HeaderContentType of - undefined -> - to_list("application/octet-stream"); - _ -> - HeaderContentType - end, - {Url, StrippedHeaders, ContentType, Body}. - -%% ============================================================================ -%% Message-Based Streaming (Thin Wrapper) -%% ============================================================================ - -%% @doc Start a message-based streaming HTTP request -%% -%% Initiates a streaming HTTP request where messages are sent directly to the caller's -%% process mailbox. This is used for OTP actor integration where messages arrive -%% asynchronously without needing to call `fetch_next`. The request ID is returned -%% as a string for type-safe handling in Gleam. -%% -%% ## Parameters -%% -%% - `Method`: HTTP method atom (`get`, `post`, `put`, `delete`, etc.) -%% - `Url`: Full request URL as a string -%% - `Headers`: List of `{Key, Value}` tuples (both strings or binaries) -%% - `Body`: Request body as a binary -%% - `ReceiverPid`: Process ID that will receive stream messages (unused, kept for compatibility) -%% - `TimeoutMs`: Request timeout in milliseconds -%% -%% ## Returns -%% -%% - `{ok, StringId}`: Stream started successfully, `StringId` is a string representation -%% of the httpc request ID (use with `cancel_stream_by_string`) -%% - `{error, Reason}`: Failed to start stream (connection error, invalid URL, etc.) -%% -%% ## Examples -%% -%% ```erlang -%% {ok, ReqId} = request_stream_messages(get, "https://api.example.com/stream", [], <<>>, self(), 30000), -%% %% Messages will arrive as {http, {HttpcRef, stream_start, Headers}}, etc. -%% ``` -%% -%% ## Notes -%% -%% - Messages arrive as `{http, {HttpcRef, Tag, Data}}` tuples in the process mailbox -%% - Use `decode_stream_message_for_selector` for OTP selector integration -%% - Stores bidirectional mapping: `StringId <-> HttpcRef` for cancellation -%% - String ID is derived from httpc ref's string representation (guaranteed unique) -%% - Ensures `ssl` and `inets` applications are started before making requests -request_stream_messages(Method, Url, Headers, Body, _ReceiverPid, TimeoutMs, - ConnectTimeoutMs, AutoRedirect) -> - ok = ensure_started(ssl), - ok = ensure_started(inets), - ok = configure_httpc(), - - NUrl = to_list(Url), - NHeaders = maybe_add_accept_encoding(to_headers(Headers)), - Req = build_req(NUrl, NHeaders, Body), - - HttpOpts = [{timeout, TimeoutMs}, {connect_timeout, ConnectTimeoutMs}, - {autoredirect, AutoRedirect}], - StreamOpts = [{stream, self}, {sync, false}], - - case httpc:request(Method, Req, HttpOpts, StreamOpts) of - {ok, HttpcRef} -> - %% Convert ref to string for type-safe Gleam API - RefString = ref_to_string(HttpcRef), - %% Store mapping for cancellation - store_ref_mapping(RefString, HttpcRef), - {ok, RefString}; - {error, Reason} -> - {error, format_error(Reason)} - end. - -%% @doc Cancel a streaming request using httpc ref directly -%% -%% Cancels an active streaming HTTP request using the httpc request reference directly. -%% This is a legacy function that takes the raw httpc ref. For new code, use -%% `cancel_stream_by_string` which works with the type-safe string IDs. -%% -%% ## Parameters -%% -%% - `RequestId`: The httpc request reference (returned from `httpc:request/4`) -%% -%% ## Returns -%% -%% `ok` - Always returns successfully (even if request doesn't exist) -%% -%% ## Notes -%% -%% - This function is kept for backward compatibility -%% - Prefer `cancel_stream_by_string` for type-safe cancellation -%% - After cancellation, no more messages will be sent to the receiver process -%% - Safe to call multiple times on the same request ID -cancel_stream(RequestId) -> - httpc:cancel_request(RequestId), - ok. - -%% @doc Cancel a streaming request by string ID -%% -%% Cancels an active streaming HTTP request using the string ID returned from -%% `request_stream_messages`. Looks up the corresponding httpc reference from -%% the internal mapping table and cancels the request. -%% -%% ## Parameters -%% -%% - `StringId`: The string request ID returned from `request_stream_messages` -%% -%% ## Returns -%% -%% `nil` - Always returns successfully (even if request doesn't exist or already ended) -%% -%% ## Examples -%% -%% ```erlang -%% {ok, ReqId} = request_stream_messages(get, "https://api.example.com/stream", [], <<>>, self(), 30000), -%% %% Later, cancel the stream -%% cancel_stream_by_string(ReqId). -%% ``` -%% -%% ## Notes -%% -%% - Uses internal ETS table to map string IDs to httpc refs -%% - Returns `nil` if the request ID is not found (stream already ended or never existed) -%% - After cancellation, no more messages will be sent to the receiver process -%% - Safe to call multiple times on the same request ID -%% - Mapping is cleaned up automatically when stream ends normally or errors -cancel_stream_by_string(StringId) -> - case lookup_ref_by_string(StringId) of - {some, HttpcRef} -> - httpc:cancel_request(HttpcRef), - remove_ref_mapping(StringId), - nil; - none -> - %% Ref not found - stream already ended or never existed - nil - end. - -%% @doc Receive and decode the next stream message from process mailbox -%% -%% Blocks waiting for an httpc stream message in the process mailbox and returns -%% a normalized tuple format that Gleam can easily decode. This is a helper function -%% for non-selector use cases where you want to receive messages directly from the -%% mailbox rather than using OTP selectors. -%% -%% ## Parameters -%% -%% - `TimeoutMs`: Timeout in milliseconds (0 for non-blocking, -1 for infinite wait) -%% -%% ## Returns -%% -%% - `{stream_start, RequestId, Headers}`: Stream started, initial headers received -%% - `{chunk, RequestId, Data}`: Data chunk received (binary) -%% - `{stream_end, RequestId, Headers}`: Stream completed successfully with trailing headers -%% - `{stream_error, RequestId, Reason}`: Stream failed with error (binary error message) -%% - `timeout`: No message received within the timeout period -%% -%% ## Examples -%% -%% ```erlang -%% {ok, _ReqId} = request_stream_messages(get, "https://api.example.com/stream", [], <<>>, self(), 30000), -%% case receive_stream_message(5000) of -%% {stream_start, ReqId, Headers} -> process_start(ReqId, Headers); -%% {chunk, ReqId, Data} -> process_chunk(ReqId, Data); -%% {stream_end, ReqId, Headers} -> process_end(ReqId, Headers); -%% {stream_error, ReqId, Reason} -> handle_error(ReqId, Reason); -%% timeout -> handle_timeout() -%% end. -%% ``` -%% -%% ## Notes -%% -%% - Blocks until a message arrives or timeout expires -%% - Headers are normalized to binary tuples for consistent Gleam decoding -%% - RequestId is the httpc reference (use `decode_stream_message_for_selector` for string IDs) -%% - Handles both `{http, {Ref, stream_start, Headers}}` and `{http, {Ref, stream_start, Headers, Pid}}` formats -%% - Error reasons are formatted as binaries for Gleam compatibility -receive_stream_message(TimeoutMs) -> - receive - {http, {RequestId, stream_start, Headers}} -> - StringId = get_or_create_string_id(RequestId), - maybe_store_stream_zlib(StringId, Headers), - {stream_start, RequestId, normalize_headers(Headers)}; - {http, {RequestId, stream_start, Headers, _Pid}} -> - StringId = get_or_create_string_id(RequestId), - maybe_store_stream_zlib(StringId, Headers), - {stream_start, RequestId, normalize_headers(Headers)}; - {http, {RequestId, stream, Data}} -> - StringId = get_or_create_string_id(RequestId), - DecompData = maybe_decompress_stream_chunk(StringId, Data), - {chunk, RequestId, DecompData}; - {http, {RequestId, stream_end, Headers}} -> - StringId = get_or_create_string_id(RequestId), - cleanup_stream_zlib(StringId), - {stream_end, RequestId, normalize_headers(Headers)}; - {http, {RequestId, {error, Reason}}} -> - StringId = get_or_create_string_id(RequestId), - cleanup_stream_zlib(StringId), - {stream_error, RequestId, format_error(Reason)}; - {http, {RequestId, {{_HttpVersion, StatusCode, ReasonPhrase}, _Headers, Body}}} -> - StringId = get_or_create_string_id(RequestId), - cleanup_stream_zlib(StringId), - {stream_error, RequestId, format_complete_response_error(StatusCode, ReasonPhrase, Body)} - after TimeoutMs -> - timeout - end. - -%% @doc Decode an httpc stream message for OTP selector integration -%% -%% Processes raw httpc stream messages extracted by OTP selectors and converts them -%% to a normalized format suitable for Gleam decoding. Converts httpc references to -%% string IDs for type-safe handling in Gleam, normalizes headers to binary tuples, -%% and formats error messages as binaries. -%% -%% ## Parameters -%% -%% - `{http, InnerMessage}`: The message tuple extracted by `process:select_record/4` -%% where `InnerMessage` is the inner tuple from httpc (e.g., `{Ref, stream_start, Headers}`) -%% -%% ## Returns -%% -%% A normalized tuple `{Tag, StringId, Data}` where: -%% - `Tag`: Atom (`stream_start`, `chunk`, `stream_end`, `stream_error`) -%% - `StringId`: String representation of the httpc request ID (for type-safe Gleam API) -%% - `Data`: Varies by tag: -%% - `stream_start`: Normalized headers (list of `{Binary, Binary}` tuples) -%% - `chunk`: Binary data -%% - `stream_end`: Normalized trailing headers -%% - `stream_error`: Binary error message -%% -%% ## Examples -%% -%% ```erlang -%% %% In selector callback -%% case decode_stream_message_for_selector({http, {Ref, stream_start, Headers}}) of -%% {stream_start, StringId, NormalizedHeaders} -> process_start(StringId, NormalizedHeaders); -%% {chunk, StringId, Data} -> process_chunk(StringId, Data); -%% {stream_end, StringId, Headers} -> process_end(StringId, Headers); -%% {stream_error, StringId, Reason} -> handle_error(StringId, Reason) -%% end. -%% ``` -%% -%% ## Notes -%% -%% - Used internally by `client.select_stream_messages()` for selector integration -%% - Creates string ID mapping if it doesn't exist (handles messages arriving before mapping stored) -%% - Cleans up ref mapping when stream ends (`stream_end`) or errors (`stream_error`) -%% - Headers are normalized to binary tuples for consistent Gleam decoding -%% - Handles both `{Ref, stream_start, Headers}` and `{Ref, stream_start, Headers, Pid}` formats -%% - Error reasons are formatted as binaries for Gleam compatibility -decode_stream_message_for_selector({http, InnerMessage}) -> - case InnerMessage of - {HttpcRef, stream_start, Headers} -> - StringId = get_or_create_string_id(HttpcRef), - maybe_store_stream_zlib(StringId, Headers), - {stream_start, StringId, normalize_headers(Headers)}; - {HttpcRef, stream_start, Headers, _Pid} -> - StringId = get_or_create_string_id(HttpcRef), - maybe_store_stream_zlib(StringId, Headers), - {stream_start, StringId, normalize_headers(Headers)}; - {HttpcRef, stream, Data} -> - StringId = get_or_create_string_id(HttpcRef), - DecompData = maybe_decompress_stream_chunk(StringId, Data), - {chunk, StringId, DecompData}; - {HttpcRef, stream_end, Headers} -> - StringId = get_or_create_string_id(HttpcRef), - cleanup_stream_zlib(StringId), - remove_ref_mapping(StringId), - {stream_end, StringId, normalize_headers(Headers)}; - {HttpcRef, {error, Reason}} -> - StringId = get_or_create_string_id(HttpcRef), - cleanup_stream_zlib(StringId), - remove_ref_mapping(StringId), - {stream_error, StringId, format_error(Reason)}; - {HttpcRef, {{_HttpVersion, StatusCode, ReasonPhrase}, _Headers, Body}} -> - StringId = get_or_create_string_id(HttpcRef), - cleanup_stream_zlib(StringId), - remove_ref_mapping(StringId), - {stream_error, StringId, format_complete_response_error(StatusCode, ReasonPhrase, Body)}; - _ -> - error(badarg) - end. - -%% Get string ID for httpc ref, creating mapping if needed. -%% This handles the case where selector receives messages before we stored -%% the mapping. -get_or_create_string_id(HttpcRef) -> - case lookup_string_by_ref(HttpcRef) of - {some, StringId} -> - StringId; - none -> - NewId = ref_to_string(HttpcRef), - store_ref_mapping(NewId, HttpcRef), - NewId - end. - -%% @doc Normalize HTTP headers to binary tuples for Gleam decoding -%% -%% Converts HTTP headers from various formats (charlists, binaries, mixed types) -%% to a consistent format of `{Binary, Binary}` tuples that Gleam can easily decode. -%% This ensures type safety and consistent handling regardless of how httpc returns headers. -%% -%% ## Parameters -%% -%% - `Headers`: List of header tuples in any format (charlists, binaries, mixed) -%% -%% ## Returns -%% -%% List of `{Binary, Binary}` tuples where both name and value are binaries. -%% -%% ## Examples -%% -%% ```erlang -%% Headers = [{"content-type", "application/json"}, {"authorization", <<"Bearer token">>}], -%% Normalized = normalize_headers(Headers), -%% %% Returns: [{<<"content-type">>, <<"application/json">>}, {<<"authorization">>, <<"Bearer token">>}] -%% ``` -%% -%% ## Notes -%% -%% - Converts charlists to binaries using `unicode:characters_to_binary/1` -%% - Leaves binaries unchanged -%% - Converts other types to binaries using `io_lib:format/2` -%% - Returns empty list `[]` if input is not a list -%% - Invalid header tuples are converted to `{<<"">>, <<"">>}` -normalize_headers(Headers) when is_list(Headers) -> - lists:map(fun normalize_header_tuple/1, Headers); -normalize_headers(_) -> - []. - -normalize_header_tuple({Name, Value}) -> - {to_binary(Name), to_binary(Value)}; -normalize_header_tuple(_) -> - {<<"">>, <<"">>}. - -to_binary(Bin) when is_binary(Bin) -> - Bin; -to_binary(List) when is_list(List) -> - unicode:characters_to_binary(List); -to_binary(Other) -> - ensure_utf8_binary(io_lib:format("~p", [Other])). - -%% Guarantee a valid UTF-8 binary from any input. -%% Handles binaries (validates UTF-8, falls back to Latin-1 reinterpretation), -%% charlists from io_lib:format (unicode codepoints), and arbitrary terms. -ensure_utf8_binary(Bin) when is_binary(Bin) -> - case unicode:characters_to_binary(Bin, utf8, utf8) of - Result when is_binary(Result) -> Result; - _ -> - case unicode:characters_to_binary(Bin, latin1, utf8) of - Result2 when is_binary(Result2) -> Result2; - _ -> iolist_to_binary(io_lib:format("~w", [Bin])) - end - end; -ensure_utf8_binary(List) when is_list(List) -> - case unicode:characters_to_binary(List) of - Result when is_binary(Result) -> Result; - _ -> iolist_to_binary(io_lib:format("~w", [List])) - end; -ensure_utf8_binary(Other) -> - iolist_to_binary(io_lib:format("~w", [Other])). - -%% @doc Make a synchronous (blocking) HTTP request -%% -%% Sends an HTTP request and waits for the complete response body. This is the -%% correct way to make non-streaming HTTP requests - it uses httpc's synchronous -%% mode without streaming, which is more efficient than streaming mode for complete -%% responses. -%% -%% ## Parameters -%% -%% - `Method`: HTTP method atom (`get`, `post`, `put`, `delete`, `patch`, `head`, etc.) -%% - `Url`: Full request URL as a string (e.g., `"https://api.example.com/users"`) -%% - `Headers`: List of `{Key, Value}` tuples where both are strings or binaries -%% - `Body`: Request body as a binary (empty binary `<<>>` for requests without body) -%% - `TimeoutMs`: Request timeout in milliseconds -%% - `ConnectTimeoutMs`: TCP connection timeout in milliseconds -%% - `AutoRedirect`: Whether to follow 3xx redirects automatically (boolean) -%% -%% ## Returns -%% -%% - `{ok, {StatusCode, ResponseHeaders, Body}}`: -%% - `StatusCode` is an integer HTTP status code (e.g. 200, 404) -%% - `ResponseHeaders` is a list of `{Name, Value}` tuples as binaries -%% - `Body` is the complete response body as a binary -%% - `{error, Reason}`: Error occurred (connection failure, timeout, etc.) -%% where `Reason` is a binary error message -%% -%% ## Examples -%% -%% ```erlang -%% {ok, {Status, Headers, Body}} = request_sync(get, "https://api.example.com/users", [], <<>>, 30000, 15000, true), -%% ``` -%% -%% ## Notes -%% -%% - Uses httpc's synchronous mode (`{sync, true}`) - blocks until complete response received -%% - Uses `{body_format, binary}` to get response as binary (not parsed) -%% - More efficient than streaming mode for non-streaming use cases -%% - Ensures `ssl` and `inets` applications are started before making requests -%% - Configures httpc with appropriate timeout and redirect settings -%% - Error reasons are formatted as binaries for Gleam compatibility -request_sync(Method, Url, Headers, Body, TimeoutMs, ConnectTimeoutMs, AutoRedirect) -> - ok = ensure_started(ssl), - ok = ensure_started(inets), - ok = configure_httpc(), - - NUrl = to_list(Url), - NHeaders = maybe_add_accept_encoding(to_headers(Headers)), - Req = build_req(NUrl, NHeaders, Body), - - HttpOpts = [{timeout, TimeoutMs}, {connect_timeout, ConnectTimeoutMs}, - {autoredirect, AutoRedirect}], - Opts = [{sync, true}, {body_format, binary}], - - case httpc:request(Method, Req, HttpOpts, Opts) of - {ok, {{_Version, StatusCode, _ReasonPhrase}, ResponseHeaders, ResponseBody}} -> - {DecompressedBody, CleanHeaders} = - maybe_decompress_response(ResponseBody, ResponseHeaders), - {ok, {StatusCode, normalize_headers(CleanHeaders), DecompressedBody}}; - {error, Reason} -> - {error, format_error(Reason)} - end. - -format_error(Reason) -> - ensure_utf8_binary(io_lib:format("~p", [Reason])). - -%% Format error for a complete (non-streaming) HTTP response from httpc. -%% httpc sends this instead of stream_start/stream/stream_end when the upstream -%% returns a non-streaming response (typically 4xx/5xx errors). -format_complete_response_error(StatusCode, ReasonPhrase, Body) -> - <<(<<"HTTP ">>)/binary, - (integer_to_binary(StatusCode))/binary, - (<<" ">>)/binary, - (ensure_utf8_binary(ReasonPhrase))/binary, - (<<": ">>)/binary, - (ensure_utf8_binary(Body))/binary>>. - -%% Format exit reason from owner process death -%% -%% When the owner process dies, we extract the exit reason and format it -%% into a meaningful error message for the user. -format_exit_reason({stream_start_failed, Error}) -> - %% HTTP request failed to start - return the actual httpc error - format_error(Error); -format_exit_reason(normal) -> - %% Normal exit - shouldn't happen in middle of stream - <<"Stream process exited normally">>; -format_exit_reason(Reason) -> - %% Some other exit reason - format it for debugging - ensure_utf8_binary(io_lib:format("Stream process died: ~p", [Reason])). - -%% ============================================================================= -%% ETS Functions for Stream Recorder State Management -%% ============================================================================= - -%% @doc Check if an ETS table exists -%% -%% Determines whether a named ETS table exists by attempting to get its info. -%% Used internally to check if tables need to be created before use. -%% -%% ## Parameters -%% -%% - `Name`: Table name as a binary (will be converted to atom) -%% -%% ## Returns -%% -%% - `true`: Table exists -%% - `false`: Table does not exist or name is invalid -%% -%% ## Notes -%% -%% - Converts binary name to atom using `binary_to_atom` -%% - Returns `false` if conversion fails or table doesn't exist -%% - Used internally for idempotent table creation -ets_table_exists(Name) -> - try - NameAtom = binary_to_atom(Name, utf8), - case ets:info(NameAtom) of - undefined -> - false; - _ -> - true - end - catch - error:badarg -> - false - end. - -%% @doc Create a new ETS table -%% -%% Creates a new ETS (Erlang Term Storage) table with the specified name and options. -%% Used internally for storing stream recorder state and request ID mappings. -%% -%% ## Parameters -%% -%% - `Name`: Table name as a binary (will be converted to atom) -%% - `Options`: List of ETS table options (e.g., `[set, public, named_table]`) -%% -%% ## Returns -%% -%% The table reference (atom or integer) returned by `ets:new/2`. -%% -%% ## Examples -%% -%% ```erlang -%% TableRef = ets_new(<<"my_table">>, [set, public, named_table]). -%% ``` -%% -%% ## Notes -%% -%% - Converts binary name to atom using `binary_to_atom` -%% - Options should include `named_table` if you want to reference by name later -%% - Used internally for creating recorder and ref mapping tables -ets_new(Name, Options) -> - NameAtom = binary_to_atom(Name, utf8), - ets:new(NameAtom, Options). - -%% @doc Insert recorder state into ETS table -%% -%% Stores stream recorder state in an ETS table for message-based streaming recording. -%% The state includes the recorder handle, recorded request, accumulated chunks, and -%% timing information for recreating streaming behavior during playback. -%% -%% ## Parameters -%% -%% - `TableName`: Table name as a binary (will be converted to atom) -%% - `Key`: String key identifying the stream (typically the request ID string) -%% - `Recorder`: Gleam recorder handle (opaque term) -%% - `RecordedRequest`: The recorded HTTP request structure -%% - `Chunks`: List of accumulated chunks (in reverse order, will be reversed on completion) -%% - `LastChunkTime`: Optional timestamp of the last chunk (for delay calculation) -%% -%% ## Returns -%% -%% `nil` - Always returns successfully -%% -%% ## Notes -%% -%% - Stores as `{Key, {Recorder, RecordedRequest, Chunks, LastChunkTime}}` -%% - Overwrites existing entry if key already exists -%% - Used internally by message-based streaming recorder -%% - Chunks are stored in reverse order (prepended) and reversed when stream completes -ets_insert(TableName, Key, Recorder, RecordedRequest, Headers, Chunks, LastChunkTime) -> - TableAtom = binary_to_atom(TableName, utf8), - Value = {Recorder, RecordedRequest, Headers, Chunks, LastChunkTime}, - ets:insert(TableAtom, {Key, Value}), - nil. - -%% @doc Lookup recorder state from ETS table -%% -%% Retrieves stream recorder state from an ETS table using the stream's request ID key. -%% Returns the state in a format that Gleam can decode as `MessageStreamRecorderState`. -%% -%% ## Parameters -%% -%% - `TableName`: Table name as a binary (will be converted to atom) -%% - `Key`: String key identifying the stream (typically the request ID string) -%% -%% ## Returns -%% -%% - `{some, State}`: State found, where `State` is a tuple matching Gleam's -%% `MessageStreamRecorderState` constructor format -%% - `none`: No state found for the key (stream doesn't exist or already completed) -%% -%% ## Examples -%% -%% ```erlang -%% case ets_lookup(<<"dream_http_client_stream_recorders">>, ReqId) of -%% {some, State} -> update_recorder_state(State); -%% none -> handle_missing_state() -%% end. -%% ``` -%% -%% ## Notes -%% -%% - Returns `none` if table doesn't exist or key not found -%% - State format: `{message_stream_recorder_state, Recorder, RecordedRequest, Chunks, LastChunkTime}` -%% - Used internally by message-based streaming recorder to update state -%% - Returns `none` if table conversion fails (safe error handling) -ets_lookup(TableName, Key) -> - try - TableAtom = binary_to_atom(TableName, utf8), - case ets:lookup(TableAtom, Key) of - [{Key, {Recorder, RecordedRequest, Headers, Chunks, LastChunkTime}}] -> - %% Return as Gleam MessageStreamRecorderState constructor - State = - {message_stream_recorder_state, - Recorder, - RecordedRequest, - Headers, - Chunks, - LastChunkTime}, - {some, State}; - [] -> - none - end - catch - error:badarg -> - none - end. - -%% @doc Delete a key from an ETS table -%% -%% Removes an entry from an ETS table by key. Used for cleaning up stream recorder -%% state when a stream completes or is cancelled. -%% -%% ## Parameters -%% -%% - `TableName`: Table name as a binary (will be converted to atom) -%% - `Key`: String key identifying the entry to delete -%% -%% ## Returns -%% -%% - `true`: Key was deleted successfully -%% - `false`: Key not found or table doesn't exist -%% -%% ## Examples -%% -%% ```erlang -%% ets_delete(<<"dream_http_client_stream_recorders">>, ReqId). -%% ``` -%% -%% ## Notes -%% -%% - Returns `false` if table doesn't exist or conversion fails (safe error handling) -%% - Used internally to clean up recorder state when streams end -%% - Safe to call multiple times on the same key -ets_delete(TableName, Key) -> - try - TableAtom = binary_to_atom(TableName, utf8), - ets:delete(TableAtom, Key) - catch - error:badarg -> - false - end. - -%% ============================================================================= -%% Request ID Mapping (String <-> Httpc Ref) -%% ============================================================================= - -%% Table for mapping string IDs to httpc refs (for cancellation) --define(REF_MAPPING_TABLE, dream_http_client_ref_mapping). - -%% Convert httpc ref to unique string ID -%% Uses the ref's string representation which is guaranteed unique -ref_to_string(Ref) -> - ensure_utf8_binary(io_lib:format("~p", [Ref])). - -%% Store bidirectional mapping: string <-> ref -store_ref_mapping(StringId, HttpcRef) -> - ets:insert(?REF_MAPPING_TABLE, {StringId, HttpcRef}), - ets:insert(?REF_MAPPING_TABLE, {HttpcRef, StringId}), - ok. - -%% Lookup httpc ref by string ID (for cancellation) -lookup_ref_by_string(StringId) -> - case ets:lookup(?REF_MAPPING_TABLE, StringId) of - [{StringId, HttpcRef}] -> - {some, HttpcRef}; - [] -> - none - end. - -%% Lookup string ID by httpc ref (for message translation) -lookup_string_by_ref(HttpcRef) -> - case ets:lookup(?REF_MAPPING_TABLE, HttpcRef) of - [{HttpcRef, StringId}] -> - {some, StringId}; - [] -> - none - end. - -%% Store a zlib context in ETS for message-based streaming decompression -maybe_store_stream_zlib(StringId, Headers) -> - case detect_stream_encoding(Headers) of - {_Enc, WindowBits} -> - Z = init_zlib_context(WindowBits), - ets:insert(?REF_MAPPING_TABLE, {{zlib, StringId}, Z}), - ok; - none -> - ok - end. - -%% Decompress a chunk using ETS-stored zlib context -maybe_decompress_stream_chunk(StringId, Data) -> - case ets:lookup(?REF_MAPPING_TABLE, {zlib, StringId}) of - [{{zlib, StringId}, Z}] -> - decompress_chunk(Z, Data); - [] -> - Data - end. - -%% Clean up ETS-stored zlib context -cleanup_stream_zlib(StringId) -> - case ets:lookup(?REF_MAPPING_TABLE, {zlib, StringId}) of - [{{zlib, StringId}, Z}] -> - cleanup_zlib(Z), - ets:delete(?REF_MAPPING_TABLE, {zlib, StringId}), - ok; - [] -> - ok - end. - -%% Remove both mappings (cleanup after stream ends) -remove_ref_mapping(StringId) -> - case lookup_ref_by_string(StringId) of - {some, HttpcRef} -> - ets:delete(?REF_MAPPING_TABLE, StringId), - ets:delete(?REF_MAPPING_TABLE, HttpcRef), - ok; - none -> - ok - end. diff --git a/modules/http_client/src/dream_http_client/internal.gleam b/modules/http_client/src/dream_http_client/internal.gleam index a01fb75..1a6faf3 100644 --- a/modules/http_client/src/dream_http_client/internal.gleam +++ b/modules/http_client/src/dream_http_client/internal.gleam @@ -22,7 +22,7 @@ import gleam/result import gleam/string // Erlang externals for streaming HTTP requests -@external(erlang, "dream_httpc_shim", "request_stream") +@external(erlang, "dream_http_shim", "request_stream") fn request_stream( method: atom.Atom, url: String, @@ -34,16 +34,16 @@ fn request_stream( autoredirect: Bool, ) -> d.Dynamic -@external(erlang, "dream_httpc_shim", "fetch_next") +@external(erlang, "dream_http_shim", "fetch_next") fn fetch_next(owner: d.Dynamic, timeout_ms: Int) -> d.Dynamic -@external(erlang, "dream_httpc_shim", "fetch_start_headers") +@external(erlang, "dream_http_shim", "fetch_start_headers") fn fetch_start_headers(owner: d.Dynamic, timeout_ms: Int) -> d.Dynamic /// Convert an HTTP method to an Erlang atom /// /// Converts a Gleam HTTP method type to an Erlang atom for use with the -/// Erlang httpc library. This is an internal function used by the streaming +/// Erlang gun library. This is an internal function used by the streaming /// request implementation. /// /// ## Parameters @@ -70,7 +70,7 @@ pub fn atomize_method(method: http.Method) -> atom.Atom { /// Start an HTTP streaming request /// -/// Initiates a streaming HTTP request using Erlang's httpc library. This +/// Initiates a streaming HTTP request using Erlang's gun library. This /// function constructs the URL, converts the method to an atom, and starts /// the streaming process. Returns a dynamic value containing the owner PID /// that can be used to receive chunks. @@ -84,7 +84,7 @@ pub fn atomize_method(method: http.Method) -> atom.Atom { /// /// A dynamic value containing the owner PID in the format `{ok, OwnerPid}`. /// Use `extract_owner_pid()` to get the PID for receiving chunks. -pub fn start_httpc_stream( +pub fn start_gun_stream( request: Request(String), timeout_ms: Int, connect_timeout_ms: Int, @@ -122,12 +122,12 @@ pub fn start_httpc_stream( /// Extract the owner PID from the request result /// -/// Extracts the owner process ID from the result returned by `start_httpc_stream()`. +/// Extracts the owner process ID from the result returned by `start_gun_stream()`. /// The owner PID is used to receive response chunks from the streaming request. /// /// ## Parameters /// -/// - `request_result`: The dynamic result from `start_httpc_stream()`, which is +/// - `request_result`: The dynamic result from `start_gun_stream()`, which is /// always `{ok, OwnerPid}` (errors are detected asynchronously) /// /// ## Returns @@ -259,7 +259,7 @@ fn convert_to_atom(dyn: d.Dynamic) -> Result(atom.Atom, e) { /// Start a message-based streaming HTTP request /// /// Low-level FFI function that initiates a streaming HTTP request using Erlang's -/// `httpc` library. Messages are sent directly to the specified process mailbox +/// `gun` library. Messages are sent directly to the specified process mailbox /// without buffering or an intermediate owner process. /// /// **Note:** This is an internal function used by the public API. Most callers @@ -285,7 +285,7 @@ fn convert_to_atom(dyn: d.Dynamic) -> Result(atom.Atom, e) { /// - This function is used internally by `client.start_stream()` /// - Messages arrive as Erlang tuples that must be decoded /// - Use `decode_stream_message_for_selector()` for selector integration -@external(erlang, "dream_httpc_shim", "request_stream_messages") +@external(erlang, "dream_http_shim", "request_stream_messages") pub fn start_stream_messages( method: atom.Atom, url: String, @@ -300,21 +300,21 @@ pub fn start_stream_messages( /// Cancel a streaming request /// /// Low-level FFI function that cancels an active streaming HTTP request using -/// the httpc request ID. +/// the gun request ID. /// /// **Note:** This is an internal function used by the public API. Most callers /// should use `client.cancel_stream()` instead. /// /// ## Parameters /// -/// - `request_id`: The httpc request ID as a dynamic value +/// - `request_id`: The gun request ID as a dynamic value /// /// ## Notes /// /// - This function is used internally by `client.cancel_stream()` /// - After cancellation, no more messages will be sent to the receiver process /// - Safe to call multiple times on the same request ID -@external(erlang, "dream_httpc_shim", "cancel_stream") +@external(erlang, "dream_http_shim", "cancel_stream") pub fn cancel_stream_internal(request_id: d.Dynamic) -> Nil /// Cancel a streaming request by string ID @@ -332,20 +332,20 @@ pub fn cancel_stream_internal(request_id: d.Dynamic) -> Nil /// ## Notes /// /// - This function is used internally by `client.cancel_stream()` -/// - Converts the string ID to the appropriate format for httpc +/// - Converts the string ID to the appropriate format for gun /// - After cancellation, no more messages will be sent to the receiver process -@external(erlang, "dream_httpc_shim", "cancel_stream_by_string") +@external(erlang, "dream_http_shim", "cancel_stream_by_string") pub fn cancel_stream_by_string(request_id_string: String) -> Nil /// Receive the next stream message with timeout /// -/// Low-level FFI function that blocks waiting for an httpc stream message from +/// Low-level FFI function that blocks waiting for an gun stream message from /// the process mailbox and returns a normalized tuple. This is used for direct /// message receiving without selector integration. /// /// **Note:** This is an internal function. Most callers should use the public /// streaming API (`client.start_stream()` or `client.stream_yielder()`), not -/// raw httpc messages. +/// raw gun messages. /// /// ## Parameters /// @@ -361,12 +361,12 @@ pub fn cancel_stream_by_string(request_id_string: String) -> Nil /// - This function is used internally for non-selector message handling /// - Messages are normalized by the Erlang shim before being returned /// - Use `decode_stream_message_for_selector()` for selector integration -@external(erlang, "dream_httpc_shim", "receive_stream_message") +@external(erlang, "dream_http_shim", "receive_stream_message") pub fn receive_stream_message(timeout_ms: Int) -> d.Dynamic /// Decode stream message for selector integration /// -/// Low-level FFI function that processes raw httpc messages from OTP selectors. +/// Low-level FFI function that processes raw gun messages from OTP selectors. /// The Erlang shim handles pattern matching, normalizes charlists to binaries, /// and returns a clean tuple format that Gleam can easily decode. /// @@ -389,5 +389,5 @@ pub fn receive_stream_message(timeout_ms: Int) -> d.Dynamic /// - This function is used internally by `client.start_stream()` /// - Handles all message normalization and type conversion /// - Returns a format optimized for Gleam's dynamic decoder -@external(erlang, "dream_httpc_shim", "decode_stream_message_for_selector") +@external(erlang, "dream_http_shim", "decode_stream_message_for_selector") pub fn decode_stream_message_for_selector(message: d.Dynamic) -> d.Dynamic diff --git a/modules/http_client/test/error_handling_test.gleam b/modules/http_client/test/error_handling_test.gleam index 20eb709..a30460c 100644 --- a/modules/http_client/test/error_handling_test.gleam +++ b/modules/http_client/test/error_handling_test.gleam @@ -151,9 +151,8 @@ pub fn send_connection_failure_test() { /// Test: requests with body do not hardcode Content-Type /// -/// Regression test for the Erlang httpc shim: it must respect the caller's -/// `Content-Type` header when building the `{Url, Headers, ContentType, Body}` -/// request tuple (and never force `application/json`). +/// Regression test: the shim must respect the caller's `Content-Type` header +/// and never force a default content type. pub fn send_respects_explicit_request_content_type_test() { let req = client.new() diff --git a/modules/http_client/test/ets_table_ownership_test.gleam b/modules/http_client/test/ets_table_ownership_test.gleam index ff89a7e..4e282e3 100644 --- a/modules/http_client/test/ets_table_ownership_test.gleam +++ b/modules/http_client/test/ets_table_ownership_test.gleam @@ -22,6 +22,7 @@ import dream_http_client/client import dream_http_client_test import gleam/erlang/process import gleam/http +import gleam/int import gleam/list import gleeunit/should @@ -184,7 +185,7 @@ pub fn five_concurrent_streams_from_expired_callers_test() { let end_subject = process.new_subject() let count = 5 - list.each(list.range(1, count), fn(i) { + int.range(from: 1, to: count + 1, with: Nil, run: fn(_, i) { let _pid = process.spawn_unlinked(fn() { let request = @@ -198,7 +199,7 @@ pub fn five_concurrent_streams_from_expired_callers_test() { Nil }) - let results = collect_n(end_subject, count, 8000) + let results = collect_n(end_subject, count, 15_000) list.length(results) |> should.equal(count) list.each(results, fn(id) { { id > 0 } |> should.be_true() }) } diff --git a/modules/http_client/test/redirect_test.gleam b/modules/http_client/test/redirect_test.gleam new file mode 100644 index 0000000..96ce1b9 --- /dev/null +++ b/modules/http_client/test/redirect_test.gleam @@ -0,0 +1,183 @@ +//// Auto-redirect integration tests +//// +//// Tests the shim's manual redirect-following logic against live mock server +//// endpoints. Gun does not auto-redirect natively, so the shim implements +//// redirect following for 301, 302, 303, 307, and 308 status codes. + +import dream_http_client/client +import dream_http_client_test +import gleam/bit_array +import gleam/bytes_tree +import gleam/erlang/process +import gleam/http +import gleam/list +import gleam/string +import gleam/yielder +import gleeunit/should + +fn mock_request(path: String) -> client.ClientRequest { + client.new() + |> client.method(http.Get) + |> client.scheme(http.Http) + |> client.host("localhost") + |> client.port(dream_http_client_test.get_test_port()) + |> client.path(path) +} + +// ============================================================================ +// send() redirect tests +// ============================================================================ + +pub fn send_follows_301_redirect_test() { + let req = mock_request("/redirect/301") + let assert Ok(resp) = client.send(req) + resp.status |> should.equal(200) + string.contains(resp.body, "Hello") |> should.be_true() +} + +pub fn send_follows_302_redirect_test() { + let req = mock_request("/redirect/302") + let assert Ok(resp) = client.send(req) + resp.status |> should.equal(200) + string.contains(resp.body, "Hello") |> should.be_true() +} + +pub fn send_follows_303_redirect_test() { + let req = mock_request("/redirect/303") + let assert Ok(resp) = client.send(req) + resp.status |> should.equal(200) + string.contains(resp.body, "Hello") |> should.be_true() +} + +pub fn send_follows_307_redirect_test() { + let req = mock_request("/redirect/307") + let assert Ok(resp) = client.send(req) + resp.status |> should.equal(200) + string.contains(resp.body, "Hello") |> should.be_true() +} + +pub fn send_follows_308_redirect_test() { + let req = mock_request("/redirect/308") + let assert Ok(resp) = client.send(req) + resp.status |> should.equal(200) + string.contains(resp.body, "Hello") |> should.be_true() +} + +pub fn send_auto_redirect_false_returns_3xx_test() { + let req = + mock_request("/redirect/301") + |> client.auto_redirect(False) + let assert Ok(resp) = client.send(req) + resp.status |> should.equal(301) + let has_location = + list.any(resp.headers, fn(h) { string.lowercase(h.name) == "location" }) + has_location |> should.be_true() +} + +pub fn send_follows_redirect_chain_test() { + let req = mock_request("/redirect/chain") + let assert Ok(resp) = client.send(req) + resp.status |> should.equal(200) + string.contains(resp.body, "Hello") |> should.be_true() +} + +pub fn send_follows_absolute_url_redirect_test() { + let req = mock_request("/redirect/absolute") + let assert Ok(resp) = client.send(req) + resp.status |> should.equal(200) + string.contains(resp.body, "Hello") |> should.be_true() +} + +// ============================================================================ +// stream_yielder() redirect tests +// ============================================================================ + +pub fn stream_yielder_follows_301_redirect_test() { + let req = mock_request("/redirect/301") + let results = client.stream_yielder(req) |> yielder.to_list + + { results != [] } |> should.be_true() + + let combined = combine_stream_chunks(results) + string.contains(combined, "Hello") |> should.be_true() +} + +pub fn stream_yielder_follows_redirect_chain_test() { + let req = mock_request("/redirect/chain") + let results = client.stream_yielder(req) |> yielder.to_list + + { results != [] } |> should.be_true() + + let combined = combine_stream_chunks(results) + string.contains(combined, "Hello") |> should.be_true() +} + +// ============================================================================ +// start_stream() redirect tests +// ============================================================================ + +pub fn start_stream_follows_301_redirect_test() { + let chunks_subject = process.new_subject() + let ended_subject = process.new_subject() + let error_subject = process.new_subject() + + let request = + mock_request("/redirect/301") + |> client.on_stream_chunk(fn(data) { process.send(chunks_subject, data) }) + |> client.on_stream_end(fn(_headers) { process.send(ended_subject, True) }) + |> client.on_stream_error(fn(reason) { process.send(error_subject, reason) }) + + let assert Ok(_handle) = client.start_stream(request) + + case process.receive(ended_subject, 10_000) { + Ok(True) -> { + let chunks = collect_chunks(chunks_subject, []) + { chunks != [] } |> should.be_true() + let combined = combine_bit_chunks(chunks) + string.contains(combined, "Hello") |> should.be_true() + } + Ok(False) -> should.fail() + Error(Nil) -> { + case process.receive(error_subject, 1000) { + Ok(_reason) -> should.fail() + Error(Nil) -> should.fail() + } + } + } +} + +// ============================================================================ +// Helpers +// ============================================================================ + +fn collect_chunks( + subject: process.Subject(BitArray), + acc: List(BitArray), +) -> List(BitArray) { + case process.receive(subject, 100) { + Ok(item) -> collect_chunks(subject, [item, ..acc]) + Error(Nil) -> list.reverse(acc) + } +} + +fn combine_stream_chunks( + results: List(Result(bytes_tree.BytesTree, String)), +) -> String { + let chunks = + list.filter_map(results, fn(r) { + case r { + Ok(bt) -> Ok(bytes_tree.to_bit_array(bt)) + Error(_) -> Error(Nil) + } + }) + combine_bit_chunks(chunks) +} + +fn combine_bit_chunks(chunks: List(BitArray)) -> String { + let combined = + list.fold(chunks, <<>>, fn(acc, chunk) { bit_array.append(acc, chunk) }) + case bit_array.to_string(combined) { + Ok(s) -> s + Error(Nil) -> "" + } +} diff --git a/modules/http_client/test/snippets/transport_config_example.gleam b/modules/http_client/test/snippets/transport_config_example.gleam index c85e3cc..54ad603 100644 --- a/modules/http_client/test/snippets/transport_config_example.gleam +++ b/modules/http_client/test/snippets/transport_config_example.gleam @@ -2,7 +2,8 @@ import dream_http_client/client pub fn configure_high_concurrency() -> Nil { client.transport_config() - |> client.max_sessions(200) - |> client.keep_alive_timeout(120_000) + |> client.max_connections(200) + |> client.idle_timeout(120_000) + |> client.max_concurrent_streams(500) |> client.configure_transport() } diff --git a/modules/http_client/test/start_stream_test.gleam b/modules/http_client/test/start_stream_test.gleam index 5df6080..7fca4af 100644 --- a/modules/http_client/test/start_stream_test.gleam +++ b/modules/http_client/test/start_stream_test.gleam @@ -102,8 +102,8 @@ pub fn start_stream_calls_on_error_for_network_failure_test() { // Act let assert Ok(_handle) = client.start_stream(request) - // Assert - on_error was called - case process.receive(error_subject, 2000) { + // Assert - on_error was called (gun retries 3 times with 1s between retries) + case process.receive(error_subject, 10_000) { Ok(reason) -> { { reason != "" } |> should.be_true() } diff --git a/modules/http_client/test/stream_error_decode_test.gleam b/modules/http_client/test/stream_error_decode_test.gleam index c19b46c..52fa562 100644 --- a/modules/http_client/test/stream_error_decode_test.gleam +++ b/modules/http_client/test/stream_error_decode_test.gleam @@ -1,15 +1,14 @@ //// Tests for stream error reason decoding robustness //// -//// Verifies that the HTTP client can decode error reasons from Erlang's httpc -//// regardless of the error format: transport-level errors (atoms/tuples from -//// httpc), non-UTF-8 response bodies, and connection failures. Both the +//// Verifies that the HTTP client can decode error reasons from gun +//// regardless of the error format: transport-level errors (atoms/tuples), +//// non-UTF-8 response bodies, and connection failures. Both the //// message-based (start_stream) and pull-based (stream_yielder) paths are //// tested. //// //// These tests close the gap left by stream_non_streaming_response_test.gleam, -//// which only covered HTTP error responses (complete response messages). The -//// tests here cover the {error, Reason} message path from httpc, which fires -//// on transport-level failures like connection refused and socket drops. +//// which only covered HTTP error responses. The tests here cover +//// transport-level failures like connection refused and socket drops. import dream_http_client/client import dream_http_client_test diff --git a/modules/http_client/test/stream_non_streaming_response_test.gleam b/modules/http_client/test/stream_non_streaming_response_test.gleam index b49ef34..a6403d4 100644 --- a/modules/http_client/test/stream_non_streaming_response_test.gleam +++ b/modules/http_client/test/stream_non_streaming_response_test.gleam @@ -2,10 +2,10 @@ //// //// When a streaming HTTP request is made via start_stream/stream_yielder and //// the upstream returns a non-streaming response (e.g. HTTP 401/500 with a -//// JSON body), Erlang's httpc sends a complete response message instead of -//// the expected stream_start/stream/stream_end sequence. +//// body), the gun shim detects non-2xx status codes and surfaces them as +//// errors through the existing error paths. //// -//// These tests verify that complete response messages are handled gracefully +//// These tests verify that non-2xx responses are handled gracefully //// and surfaced through the existing error paths rather than crashing the //// stream process or silently hanging. diff --git a/modules/http_client/test/transport_config_test.gleam b/modules/http_client/test/transport_config_test.gleam index 12d29a1..db7ab3e 100644 --- a/modules/http_client/test/transport_config_test.gleam +++ b/modules/http_client/test/transport_config_test.gleam @@ -2,94 +2,159 @@ import dream_http_client/client import gleeunit/should pub fn transport_config_has_correct_defaults_test() { - // Arrange & Act let config = client.transport_config() - // Assert - client.get_max_sessions(config) |> should.equal(100) - client.get_max_pipeline_length(config) |> should.equal(0) - client.get_keep_alive_timeout(config) |> should.equal(60_000) - client.get_max_keep_alive_length(config) |> should.equal(100) + client.get_max_connections(config) |> should.equal(50) + client.get_idle_timeout(config) |> should.equal(60_000) + client.get_default_connect_timeout(config) |> should.equal(15_000) + client.get_domain_lookup_timeout(config) |> should.equal(5000) + client.get_tls_handshake_timeout(config) |> should.equal(10_000) + client.get_retry(config) |> should.equal(3) + client.get_retry_timeout(config) |> should.equal(1000) + client.get_keepalive(config) |> should.equal(30_000) + client.get_keepalive_tolerance(config) |> should.equal(3) + client.get_max_concurrent_streams(config) |> should.equal(100) + client.get_initial_connection_window_size(config) |> should.equal(65_535) + client.get_initial_stream_window_size(config) |> should.equal(65_535) + client.get_closing_timeout(config) |> should.equal(15_000) } -pub fn max_sessions_sets_value_test() { - // Arrange +pub fn max_connections_sets_value_test() { let config = client.transport_config() + let updated = client.max_connections(config, 200) + client.get_max_connections(updated) |> should.equal(200) +} - // Act - let updated = client.max_sessions(config, 200) +pub fn max_connections_accepts_one_test() { + let config = client.transport_config() + let updated = client.max_connections(config, 1) + client.get_max_connections(updated) |> should.equal(1) +} - // Assert - client.get_max_sessions(updated) |> should.equal(200) +pub fn idle_timeout_sets_value_test() { + let config = client.transport_config() + let updated = client.idle_timeout(config, 120_000) + client.get_idle_timeout(updated) |> should.equal(120_000) } -pub fn max_sessions_accepts_zero_test() { - // Arrange +pub fn default_connect_timeout_sets_value_test() { let config = client.transport_config() + let updated = client.default_connect_timeout(config, 30_000) + client.get_default_connect_timeout(updated) |> should.equal(30_000) +} - // Act - let updated = client.max_sessions(config, 0) +pub fn domain_lookup_timeout_sets_value_test() { + let config = client.transport_config() + let updated = client.domain_lookup_timeout(config, 10_000) + client.get_domain_lookup_timeout(updated) |> should.equal(10_000) +} - // Assert - client.get_max_sessions(updated) |> should.equal(0) +pub fn tls_handshake_timeout_sets_value_test() { + let config = client.transport_config() + let updated = client.tls_handshake_timeout(config, 20_000) + client.get_tls_handshake_timeout(updated) |> should.equal(20_000) } -pub fn max_pipeline_length_sets_value_test() { - // Arrange +pub fn retry_sets_value_test() { let config = client.transport_config() + let updated = client.retry(config, 5) + client.get_retry(updated) |> should.equal(5) +} - // Act - let updated = client.max_pipeline_length(config, 5) +pub fn retry_accepts_zero_test() { + let config = client.transport_config() + let updated = client.retry(config, 0) + client.get_retry(updated) |> should.equal(0) +} - // Assert - client.get_max_pipeline_length(updated) |> should.equal(5) +pub fn retry_timeout_sets_value_test() { + let config = client.transport_config() + let updated = client.retry_timeout(config, 5000) + client.get_retry_timeout(updated) |> should.equal(5000) } -pub fn keep_alive_timeout_sets_value_test() { - // Arrange +pub fn keepalive_sets_value_test() { let config = client.transport_config() + let updated = client.keepalive(config, 60_000) + client.get_keepalive(updated) |> should.equal(60_000) +} - // Act - let updated = client.keep_alive_timeout(config, 120_000) +pub fn keepalive_tolerance_sets_value_test() { + let config = client.transport_config() + let updated = client.keepalive_tolerance(config, 5) + client.get_keepalive_tolerance(updated) |> should.equal(5) +} - // Assert - client.get_keep_alive_timeout(updated) |> should.equal(120_000) +pub fn keepalive_tolerance_accepts_zero_test() { + let config = client.transport_config() + let updated = client.keepalive_tolerance(config, 0) + client.get_keepalive_tolerance(updated) |> should.equal(0) +} + +pub fn max_concurrent_streams_sets_value_test() { + let config = client.transport_config() + let updated = client.max_concurrent_streams(config, 500) + client.get_max_concurrent_streams(updated) |> should.equal(500) +} + +pub fn max_concurrent_streams_accepts_one_test() { + let config = client.transport_config() + let updated = client.max_concurrent_streams(config, 1) + client.get_max_concurrent_streams(updated) |> should.equal(1) } -pub fn max_keep_alive_length_sets_value_test() { - // Arrange +pub fn initial_connection_window_size_sets_value_test() { let config = client.transport_config() + let updated = client.initial_connection_window_size(config, 131_070) + client.get_initial_connection_window_size(updated) |> should.equal(131_070) +} - // Act - let updated = client.max_keep_alive_length(config, 50) +pub fn initial_stream_window_size_sets_value_test() { + let config = client.transport_config() + let updated = client.initial_stream_window_size(config, 131_070) + client.get_initial_stream_window_size(updated) |> should.equal(131_070) +} - // Assert - client.get_max_keep_alive_length(updated) |> should.equal(50) +pub fn closing_timeout_sets_value_test() { + let config = client.transport_config() + let updated = client.closing_timeout(config, 30_000) + client.get_closing_timeout(updated) |> should.equal(30_000) } pub fn transport_config_builder_chain_sets_all_values_test() { - // Arrange & Act let config = client.transport_config() - |> client.max_sessions(200) - |> client.max_pipeline_length(5) - |> client.keep_alive_timeout(120_000) - |> client.max_keep_alive_length(50) - - // Assert - client.get_max_sessions(config) |> should.equal(200) - client.get_max_pipeline_length(config) |> should.equal(5) - client.get_keep_alive_timeout(config) |> should.equal(120_000) - client.get_max_keep_alive_length(config) |> should.equal(50) + |> client.max_connections(200) + |> client.idle_timeout(120_000) + |> client.default_connect_timeout(30_000) + |> client.domain_lookup_timeout(10_000) + |> client.tls_handshake_timeout(20_000) + |> client.retry(5) + |> client.retry_timeout(5000) + |> client.keepalive(60_000) + |> client.keepalive_tolerance(5) + |> client.max_concurrent_streams(500) + |> client.initial_connection_window_size(131_070) + |> client.initial_stream_window_size(131_070) + |> client.closing_timeout(30_000) + + client.get_max_connections(config) |> should.equal(200) + client.get_idle_timeout(config) |> should.equal(120_000) + client.get_default_connect_timeout(config) |> should.equal(30_000) + client.get_domain_lookup_timeout(config) |> should.equal(10_000) + client.get_tls_handshake_timeout(config) |> should.equal(20_000) + client.get_retry(config) |> should.equal(5) + client.get_retry_timeout(config) |> should.equal(5000) + client.get_keepalive(config) |> should.equal(60_000) + client.get_keepalive_tolerance(config) |> should.equal(5) + client.get_max_concurrent_streams(config) |> should.equal(500) + client.get_initial_connection_window_size(config) |> should.equal(131_070) + client.get_initial_stream_window_size(config) |> should.equal(131_070) + client.get_closing_timeout(config) |> should.equal(30_000) } pub fn configure_transport_applies_without_error_test() { - // Arrange let config = client.transport_config() - - // Act let result = client.configure_transport(config) - - // Assert result |> should.equal(Nil) } diff --git a/modules/mock_server/src/dream_mock_server/controllers/api_controller.gleam b/modules/mock_server/src/dream_mock_server/controllers/api_controller.gleam index 035242b..4e5cb76 100644 --- a/modules/mock_server/src/dream_mock_server/controllers/api_controller.gleam +++ b/modules/mock_server/src/dream_mock_server/controllers/api_controller.gleam @@ -266,3 +266,91 @@ pub fn echo_accept_encoding( } text_response(status.ok, value) } + +/// GET /redirect/301 - Returns 301 with Location: /text +pub fn redirect_301( + _request: Request, + _context: EmptyContext, + _services: EmptyServices, +) -> Response { + redirect_response(status.moved_permanently, "/text") +} + +/// GET /redirect/302 - Returns 302 with Location: /text +pub fn redirect_302( + _request: Request, + _context: EmptyContext, + _services: EmptyServices, +) -> Response { + redirect_response(status.found, "/text") +} + +/// GET /redirect/303 - Returns 303 with Location: /text (forces GET) +pub fn redirect_303( + _request: Request, + _context: EmptyContext, + _services: EmptyServices, +) -> Response { + redirect_response(status.see_other, "/text") +} + +/// GET /redirect/307 - Returns 307 with Location: /text (preserves method) +pub fn redirect_307( + _request: Request, + _context: EmptyContext, + _services: EmptyServices, +) -> Response { + redirect_response(status.temporary_redirect, "/text") +} + +/// GET /redirect/308 - Returns 308 with Location: /text (preserves method, permanent) +pub fn redirect_308( + _request: Request, + _context: EmptyContext, + _services: EmptyServices, +) -> Response { + redirect_response(308, "/text") +} + +/// GET /redirect/chain - Returns 302 with Location: /redirect/chain/2 +pub fn redirect_chain( + _request: Request, + _context: EmptyContext, + _services: EmptyServices, +) -> Response { + redirect_response(status.found, "/redirect/chain/2") +} + +/// GET /redirect/chain/2 - Returns 302 with Location: /text +pub fn redirect_chain_2( + _request: Request, + _context: EmptyContext, + _services: EmptyServices, +) -> Response { + redirect_response(status.found, "/text") +} + +/// GET /redirect/absolute - Returns 302 with a fully-qualified Location URL +pub fn redirect_absolute( + request: Request, + _context: EmptyContext, + _services: EmptyServices, +) -> Response { + let port_str = case + list.find(request.headers, fn(h) { string.lowercase(h.name) == "host" }) + { + Ok(host_header) -> host_header.value + Error(Nil) -> "localhost:9876" + } + redirect_response(status.found, "http://" <> port_str <> "/text") +} + +fn redirect_response(status_code: Int, location: String) -> Response { + response.Response( + status: status_code, + body: response.Text(""), + headers: [Header("Location", location)], + cookies: [], + content_type: option.None, + ) +} diff --git a/modules/mock_server/src/dream_mock_server/router.gleam b/modules/mock_server/src/dream_mock_server/router.gleam index 3205e3c..d1f02d5 100644 --- a/modules/mock_server/src/dream_mock_server/router.gleam +++ b/modules/mock_server/src/dream_mock_server/router.gleam @@ -18,6 +18,15 @@ //// - `GET /empty` - Returns empty response body //// - `GET /slow` - Returns response after 5s delay //// +//// **Redirect:** +//// - `GET /redirect/301` - 301 redirect to /text +//// - `GET /redirect/302` - 302 redirect to /text +//// - `GET /redirect/303` - 303 redirect to /text (forces GET) +//// - `GET /redirect/307` - 307 redirect to /text (preserves method) +//// - `GET /redirect/308` - 308 redirect to /text (preserves method, permanent) +//// - `GET /redirect/chain` - 302 chain: /chain -> /chain/2 -> /text +//// - `GET /redirect/absolute` - 302 with fully-qualified Location URL +//// //// **Streaming:** //// - `GET /` - Info page //// - `GET /stream/fast` - 10 chunks @ 100ms @@ -165,6 +174,55 @@ pub fn create_router() -> Router(EmptyContext, EmptyServices) { controller: api_controller.non_utf8_error, middleware: [], ) + // Redirect endpoints + |> route( + method: Get, + path: "/redirect/301", + controller: api_controller.redirect_301, + middleware: [], + ) + |> route( + method: Get, + path: "/redirect/302", + controller: api_controller.redirect_302, + middleware: [], + ) + |> route( + method: Get, + path: "/redirect/303", + controller: api_controller.redirect_303, + middleware: [], + ) + |> route( + method: Get, + path: "/redirect/307", + controller: api_controller.redirect_307, + middleware: [], + ) + |> route( + method: Get, + path: "/redirect/308", + controller: api_controller.redirect_308, + middleware: [], + ) + |> route( + method: Get, + path: "/redirect/chain", + controller: api_controller.redirect_chain, + middleware: [], + ) + |> route( + method: Get, + path: "/redirect/chain/2", + controller: api_controller.redirect_chain_2, + middleware: [], + ) + |> route( + method: Get, + path: "/redirect/absolute", + controller: api_controller.redirect_absolute, + middleware: [], + ) // Streaming endpoints |> route( method: Get, From 9cffad00a4b99a4b32e30f5351ccdefa5c8cb3de Mon Sep 17 00:00:00 2001 From: Dara Rockwell Date: Wed, 25 Mar 2026 18:05:09 -0600 Subject: [PATCH 3/7] fix: regenerate manifest.toml files to include gun dependency The httpc-to-gun swap added gun as a transitive dependency of dream_http_client, but downstream manifest.toml files were stale and missing it, causing "no such file or directory: gun.app" at runtime in CI integration tests. --- examples/custom_context/manifest.toml | 20 ++++++++++--------- examples/simple/manifest.toml | 20 ++++++++++--------- examples/streaming/manifest.toml | 14 +++++++------ examples/streaming_capabilities/manifest.toml | 14 +++++++------ modules/opensearch/manifest.toml | 8 +++++--- 5 files changed, 43 insertions(+), 33 deletions(-) diff --git a/examples/custom_context/manifest.toml b/examples/custom_context/manifest.toml index b2ed7c5..2ebc526 100644 --- a/examples/custom_context/manifest.toml +++ b/examples/custom_context/manifest.toml @@ -2,26 +2,28 @@ # You typically do not need to edit this file packages = [ - { name = "dream", version = "0.0.1", build_tools = ["gleam"], requirements = ["gleam_erlang", "gleam_http", "gleam_json", "gleam_otp", "gleam_stdlib", "gleam_time", "gleam_yielder", "marceau", "mist", "simplifile"], source = "local", path = "../.." }, - { name = "dream_http_client", version = "0.1.0", build_tools = ["gleam"], requirements = ["gleam_erlang", "gleam_http", "gleam_stdlib", "gleam_yielder"], source = "local", path = "../../modules/http_client" }, + { name = "cowlib", version = "2.16.0", build_tools = ["make", "rebar3"], requirements = [], otp_app = "cowlib", source = "hex", outer_checksum = "7F478D80D66B747344F0EA7708C187645CFCC08B11AA424632F78E25BF05DB51" }, + { name = "dream", version = "2.4.1", build_tools = ["gleam"], requirements = ["gleam_erlang", "gleam_http", "gleam_json", "gleam_otp", "gleam_stdlib", "gleam_time", "gleam_yielder", "marceau", "mist", "simplifile"], source = "local", path = "../.." }, + { name = "dream_http_client", version = "5.2.0", build_tools = ["gleam"], requirements = ["gleam_crypto", "gleam_erlang", "gleam_http", "gleam_json", "gleam_otp", "gleam_stdlib", "gleam_yielder", "gun", "simplifile"], source = "local", path = "../../modules/http_client" }, { name = "exception", version = "2.1.0", build_tools = ["gleam"], requirements = ["gleam_stdlib"], otp_app = "exception", source = "hex", outer_checksum = "329D269D5C2A314F7364BD2711372B6F2C58FA6F39981572E5CA68624D291F8C" }, { name = "filepath", version = "1.1.2", build_tools = ["gleam"], requirements = ["gleam_stdlib"], otp_app = "filepath", source = "hex", outer_checksum = "B06A9AF0BF10E51401D64B98E4B627F1D2E48C154967DA7AF4D0914780A6D40A" }, { name = "gleam_crypto", version = "1.5.1", build_tools = ["gleam"], requirements = ["gleam_stdlib"], otp_app = "gleam_crypto", source = "hex", outer_checksum = "50774BAFFF1144E7872814C566C5D653D83A3EBF23ACC3156B757A1B6819086E" }, { name = "gleam_erlang", version = "1.3.0", build_tools = ["gleam"], requirements = ["gleam_stdlib"], otp_app = "gleam_erlang", source = "hex", outer_checksum = "1124AD3AA21143E5AF0FC5CF3D9529F6DB8CA03E43A55711B60B6B7B3874375C" }, { name = "gleam_http", version = "4.3.0", build_tools = ["gleam"], requirements = ["gleam_stdlib"], otp_app = "gleam_http", source = "hex", outer_checksum = "82EA6A717C842456188C190AFB372665EA56CE13D8559BF3B1DD9E40F619EE0C" }, - { name = "gleam_json", version = "3.0.2", build_tools = ["gleam"], requirements = ["gleam_stdlib"], otp_app = "gleam_json", source = "hex", outer_checksum = "874FA3C3BB6E22DD2BB111966BD40B3759E9094E05257899A7C08F5DE77EC049" }, + { name = "gleam_json", version = "3.1.0", build_tools = ["gleam"], requirements = ["gleam_stdlib"], otp_app = "gleam_json", source = "hex", outer_checksum = "44FDAA8847BE8FC48CA7A1C089706BD54BADCC4C45B237A992EDDF9F2CDB2836" }, { name = "gleam_otp", version = "1.2.0", build_tools = ["gleam"], requirements = ["gleam_erlang", "gleam_stdlib"], otp_app = "gleam_otp", source = "hex", outer_checksum = "BA6A294E295E428EC1562DC1C11EA7530DCB981E8359134BEABC8493B7B2258E" }, - { name = "gleam_stdlib", version = "0.65.0", build_tools = ["gleam"], requirements = [], otp_app = "gleam_stdlib", source = "hex", outer_checksum = "7C69C71D8C493AE11A5184828A77110EB05A7786EBF8B25B36A72F879C3EE107" }, - { name = "gleam_time", version = "1.5.0", build_tools = ["gleam"], requirements = ["gleam_stdlib"], otp_app = "gleam_time", source = "hex", outer_checksum = "D560E672C7279C89908981E068DF07FD16D0C859DCA266F908B18F04DF0EB8E6" }, + { name = "gleam_stdlib", version = "0.70.0", build_tools = ["gleam"], requirements = [], otp_app = "gleam_stdlib", source = "hex", outer_checksum = "86949BF5D1F0E4AC0AB5B06F235D8A5CC11A2DFC33BF22F752156ED61CA7D0FF" }, + { name = "gleam_time", version = "1.7.0", build_tools = ["gleam"], requirements = ["gleam_stdlib"], otp_app = "gleam_time", source = "hex", outer_checksum = "56DB0EF9433826D3B99DB0B4AF7A2BFED13D09755EC64B1DAAB46F804A9AD47D" }, { name = "gleam_yielder", version = "1.1.0", build_tools = ["gleam"], requirements = ["gleam_stdlib"], otp_app = "gleam_yielder", source = "hex", outer_checksum = "8E4E4ECFA7982859F430C57F549200C7749823C106759F4A19A78AEA6687717A" }, - { name = "glisten", version = "8.0.1", build_tools = ["gleam"], requirements = ["gleam_erlang", "gleam_otp", "gleam_stdlib", "logging", "telemetry"], otp_app = "glisten", source = "hex", outer_checksum = "534BB27C71FB9E506345A767C0D76B17A9E9199934340C975DC003C710E3692D" }, + { name = "glisten", version = "8.0.3", build_tools = ["gleam"], requirements = ["gleam_erlang", "gleam_otp", "gleam_stdlib", "logging", "telemetry"], otp_app = "glisten", source = "hex", outer_checksum = "86B838196592D9EBDE7A1D2369AE3A51E568F7DD2D168706C463C42D17B95312" }, { name = "gramps", version = "6.0.0", build_tools = ["gleam"], requirements = ["gleam_crypto", "gleam_erlang", "gleam_http", "gleam_stdlib"], otp_app = "gramps", source = "hex", outer_checksum = "8B7195978FBFD30B43DF791A8A272041B81E45D245314D7A41FC57237AA882A0" }, + { name = "gun", version = "2.2.0", build_tools = ["make", "rebar3"], requirements = ["cowlib"], otp_app = "gun", source = "hex", outer_checksum = "76022700C64287FEB4DF93A1795CFF6741B83FB37415C40C34C38D2A4645261A" }, { name = "hpack_erl", version = "0.3.0", build_tools = ["rebar3"], requirements = [], otp_app = "hpack", source = "hex", outer_checksum = "D6137D7079169D8C485C6962DFE261AF5B9EF60FBC557344511C1E65E3D95FB0" }, { name = "logging", version = "1.3.0", build_tools = ["gleam"], requirements = ["gleam_stdlib"], otp_app = "logging", source = "hex", outer_checksum = "1098FBF10B54B44C2C7FDF0B01C1253CAFACDACABEFB4B0D027803246753E06D" }, { name = "marceau", version = "1.3.0", build_tools = ["gleam"], requirements = [], otp_app = "marceau", source = "hex", outer_checksum = "2D1C27504BEF45005F5DFB18591F8610FB4BFA91744878210BDC464412EC44E9" }, - { name = "mist", version = "5.0.3", build_tools = ["gleam"], requirements = ["exception", "gleam_erlang", "gleam_http", "gleam_otp", "gleam_stdlib", "gleam_yielder", "glisten", "gramps", "hpack_erl", "logging"], otp_app = "mist", source = "hex", outer_checksum = "7C4BE717A81305323C47C8A591E6B9BA4AC7F56354BF70B4D3DF08CC01192668" }, - { name = "simplifile", version = "2.3.1", build_tools = ["gleam"], requirements = ["filepath", "gleam_stdlib"], otp_app = "simplifile", source = "hex", outer_checksum = "957E0E5B75927659F1D2A1B7B75D7B9BA96FAA8D0C53EA71C4AD9CD0C6B848F6" }, - { name = "telemetry", version = "1.3.0", build_tools = ["rebar3"], requirements = [], otp_app = "telemetry", source = "hex", outer_checksum = "7015FC8919DBE63764F4B4B87A95B7C0996BD539E0D499BE6EC9D7F3875B79E6" }, + { name = "mist", version = "5.0.4", build_tools = ["gleam"], requirements = ["exception", "gleam_erlang", "gleam_http", "gleam_otp", "gleam_stdlib", "gleam_yielder", "glisten", "gramps", "hpack_erl", "logging"], otp_app = "mist", source = "hex", outer_checksum = "7CED4B2D81FD547ADB093D97B9928B9419A7F58B8562A30A6CC17A252B31AD05" }, + { name = "simplifile", version = "2.4.0", build_tools = ["gleam"], requirements = ["filepath", "gleam_stdlib"], otp_app = "simplifile", source = "hex", outer_checksum = "7C18AFA4FED0B4CE1FA5B0B4BAC1FA1744427054EA993565F6F3F82E5453170D" }, + { name = "telemetry", version = "1.4.1", build_tools = ["rebar3"], requirements = [], otp_app = "telemetry", source = "hex", outer_checksum = "2172E05A27531D3D31DD9782841065C50DD5C3C7699D95266B2EDD54C2DAFA1C" }, ] [requirements] diff --git a/examples/simple/manifest.toml b/examples/simple/manifest.toml index 2e91421..1be2614 100644 --- a/examples/simple/manifest.toml +++ b/examples/simple/manifest.toml @@ -2,26 +2,28 @@ # You typically do not need to edit this file packages = [ - { name = "dream", version = "0.0.1", build_tools = ["gleam"], requirements = ["gleam_erlang", "gleam_http", "gleam_json", "gleam_otp", "gleam_stdlib", "gleam_time", "gleam_yielder", "marceau", "mist", "simplifile"], source = "local", path = "../.." }, - { name = "dream_http_client", version = "0.1.0", build_tools = ["gleam"], requirements = ["gleam_erlang", "gleam_http", "gleam_stdlib", "gleam_yielder"], source = "local", path = "../../modules/http_client" }, + { name = "cowlib", version = "2.16.0", build_tools = ["make", "rebar3"], requirements = [], otp_app = "cowlib", source = "hex", outer_checksum = "7F478D80D66B747344F0EA7708C187645CFCC08B11AA424632F78E25BF05DB51" }, + { name = "dream", version = "2.4.1", build_tools = ["gleam"], requirements = ["gleam_erlang", "gleam_http", "gleam_json", "gleam_otp", "gleam_stdlib", "gleam_time", "gleam_yielder", "marceau", "mist", "simplifile"], source = "local", path = "../.." }, + { name = "dream_http_client", version = "5.2.0", build_tools = ["gleam"], requirements = ["gleam_crypto", "gleam_erlang", "gleam_http", "gleam_json", "gleam_otp", "gleam_stdlib", "gleam_yielder", "gun", "simplifile"], source = "local", path = "../../modules/http_client" }, { name = "exception", version = "2.1.0", build_tools = ["gleam"], requirements = ["gleam_stdlib"], otp_app = "exception", source = "hex", outer_checksum = "329D269D5C2A314F7364BD2711372B6F2C58FA6F39981572E5CA68624D291F8C" }, { name = "filepath", version = "1.1.2", build_tools = ["gleam"], requirements = ["gleam_stdlib"], otp_app = "filepath", source = "hex", outer_checksum = "B06A9AF0BF10E51401D64B98E4B627F1D2E48C154967DA7AF4D0914780A6D40A" }, { name = "gleam_crypto", version = "1.5.1", build_tools = ["gleam"], requirements = ["gleam_stdlib"], otp_app = "gleam_crypto", source = "hex", outer_checksum = "50774BAFFF1144E7872814C566C5D653D83A3EBF23ACC3156B757A1B6819086E" }, { name = "gleam_erlang", version = "1.3.0", build_tools = ["gleam"], requirements = ["gleam_stdlib"], otp_app = "gleam_erlang", source = "hex", outer_checksum = "1124AD3AA21143E5AF0FC5CF3D9529F6DB8CA03E43A55711B60B6B7B3874375C" }, { name = "gleam_http", version = "4.3.0", build_tools = ["gleam"], requirements = ["gleam_stdlib"], otp_app = "gleam_http", source = "hex", outer_checksum = "82EA6A717C842456188C190AFB372665EA56CE13D8559BF3B1DD9E40F619EE0C" }, - { name = "gleam_json", version = "3.0.2", build_tools = ["gleam"], requirements = ["gleam_stdlib"], otp_app = "gleam_json", source = "hex", outer_checksum = "874FA3C3BB6E22DD2BB111966BD40B3759E9094E05257899A7C08F5DE77EC049" }, + { name = "gleam_json", version = "3.1.0", build_tools = ["gleam"], requirements = ["gleam_stdlib"], otp_app = "gleam_json", source = "hex", outer_checksum = "44FDAA8847BE8FC48CA7A1C089706BD54BADCC4C45B237A992EDDF9F2CDB2836" }, { name = "gleam_otp", version = "1.2.0", build_tools = ["gleam"], requirements = ["gleam_erlang", "gleam_stdlib"], otp_app = "gleam_otp", source = "hex", outer_checksum = "BA6A294E295E428EC1562DC1C11EA7530DCB981E8359134BEABC8493B7B2258E" }, - { name = "gleam_stdlib", version = "0.65.0", build_tools = ["gleam"], requirements = [], otp_app = "gleam_stdlib", source = "hex", outer_checksum = "7C69C71D8C493AE11A5184828A77110EB05A7786EBF8B25B36A72F879C3EE107" }, - { name = "gleam_time", version = "1.5.0", build_tools = ["gleam"], requirements = ["gleam_stdlib"], otp_app = "gleam_time", source = "hex", outer_checksum = "D560E672C7279C89908981E068DF07FD16D0C859DCA266F908B18F04DF0EB8E6" }, + { name = "gleam_stdlib", version = "0.70.0", build_tools = ["gleam"], requirements = [], otp_app = "gleam_stdlib", source = "hex", outer_checksum = "86949BF5D1F0E4AC0AB5B06F235D8A5CC11A2DFC33BF22F752156ED61CA7D0FF" }, + { name = "gleam_time", version = "1.7.0", build_tools = ["gleam"], requirements = ["gleam_stdlib"], otp_app = "gleam_time", source = "hex", outer_checksum = "56DB0EF9433826D3B99DB0B4AF7A2BFED13D09755EC64B1DAAB46F804A9AD47D" }, { name = "gleam_yielder", version = "1.1.0", build_tools = ["gleam"], requirements = ["gleam_stdlib"], otp_app = "gleam_yielder", source = "hex", outer_checksum = "8E4E4ECFA7982859F430C57F549200C7749823C106759F4A19A78AEA6687717A" }, - { name = "glisten", version = "8.0.1", build_tools = ["gleam"], requirements = ["gleam_erlang", "gleam_otp", "gleam_stdlib", "logging", "telemetry"], otp_app = "glisten", source = "hex", outer_checksum = "534BB27C71FB9E506345A767C0D76B17A9E9199934340C975DC003C710E3692D" }, + { name = "glisten", version = "8.0.3", build_tools = ["gleam"], requirements = ["gleam_erlang", "gleam_otp", "gleam_stdlib", "logging", "telemetry"], otp_app = "glisten", source = "hex", outer_checksum = "86B838196592D9EBDE7A1D2369AE3A51E568F7DD2D168706C463C42D17B95312" }, { name = "gramps", version = "6.0.0", build_tools = ["gleam"], requirements = ["gleam_crypto", "gleam_erlang", "gleam_http", "gleam_stdlib"], otp_app = "gramps", source = "hex", outer_checksum = "8B7195978FBFD30B43DF791A8A272041B81E45D245314D7A41FC57237AA882A0" }, + { name = "gun", version = "2.2.0", build_tools = ["make", "rebar3"], requirements = ["cowlib"], otp_app = "gun", source = "hex", outer_checksum = "76022700C64287FEB4DF93A1795CFF6741B83FB37415C40C34C38D2A4645261A" }, { name = "hpack_erl", version = "0.3.0", build_tools = ["rebar3"], requirements = [], otp_app = "hpack", source = "hex", outer_checksum = "D6137D7079169D8C485C6962DFE261AF5B9EF60FBC557344511C1E65E3D95FB0" }, { name = "logging", version = "1.3.0", build_tools = ["gleam"], requirements = ["gleam_stdlib"], otp_app = "logging", source = "hex", outer_checksum = "1098FBF10B54B44C2C7FDF0B01C1253CAFACDACABEFB4B0D027803246753E06D" }, { name = "marceau", version = "1.3.0", build_tools = ["gleam"], requirements = [], otp_app = "marceau", source = "hex", outer_checksum = "2D1C27504BEF45005F5DFB18591F8610FB4BFA91744878210BDC464412EC44E9" }, - { name = "mist", version = "5.0.3", build_tools = ["gleam"], requirements = ["exception", "gleam_erlang", "gleam_http", "gleam_otp", "gleam_stdlib", "gleam_yielder", "glisten", "gramps", "hpack_erl", "logging"], otp_app = "mist", source = "hex", outer_checksum = "7C4BE717A81305323C47C8A591E6B9BA4AC7F56354BF70B4D3DF08CC01192668" }, - { name = "simplifile", version = "2.3.1", build_tools = ["gleam"], requirements = ["filepath", "gleam_stdlib"], otp_app = "simplifile", source = "hex", outer_checksum = "957E0E5B75927659F1D2A1B7B75D7B9BA96FAA8D0C53EA71C4AD9CD0C6B848F6" }, - { name = "telemetry", version = "1.3.0", build_tools = ["rebar3"], requirements = [], otp_app = "telemetry", source = "hex", outer_checksum = "7015FC8919DBE63764F4B4B87A95B7C0996BD539E0D499BE6EC9D7F3875B79E6" }, + { name = "mist", version = "5.0.4", build_tools = ["gleam"], requirements = ["exception", "gleam_erlang", "gleam_http", "gleam_otp", "gleam_stdlib", "gleam_yielder", "glisten", "gramps", "hpack_erl", "logging"], otp_app = "mist", source = "hex", outer_checksum = "7CED4B2D81FD547ADB093D97B9928B9419A7F58B8562A30A6CC17A252B31AD05" }, + { name = "simplifile", version = "2.4.0", build_tools = ["gleam"], requirements = ["filepath", "gleam_stdlib"], otp_app = "simplifile", source = "hex", outer_checksum = "7C18AFA4FED0B4CE1FA5B0B4BAC1FA1744427054EA993565F6F3F82E5453170D" }, + { name = "telemetry", version = "1.4.1", build_tools = ["rebar3"], requirements = [], otp_app = "telemetry", source = "hex", outer_checksum = "2172E05A27531D3D31DD9782841065C50DD5C3C7699D95266B2EDD54C2DAFA1C" }, ] [requirements] diff --git a/examples/streaming/manifest.toml b/examples/streaming/manifest.toml index 0d9cdff..df5948f 100644 --- a/examples/streaming/manifest.toml +++ b/examples/streaming/manifest.toml @@ -2,9 +2,10 @@ # You typically do not need to edit this file packages = [ - { name = "dream", version = "2.3.2", build_tools = ["gleam"], requirements = ["gleam_erlang", "gleam_http", "gleam_json", "gleam_otp", "gleam_stdlib", "gleam_time", "gleam_yielder", "marceau", "mist", "simplifile"], source = "local", path = "../.." }, - { name = "dream_http_client", version = "4.1.0", build_tools = ["gleam"], requirements = ["gleam_crypto", "gleam_erlang", "gleam_http", "gleam_json", "gleam_otp", "gleam_stdlib", "gleam_yielder", "simplifile"], source = "local", path = "../../modules/http_client" }, - { name = "dream_mock_server", version = "1.1.0", build_tools = ["gleam"], requirements = ["dream", "gleam_erlang", "gleam_http", "gleam_json", "gleam_otp", "gleam_stdlib", "gleam_yielder"], source = "local", path = "../../modules/mock_server" }, + { name = "cowlib", version = "2.16.0", build_tools = ["make", "rebar3"], requirements = [], otp_app = "cowlib", source = "hex", outer_checksum = "7F478D80D66B747344F0EA7708C187645CFCC08B11AA424632F78E25BF05DB51" }, + { name = "dream", version = "2.4.1", build_tools = ["gleam"], requirements = ["gleam_erlang", "gleam_http", "gleam_json", "gleam_otp", "gleam_stdlib", "gleam_time", "gleam_yielder", "marceau", "mist", "simplifile"], source = "local", path = "../.." }, + { name = "dream_http_client", version = "5.2.0", build_tools = ["gleam"], requirements = ["gleam_crypto", "gleam_erlang", "gleam_http", "gleam_json", "gleam_otp", "gleam_stdlib", "gleam_yielder", "gun", "simplifile"], source = "local", path = "../../modules/http_client" }, + { name = "dream_mock_server", version = "1.1.1", build_tools = ["gleam"], requirements = ["dream", "gleam_erlang", "gleam_http", "gleam_json", "gleam_otp", "gleam_stdlib", "gleam_yielder"], source = "local", path = "../../modules/mock_server" }, { name = "exception", version = "2.1.0", build_tools = ["gleam"], requirements = ["gleam_stdlib"], otp_app = "exception", source = "hex", outer_checksum = "329D269D5C2A314F7364BD2711372B6F2C58FA6F39981572E5CA68624D291F8C" }, { name = "filepath", version = "1.1.2", build_tools = ["gleam"], requirements = ["gleam_stdlib"], otp_app = "filepath", source = "hex", outer_checksum = "B06A9AF0BF10E51401D64B98E4B627F1D2E48C154967DA7AF4D0914780A6D40A" }, { name = "gleam_crypto", version = "1.5.1", build_tools = ["gleam"], requirements = ["gleam_stdlib"], otp_app = "gleam_crypto", source = "hex", outer_checksum = "50774BAFFF1144E7872814C566C5D653D83A3EBF23ACC3156B757A1B6819086E" }, @@ -12,17 +13,18 @@ packages = [ { name = "gleam_http", version = "4.3.0", build_tools = ["gleam"], requirements = ["gleam_stdlib"], otp_app = "gleam_http", source = "hex", outer_checksum = "82EA6A717C842456188C190AFB372665EA56CE13D8559BF3B1DD9E40F619EE0C" }, { name = "gleam_json", version = "3.1.0", build_tools = ["gleam"], requirements = ["gleam_stdlib"], otp_app = "gleam_json", source = "hex", outer_checksum = "44FDAA8847BE8FC48CA7A1C089706BD54BADCC4C45B237A992EDDF9F2CDB2836" }, { name = "gleam_otp", version = "1.2.0", build_tools = ["gleam"], requirements = ["gleam_erlang", "gleam_stdlib"], otp_app = "gleam_otp", source = "hex", outer_checksum = "BA6A294E295E428EC1562DC1C11EA7530DCB981E8359134BEABC8493B7B2258E" }, - { name = "gleam_stdlib", version = "0.69.0", build_tools = ["gleam"], requirements = [], otp_app = "gleam_stdlib", source = "hex", outer_checksum = "AAB0962BEBFAA67A2FBEE9EEE218B057756808DC9AF77430F5182C6115B3A315" }, + { name = "gleam_stdlib", version = "0.70.0", build_tools = ["gleam"], requirements = [], otp_app = "gleam_stdlib", source = "hex", outer_checksum = "86949BF5D1F0E4AC0AB5B06F235D8A5CC11A2DFC33BF22F752156ED61CA7D0FF" }, { name = "gleam_time", version = "1.7.0", build_tools = ["gleam"], requirements = ["gleam_stdlib"], otp_app = "gleam_time", source = "hex", outer_checksum = "56DB0EF9433826D3B99DB0B4AF7A2BFED13D09755EC64B1DAAB46F804A9AD47D" }, { name = "gleam_yielder", version = "1.1.0", build_tools = ["gleam"], requirements = ["gleam_stdlib"], otp_app = "gleam_yielder", source = "hex", outer_checksum = "8E4E4ECFA7982859F430C57F549200C7749823C106759F4A19A78AEA6687717A" }, { name = "glisten", version = "8.0.3", build_tools = ["gleam"], requirements = ["gleam_erlang", "gleam_otp", "gleam_stdlib", "logging", "telemetry"], otp_app = "glisten", source = "hex", outer_checksum = "86B838196592D9EBDE7A1D2369AE3A51E568F7DD2D168706C463C42D17B95312" }, { name = "gramps", version = "6.0.0", build_tools = ["gleam"], requirements = ["gleam_crypto", "gleam_erlang", "gleam_http", "gleam_stdlib"], otp_app = "gramps", source = "hex", outer_checksum = "8B7195978FBFD30B43DF791A8A272041B81E45D245314D7A41FC57237AA882A0" }, + { name = "gun", version = "2.2.0", build_tools = ["make", "rebar3"], requirements = ["cowlib"], otp_app = "gun", source = "hex", outer_checksum = "76022700C64287FEB4DF93A1795CFF6741B83FB37415C40C34C38D2A4645261A" }, { name = "hpack_erl", version = "0.3.0", build_tools = ["rebar3"], requirements = [], otp_app = "hpack", source = "hex", outer_checksum = "D6137D7079169D8C485C6962DFE261AF5B9EF60FBC557344511C1E65E3D95FB0" }, { name = "logging", version = "1.3.0", build_tools = ["gleam"], requirements = ["gleam_stdlib"], otp_app = "logging", source = "hex", outer_checksum = "1098FBF10B54B44C2C7FDF0B01C1253CAFACDACABEFB4B0D027803246753E06D" }, { name = "marceau", version = "1.3.0", build_tools = ["gleam"], requirements = [], otp_app = "marceau", source = "hex", outer_checksum = "2D1C27504BEF45005F5DFB18591F8610FB4BFA91744878210BDC464412EC44E9" }, { name = "mist", version = "5.0.4", build_tools = ["gleam"], requirements = ["exception", "gleam_erlang", "gleam_http", "gleam_otp", "gleam_stdlib", "gleam_yielder", "glisten", "gramps", "hpack_erl", "logging"], otp_app = "mist", source = "hex", outer_checksum = "7CED4B2D81FD547ADB093D97B9928B9419A7F58B8562A30A6CC17A252B31AD05" }, - { name = "simplifile", version = "2.3.2", build_tools = ["gleam"], requirements = ["filepath", "gleam_stdlib"], otp_app = "simplifile", source = "hex", outer_checksum = "E049B4DACD4D206D87843BCF4C775A50AE0F50A52031A2FFB40C9ED07D6EC70A" }, - { name = "telemetry", version = "1.3.0", build_tools = ["rebar3"], requirements = [], otp_app = "telemetry", source = "hex", outer_checksum = "7015FC8919DBE63764F4B4B87A95B7C0996BD539E0D499BE6EC9D7F3875B79E6" }, + { name = "simplifile", version = "2.4.0", build_tools = ["gleam"], requirements = ["filepath", "gleam_stdlib"], otp_app = "simplifile", source = "hex", outer_checksum = "7C18AFA4FED0B4CE1FA5B0B4BAC1FA1744427054EA993565F6F3F82E5453170D" }, + { name = "telemetry", version = "1.4.1", build_tools = ["rebar3"], requirements = [], otp_app = "telemetry", source = "hex", outer_checksum = "2172E05A27531D3D31DD9782841065C50DD5C3C7699D95266B2EDD54C2DAFA1C" }, ] [requirements] diff --git a/examples/streaming_capabilities/manifest.toml b/examples/streaming_capabilities/manifest.toml index 52e9b16..ed99aa7 100644 --- a/examples/streaming_capabilities/manifest.toml +++ b/examples/streaming_capabilities/manifest.toml @@ -2,9 +2,10 @@ # You typically do not need to edit this file packages = [ - { name = "dream", version = "2.3.2", build_tools = ["gleam"], requirements = ["gleam_erlang", "gleam_http", "gleam_json", "gleam_otp", "gleam_stdlib", "gleam_time", "gleam_yielder", "marceau", "mist", "simplifile"], source = "local", path = "../.." }, - { name = "dream_http_client", version = "4.1.0", build_tools = ["gleam"], requirements = ["gleam_crypto", "gleam_erlang", "gleam_http", "gleam_json", "gleam_otp", "gleam_stdlib", "gleam_yielder", "simplifile"], source = "local", path = "../../modules/http_client" }, - { name = "dream_mock_server", version = "1.1.0", build_tools = ["gleam"], requirements = ["dream", "gleam_erlang", "gleam_http", "gleam_json", "gleam_otp", "gleam_stdlib", "gleam_yielder"], source = "local", path = "../../modules/mock_server" }, + { name = "cowlib", version = "2.16.0", build_tools = ["make", "rebar3"], requirements = [], otp_app = "cowlib", source = "hex", outer_checksum = "7F478D80D66B747344F0EA7708C187645CFCC08B11AA424632F78E25BF05DB51" }, + { name = "dream", version = "2.4.1", build_tools = ["gleam"], requirements = ["gleam_erlang", "gleam_http", "gleam_json", "gleam_otp", "gleam_stdlib", "gleam_time", "gleam_yielder", "marceau", "mist", "simplifile"], source = "local", path = "../.." }, + { name = "dream_http_client", version = "5.2.0", build_tools = ["gleam"], requirements = ["gleam_crypto", "gleam_erlang", "gleam_http", "gleam_json", "gleam_otp", "gleam_stdlib", "gleam_yielder", "gun", "simplifile"], source = "local", path = "../../modules/http_client" }, + { name = "dream_mock_server", version = "1.1.1", build_tools = ["gleam"], requirements = ["dream", "gleam_erlang", "gleam_http", "gleam_json", "gleam_otp", "gleam_stdlib", "gleam_yielder"], source = "local", path = "../../modules/mock_server" }, { name = "exception", version = "2.1.0", build_tools = ["gleam"], requirements = ["gleam_stdlib"], otp_app = "exception", source = "hex", outer_checksum = "329D269D5C2A314F7364BD2711372B6F2C58FA6F39981572E5CA68624D291F8C" }, { name = "filepath", version = "1.1.2", build_tools = ["gleam"], requirements = ["gleam_stdlib"], otp_app = "filepath", source = "hex", outer_checksum = "B06A9AF0BF10E51401D64B98E4B627F1D2E48C154967DA7AF4D0914780A6D40A" }, { name = "gleam_crypto", version = "1.5.1", build_tools = ["gleam"], requirements = ["gleam_stdlib"], otp_app = "gleam_crypto", source = "hex", outer_checksum = "50774BAFFF1144E7872814C566C5D653D83A3EBF23ACC3156B757A1B6819086E" }, @@ -12,17 +13,18 @@ packages = [ { name = "gleam_http", version = "4.3.0", build_tools = ["gleam"], requirements = ["gleam_stdlib"], otp_app = "gleam_http", source = "hex", outer_checksum = "82EA6A717C842456188C190AFB372665EA56CE13D8559BF3B1DD9E40F619EE0C" }, { name = "gleam_json", version = "3.1.0", build_tools = ["gleam"], requirements = ["gleam_stdlib"], otp_app = "gleam_json", source = "hex", outer_checksum = "44FDAA8847BE8FC48CA7A1C089706BD54BADCC4C45B237A992EDDF9F2CDB2836" }, { name = "gleam_otp", version = "1.2.0", build_tools = ["gleam"], requirements = ["gleam_erlang", "gleam_stdlib"], otp_app = "gleam_otp", source = "hex", outer_checksum = "BA6A294E295E428EC1562DC1C11EA7530DCB981E8359134BEABC8493B7B2258E" }, - { name = "gleam_stdlib", version = "0.69.0", build_tools = ["gleam"], requirements = [], otp_app = "gleam_stdlib", source = "hex", outer_checksum = "AAB0962BEBFAA67A2FBEE9EEE218B057756808DC9AF77430F5182C6115B3A315" }, + { name = "gleam_stdlib", version = "0.70.0", build_tools = ["gleam"], requirements = [], otp_app = "gleam_stdlib", source = "hex", outer_checksum = "86949BF5D1F0E4AC0AB5B06F235D8A5CC11A2DFC33BF22F752156ED61CA7D0FF" }, { name = "gleam_time", version = "1.7.0", build_tools = ["gleam"], requirements = ["gleam_stdlib"], otp_app = "gleam_time", source = "hex", outer_checksum = "56DB0EF9433826D3B99DB0B4AF7A2BFED13D09755EC64B1DAAB46F804A9AD47D" }, { name = "gleam_yielder", version = "1.1.0", build_tools = ["gleam"], requirements = ["gleam_stdlib"], otp_app = "gleam_yielder", source = "hex", outer_checksum = "8E4E4ECFA7982859F430C57F549200C7749823C106759F4A19A78AEA6687717A" }, { name = "glisten", version = "8.0.3", build_tools = ["gleam"], requirements = ["gleam_erlang", "gleam_otp", "gleam_stdlib", "logging", "telemetry"], otp_app = "glisten", source = "hex", outer_checksum = "86B838196592D9EBDE7A1D2369AE3A51E568F7DD2D168706C463C42D17B95312" }, { name = "gramps", version = "6.0.0", build_tools = ["gleam"], requirements = ["gleam_crypto", "gleam_erlang", "gleam_http", "gleam_stdlib"], otp_app = "gramps", source = "hex", outer_checksum = "8B7195978FBFD30B43DF791A8A272041B81E45D245314D7A41FC57237AA882A0" }, + { name = "gun", version = "2.2.0", build_tools = ["make", "rebar3"], requirements = ["cowlib"], otp_app = "gun", source = "hex", outer_checksum = "76022700C64287FEB4DF93A1795CFF6741B83FB37415C40C34C38D2A4645261A" }, { name = "hpack_erl", version = "0.3.0", build_tools = ["rebar3"], requirements = [], otp_app = "hpack", source = "hex", outer_checksum = "D6137D7079169D8C485C6962DFE261AF5B9EF60FBC557344511C1E65E3D95FB0" }, { name = "logging", version = "1.3.0", build_tools = ["gleam"], requirements = ["gleam_stdlib"], otp_app = "logging", source = "hex", outer_checksum = "1098FBF10B54B44C2C7FDF0B01C1253CAFACDACABEFB4B0D027803246753E06D" }, { name = "marceau", version = "1.3.0", build_tools = ["gleam"], requirements = [], otp_app = "marceau", source = "hex", outer_checksum = "2D1C27504BEF45005F5DFB18591F8610FB4BFA91744878210BDC464412EC44E9" }, { name = "mist", version = "5.0.4", build_tools = ["gleam"], requirements = ["exception", "gleam_erlang", "gleam_http", "gleam_otp", "gleam_stdlib", "gleam_yielder", "glisten", "gramps", "hpack_erl", "logging"], otp_app = "mist", source = "hex", outer_checksum = "7CED4B2D81FD547ADB093D97B9928B9419A7F58B8562A30A6CC17A252B31AD05" }, - { name = "simplifile", version = "2.3.2", build_tools = ["gleam"], requirements = ["filepath", "gleam_stdlib"], otp_app = "simplifile", source = "hex", outer_checksum = "E049B4DACD4D206D87843BCF4C775A50AE0F50A52031A2FFB40C9ED07D6EC70A" }, - { name = "telemetry", version = "1.3.0", build_tools = ["rebar3"], requirements = [], otp_app = "telemetry", source = "hex", outer_checksum = "7015FC8919DBE63764F4B4B87A95B7C0996BD539E0D499BE6EC9D7F3875B79E6" }, + { name = "simplifile", version = "2.4.0", build_tools = ["gleam"], requirements = ["filepath", "gleam_stdlib"], otp_app = "simplifile", source = "hex", outer_checksum = "7C18AFA4FED0B4CE1FA5B0B4BAC1FA1744427054EA993565F6F3F82E5453170D" }, + { name = "telemetry", version = "1.4.1", build_tools = ["rebar3"], requirements = [], otp_app = "telemetry", source = "hex", outer_checksum = "2172E05A27531D3D31DD9782841065C50DD5C3C7699D95266B2EDD54C2DAFA1C" }, ] [requirements] diff --git a/modules/opensearch/manifest.toml b/modules/opensearch/manifest.toml index 3b92965..7b95d0e 100644 --- a/modules/opensearch/manifest.toml +++ b/modules/opensearch/manifest.toml @@ -2,17 +2,19 @@ # You typically do not need to edit this file packages = [ - { name = "dream_http_client", version = "5.0.0", build_tools = ["gleam"], requirements = ["gleam_crypto", "gleam_erlang", "gleam_http", "gleam_json", "gleam_otp", "gleam_stdlib", "gleam_yielder", "simplifile"], source = "local", path = "../http_client" }, + { name = "cowlib", version = "2.16.0", build_tools = ["make", "rebar3"], requirements = [], otp_app = "cowlib", source = "hex", outer_checksum = "7F478D80D66B747344F0EA7708C187645CFCC08B11AA424632F78E25BF05DB51" }, + { name = "dream_http_client", version = "5.2.0", build_tools = ["gleam"], requirements = ["gleam_crypto", "gleam_erlang", "gleam_http", "gleam_json", "gleam_otp", "gleam_stdlib", "gleam_yielder", "gun", "simplifile"], source = "local", path = "../http_client" }, { name = "filepath", version = "1.1.2", build_tools = ["gleam"], requirements = ["gleam_stdlib"], otp_app = "filepath", source = "hex", outer_checksum = "B06A9AF0BF10E51401D64B98E4B627F1D2E48C154967DA7AF4D0914780A6D40A" }, { name = "gleam_crypto", version = "1.5.1", build_tools = ["gleam"], requirements = ["gleam_stdlib"], otp_app = "gleam_crypto", source = "hex", outer_checksum = "50774BAFFF1144E7872814C566C5D653D83A3EBF23ACC3156B757A1B6819086E" }, { name = "gleam_erlang", version = "1.3.0", build_tools = ["gleam"], requirements = ["gleam_stdlib"], otp_app = "gleam_erlang", source = "hex", outer_checksum = "1124AD3AA21143E5AF0FC5CF3D9529F6DB8CA03E43A55711B60B6B7B3874375C" }, { name = "gleam_http", version = "4.3.0", build_tools = ["gleam"], requirements = ["gleam_stdlib"], otp_app = "gleam_http", source = "hex", outer_checksum = "82EA6A717C842456188C190AFB372665EA56CE13D8559BF3B1DD9E40F619EE0C" }, { name = "gleam_json", version = "3.1.0", build_tools = ["gleam"], requirements = ["gleam_stdlib"], otp_app = "gleam_json", source = "hex", outer_checksum = "44FDAA8847BE8FC48CA7A1C089706BD54BADCC4C45B237A992EDDF9F2CDB2836" }, { name = "gleam_otp", version = "1.2.0", build_tools = ["gleam"], requirements = ["gleam_erlang", "gleam_stdlib"], otp_app = "gleam_otp", source = "hex", outer_checksum = "BA6A294E295E428EC1562DC1C11EA7530DCB981E8359134BEABC8493B7B2258E" }, - { name = "gleam_stdlib", version = "0.67.0", build_tools = ["gleam"], requirements = [], otp_app = "gleam_stdlib", source = "hex", outer_checksum = "6368313DB35963DC02F677A513BB0D95D58A34ED0A9436C8116820BF94BE3511" }, + { name = "gleam_stdlib", version = "0.70.0", build_tools = ["gleam"], requirements = [], otp_app = "gleam_stdlib", source = "hex", outer_checksum = "86949BF5D1F0E4AC0AB5B06F235D8A5CC11A2DFC33BF22F752156ED61CA7D0FF" }, { name = "gleam_yielder", version = "1.1.0", build_tools = ["gleam"], requirements = ["gleam_stdlib"], otp_app = "gleam_yielder", source = "hex", outer_checksum = "8E4E4ECFA7982859F430C57F549200C7749823C106759F4A19A78AEA6687717A" }, { name = "gleeunit", version = "1.9.0", build_tools = ["gleam"], requirements = ["gleam_stdlib"], otp_app = "gleeunit", source = "hex", outer_checksum = "DA9553CE58B67924B3C631F96FE3370C49EB6D6DC6B384EC4862CC4AAA718F3C" }, - { name = "simplifile", version = "2.3.2", build_tools = ["gleam"], requirements = ["filepath", "gleam_stdlib"], otp_app = "simplifile", source = "hex", outer_checksum = "E049B4DACD4D206D87843BCF4C775A50AE0F50A52031A2FFB40C9ED07D6EC70A" }, + { name = "gun", version = "2.2.0", build_tools = ["make", "rebar3"], requirements = ["cowlib"], otp_app = "gun", source = "hex", outer_checksum = "76022700C64287FEB4DF93A1795CFF6741B83FB37415C40C34C38D2A4645261A" }, + { name = "simplifile", version = "2.4.0", build_tools = ["gleam"], requirements = ["filepath", "gleam_stdlib"], otp_app = "simplifile", source = "hex", outer_checksum = "7C18AFA4FED0B4CE1FA5B0B4BAC1FA1744427054EA993565F6F3F82E5453170D" }, ] [requirements] From 85ee26615fae0f849415c449dbe26975ed574c4b Mon Sep 17 00:00:00 2001 From: Dara Rockwell Date: Thu, 26 Mar 2026 05:43:58 -0600 Subject: [PATCH 4/7] feat: add HTTP/2 h2c server integration tests and upgrade mist dependency MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## Why This Change Was Made - After fixing mist's HTTP/2 frame handling for RFC 9113 compliance, dream needed its own server-side tests proving the full h2c pipeline works end-to-end - The mist dependency needed to move from a local path to the GitHub branch with the HTTP/2 fixes, so CI and collaborators can resolve it - dream_http_client switched to a local path dev-dependency to access the Http2Only protocol API (not yet published on Hex) ## What Was Changed - Added 6 BDD h2c integration tests in test/dream/servers/mist/h2c_test.gleam: GET, path params, JSON with content-type, 404 error routing, POST with body echo, concurrent multiplexed requests - Each test starts its own server on a distinct port (19980-19985) to avoid TCP TIME_WAIT conflicts - Switched mist dependency from `{ path = "../mist" }` to `{ git = "https://github.com/TrustBound/mist.git", ref = "fix/http2-support" }` - Switched dream_http_client dev-dep from Hex version to `{ path = "modules/http_client" }` for access to protocols(Http2Only) API - Fixed hooks.gleam: client.new -> client.new() and readiness check now treats ResponseError (HTTP 404) as "server is responding" - Fixed opensearch module and 4 examples to use new structured error types (RequestError.error: TransportError, StreamFailure) - Removed direct mist dependency from all 11 examples and regenerated manifests (they get mist transitively from dream) - Includes all dream_http_client v5.2.0 changes: structured error types, h2c protocol support, log level configuration ## Note to Future Engineer - Examples should NOT have mist as a direct dependency — they get it transitively through dream. Adding it back will cause dependency resolution failures when dream uses a git or path dep for mist - If "starts on expected port" test starts failing, check that is_server_responding handles ResponseError (404 from empty router) as a success signal - All code that pattern-matches on RequestError must use `error: TransportError` not `message: String`; use `client.transport_error_to_string()` for readable strings - stream_yielder now returns Result(BytesTree, StreamFailure) instead of Result(BytesTree, String); use `client.stream_failure_to_string()` to convert --- examples/custom_context/gleam.toml | 3 +- examples/custom_context/manifest.toml | 6 +- .../src/controllers/posts_controller.gleam | 7 +- examples/database/gleam.toml | 1 - examples/database/manifest.toml | 32 +- examples/multi_format/gleam.toml | 1 - examples/multi_format/manifest.toml | 30 +- examples/rate_limiter/gleam.toml | 1 - examples/rate_limiter/manifest.toml | 18 +- examples/simple/gleam.toml | 2 - examples/simple/manifest.toml | 6 +- .../src/controllers/posts_controller.gleam | 7 +- examples/simplest/gleam.toml | 2 - examples/simplest/manifest.toml | 17 +- examples/sse/gleam.toml | 1 - examples/sse/manifest.toml | 8 +- examples/static/gleam.toml | 1 - examples/static/manifest.toml | 16 +- examples/streaming/gleam.toml | 1 - examples/streaming/manifest.toml | 6 +- .../src/controllers/stream_controller.gleam | 10 +- examples/streaming_capabilities/manifest.toml | 5 +- .../src/controllers/stream_controller.gleam | 2 +- examples/tasks/gleam.toml | 1 - examples/tasks/manifest.toml | 36 +- examples/websocket_chat/gleam.toml | 1 - examples/websocket_chat/manifest.toml | 14 +- gleam.toml | 4 +- manifest.toml | 47 +- modules/http_client/CHANGELOG.md | 46 +- modules/http_client/manifest.toml | 5 +- modules/http_client/releases/release-5.2.0.md | 180 +++- .../src/dream_http_client/client.gleam | 799 ++++++++++++++---- .../dream_http_client_app.erl | 1 + .../dream_http_conn_manager.erl | 72 +- .../src/dream_http_client/dream_http_shim.erl | 217 ++--- .../src/dream_http_client/internal.gleam | 49 +- modules/http_client/test/client_test.gleam | 43 +- .../http_client/test/compression_test.gleam | 31 +- .../test/error_handling_test.gleam | 94 ++- .../test/ets_table_ownership_test.gleam | 17 +- modules/http_client/test/h2c_test.gleam | 390 +++++++++ .../test/recorder_client_test.gleam | 25 +- modules/http_client/test/redirect_test.gleam | 6 +- .../test/snippets/protocols_config.gleam | 14 + .../test/snippets/recording_playback.gleam | 8 +- .../test/snippets/stream_messages_basic.gleam | 6 +- .../http_client/test/start_stream_test.gleam | 4 +- .../test/stream_error_decode_test.gleam | 28 +- .../stream_non_streaming_response_test.gleam | 92 +- .../test/stream_yielder_completion_test.gleam | 14 +- .../test/transport_config_test.gleam | 36 +- .../src/dream_opensearch/client.gleam | 3 +- research/gun_research/gleam.toml | 11 + research/gun_research/manifest.toml | 16 + research/gun_research/src/gun_bench.erl | 545 ++++++++++++ research/gun_research/src/gun_research.gleam | 3 + .../gun_research/test/gun_research_test.gleam | 10 + .../.github/workflows/test.yml | 23 + research/hackney_research/.gitignore | 4 + research/hackney_research/README.md | 24 + research/hackney_research/gleam.toml | 11 + research/hackney_research/manifest.toml | 21 + .../hackney_research/src/hackney_bench.erl | 384 +++++++++ .../src/hackney_research.gleam | 5 + .../test/hackney_research_test.gleam | 10 + src/dream/servers/mist/request.gleam | 10 +- src/dream/servers/mist/response.gleam | 8 +- src/dream/servers/mist/sse.gleam | 11 +- test/dream/servers/mist/h2c_test.gleam | 353 ++++++++ test/dream_test.gleam | 2 + test/fixtures/hooks.gleam | 4 +- 72 files changed, 3339 insertions(+), 582 deletions(-) create mode 100644 modules/http_client/test/h2c_test.gleam create mode 100644 modules/http_client/test/snippets/protocols_config.gleam create mode 100644 research/gun_research/gleam.toml create mode 100644 research/gun_research/manifest.toml create mode 100644 research/gun_research/src/gun_bench.erl create mode 100644 research/gun_research/src/gun_research.gleam create mode 100644 research/gun_research/test/gun_research_test.gleam create mode 100644 research/hackney_research/.github/workflows/test.yml create mode 100644 research/hackney_research/.gitignore create mode 100644 research/hackney_research/README.md create mode 100644 research/hackney_research/gleam.toml create mode 100644 research/hackney_research/manifest.toml create mode 100644 research/hackney_research/src/hackney_bench.erl create mode 100644 research/hackney_research/src/hackney_research.gleam create mode 100644 research/hackney_research/test/hackney_research_test.gleam create mode 100644 test/dream/servers/mist/h2c_test.gleam diff --git a/examples/custom_context/gleam.toml b/examples/custom_context/gleam.toml index 37f0696..13a68ed 100644 --- a/examples/custom_context/gleam.toml +++ b/examples/custom_context/gleam.toml @@ -7,5 +7,4 @@ licences = ["MIT"] dream = { path = "../.." } dream_http_client = { path = "../../modules/http_client" } gleam_http = ">= 4.3.0 and < 5.0.0" -gleam_stdlib = ">= 0.44.0 and < 2.0.0" -mist = ">= 5.0.3 and < 6.0.0" +gleam_stdlib = ">= 0.44.0 and < 2.0.0" \ No newline at end of file diff --git a/examples/custom_context/manifest.toml b/examples/custom_context/manifest.toml index 2ebc526..061c814 100644 --- a/examples/custom_context/manifest.toml +++ b/examples/custom_context/manifest.toml @@ -15,15 +15,14 @@ packages = [ { name = "gleam_stdlib", version = "0.70.0", build_tools = ["gleam"], requirements = [], otp_app = "gleam_stdlib", source = "hex", outer_checksum = "86949BF5D1F0E4AC0AB5B06F235D8A5CC11A2DFC33BF22F752156ED61CA7D0FF" }, { name = "gleam_time", version = "1.7.0", build_tools = ["gleam"], requirements = ["gleam_stdlib"], otp_app = "gleam_time", source = "hex", outer_checksum = "56DB0EF9433826D3B99DB0B4AF7A2BFED13D09755EC64B1DAAB46F804A9AD47D" }, { name = "gleam_yielder", version = "1.1.0", build_tools = ["gleam"], requirements = ["gleam_stdlib"], otp_app = "gleam_yielder", source = "hex", outer_checksum = "8E4E4ECFA7982859F430C57F549200C7749823C106759F4A19A78AEA6687717A" }, - { name = "glisten", version = "8.0.3", build_tools = ["gleam"], requirements = ["gleam_erlang", "gleam_otp", "gleam_stdlib", "logging", "telemetry"], otp_app = "glisten", source = "hex", outer_checksum = "86B838196592D9EBDE7A1D2369AE3A51E568F7DD2D168706C463C42D17B95312" }, + { name = "glisten", version = "9.0.0", build_tools = ["gleam"], requirements = ["gleam_erlang", "gleam_otp", "gleam_stdlib", "logging"], otp_app = "glisten", source = "hex", outer_checksum = "D92808C66F7D3F22F2289CD04CBA8151757AAE9CB3D86992F0C6DE32A41205E1" }, { name = "gramps", version = "6.0.0", build_tools = ["gleam"], requirements = ["gleam_crypto", "gleam_erlang", "gleam_http", "gleam_stdlib"], otp_app = "gramps", source = "hex", outer_checksum = "8B7195978FBFD30B43DF791A8A272041B81E45D245314D7A41FC57237AA882A0" }, { name = "gun", version = "2.2.0", build_tools = ["make", "rebar3"], requirements = ["cowlib"], otp_app = "gun", source = "hex", outer_checksum = "76022700C64287FEB4DF93A1795CFF6741B83FB37415C40C34C38D2A4645261A" }, { name = "hpack_erl", version = "0.3.0", build_tools = ["rebar3"], requirements = [], otp_app = "hpack", source = "hex", outer_checksum = "D6137D7079169D8C485C6962DFE261AF5B9EF60FBC557344511C1E65E3D95FB0" }, { name = "logging", version = "1.3.0", build_tools = ["gleam"], requirements = ["gleam_stdlib"], otp_app = "logging", source = "hex", outer_checksum = "1098FBF10B54B44C2C7FDF0B01C1253CAFACDACABEFB4B0D027803246753E06D" }, { name = "marceau", version = "1.3.0", build_tools = ["gleam"], requirements = [], otp_app = "marceau", source = "hex", outer_checksum = "2D1C27504BEF45005F5DFB18591F8610FB4BFA91744878210BDC464412EC44E9" }, - { name = "mist", version = "5.0.4", build_tools = ["gleam"], requirements = ["exception", "gleam_erlang", "gleam_http", "gleam_otp", "gleam_stdlib", "gleam_yielder", "glisten", "gramps", "hpack_erl", "logging"], otp_app = "mist", source = "hex", outer_checksum = "7CED4B2D81FD547ADB093D97B9928B9419A7F58B8562A30A6CC17A252B31AD05" }, + { name = "mist", version = "6.0.2", build_tools = ["gleam"], requirements = ["exception", "gleam_erlang", "gleam_http", "gleam_otp", "gleam_stdlib", "gleam_yielder", "glisten", "gramps", "hpack_erl", "logging"], source = "git", repo = "https://github.com/TrustBound/mist.git", commit = "ae358876d2d6763e8febb977221797fc42bffcc0" }, { name = "simplifile", version = "2.4.0", build_tools = ["gleam"], requirements = ["filepath", "gleam_stdlib"], otp_app = "simplifile", source = "hex", outer_checksum = "7C18AFA4FED0B4CE1FA5B0B4BAC1FA1744427054EA993565F6F3F82E5453170D" }, - { name = "telemetry", version = "1.4.1", build_tools = ["rebar3"], requirements = [], otp_app = "telemetry", source = "hex", outer_checksum = "2172E05A27531D3D31DD9782841065C50DD5C3C7699D95266B2EDD54C2DAFA1C" }, ] [requirements] @@ -31,4 +30,3 @@ dream = { path = "../.." } dream_http_client = { path = "../../modules/http_client" } gleam_http = { version = ">= 4.3.0 and < 5.0.0" } gleam_stdlib = { version = ">= 0.44.0 and < 2.0.0" } -mist = { version = ">= 5.0.3 and < 6.0.0" } diff --git a/examples/custom_context/src/controllers/posts_controller.gleam b/examples/custom_context/src/controllers/posts_controller.gleam index 22466ae..92a426d 100644 --- a/examples/custom_context/src/controllers/posts_controller.gleam +++ b/examples/custom_context/src/controllers/posts_controller.gleam @@ -56,7 +56,10 @@ fn make_request_and_respond(user_id: String, post_id: String) -> Response { text_response(status.ok, post_view.format_show(user_id, post_id, body)) Error(client.ResponseError(response: client.HttpResponse(body: body, ..))) -> text_response(status.internal_server_error, post_view.format_error(body)) - Error(client.RequestError(message: error)) -> - text_response(status.internal_server_error, post_view.format_error(error)) + Error(client.RequestError(error: transport_error)) -> + text_response( + status.internal_server_error, + post_view.format_error(client.transport_error_to_string(transport_error)), + ) } } diff --git a/examples/database/gleam.toml b/examples/database/gleam.toml index fe53f52..18703e5 100644 --- a/examples/database/gleam.toml +++ b/examples/database/gleam.toml @@ -7,7 +7,6 @@ licences = ["MIT"] dream = { path = "../.." } dream_json = { path = "../../modules/json" } dream_postgres = { path = "../../modules/postgres" } -mist = ">= 5.0.3 and < 6.0.0" pog = ">= 4.0.0 and < 5.0.0" cigogne = ">= 5.0.0 and < 6.0.0" gleam_json = ">= 2.2.0 and < 4.0.0" diff --git a/examples/database/manifest.toml b/examples/database/manifest.toml index e03c06f..421607c 100644 --- a/examples/database/manifest.toml +++ b/examples/database/manifest.toml @@ -4,11 +4,11 @@ packages = [ { name = "argv", version = "1.0.2", build_tools = ["gleam"], requirements = [], otp_app = "argv", source = "hex", outer_checksum = "BA1FF0929525DEBA1CE67256E5ADF77A7CDDFE729E3E3F57A5BDCAA031DED09D" }, { name = "backoff", version = "1.1.6", build_tools = ["rebar3"], requirements = [], otp_app = "backoff", source = "hex", outer_checksum = "CF0CFFF8995FB20562F822E5CC47D8CCF664C5ECDC26A684CBE85C225F9D7C39" }, - { name = "cigogne", version = "5.0.4", build_tools = ["gleam"], requirements = ["argv", "envoy", "gleam_crypto", "gleam_erlang", "gleam_otp", "gleam_stdlib", "gleam_time", "pog", "simplifile", "splitter", "tom"], otp_app = "cigogne", source = "hex", outer_checksum = "2138362840E0773213C5811DD6FB43B59E44F0DA3DF9FF2882223C789207C201" }, - { name = "dream", version = "0.0.1", build_tools = ["gleam"], requirements = ["gleam_erlang", "gleam_http", "gleam_json", "gleam_otp", "gleam_stdlib", "gleam_time", "gleam_yielder", "marceau", "mist", "simplifile"], source = "local", path = "../.." }, - { name = "dream_json", version = "0.1.0", build_tools = ["gleam"], requirements = ["gleam_json", "gleam_stdlib", "gleam_time"], source = "local", path = "../../modules/json" }, - { name = "dream_postgres", version = "0.1.0", build_tools = ["gleam"], requirements = ["gleam_erlang", "gleam_otp", "gleam_stdlib", "pog"], source = "local", path = "../../modules/postgres" }, - { name = "envoy", version = "1.0.2", build_tools = ["gleam"], requirements = ["gleam_stdlib"], otp_app = "envoy", source = "hex", outer_checksum = "95FD059345AA982E89A0B6E2A3BF1CF43E17A7048DCD85B5B65D3B9E4E39D359" }, + { name = "cigogne", version = "5.0.5", build_tools = ["gleam"], requirements = ["argv", "envoy", "gleam_crypto", "gleam_erlang", "gleam_otp", "gleam_stdlib", "gleam_time", "pog", "simplifile", "splitter", "tom"], otp_app = "cigogne", source = "hex", outer_checksum = "C65012988BDFE143D78DDFE65B1DC6B720E8F6E2FC6CD87959376002886D78E8" }, + { name = "dream", version = "2.4.1", build_tools = ["gleam"], requirements = ["gleam_erlang", "gleam_http", "gleam_json", "gleam_otp", "gleam_stdlib", "gleam_time", "gleam_yielder", "marceau", "mist", "simplifile"], source = "local", path = "../.." }, + { name = "dream_json", version = "1.0.2", build_tools = ["gleam"], requirements = ["gleam_json", "gleam_stdlib", "gleam_time"], source = "local", path = "../../modules/json" }, + { name = "dream_postgres", version = "1.0.2", build_tools = ["gleam"], requirements = ["gleam_erlang", "gleam_otp", "gleam_stdlib", "pog"], source = "local", path = "../../modules/postgres" }, + { name = "envoy", version = "1.1.0", build_tools = ["gleam"], requirements = ["gleam_stdlib"], otp_app = "envoy", source = "hex", outer_checksum = "850DA9D29D2E5987735872A2B5C81035146D7FE19EFC486129E44440D03FD832" }, { name = "exception", version = "2.1.0", build_tools = ["gleam"], requirements = ["gleam_stdlib"], otp_app = "exception", source = "hex", outer_checksum = "329D269D5C2A314F7364BD2711372B6F2C58FA6F39981572E5CA68624D291F8C" }, { name = "filepath", version = "1.1.2", build_tools = ["gleam"], requirements = ["gleam_stdlib"], otp_app = "filepath", source = "hex", outer_checksum = "B06A9AF0BF10E51401D64B98E4B627F1D2E48C154967DA7AF4D0914780A6D40A" }, { name = "gleam_crypto", version = "1.5.1", build_tools = ["gleam"], requirements = ["gleam_stdlib"], otp_app = "gleam_crypto", source = "hex", outer_checksum = "50774BAFFF1144E7872814C566C5D653D83A3EBF23ACC3156B757A1B6819086E" }, @@ -16,23 +16,22 @@ packages = [ { name = "gleam_http", version = "4.3.0", build_tools = ["gleam"], requirements = ["gleam_stdlib"], otp_app = "gleam_http", source = "hex", outer_checksum = "82EA6A717C842456188C190AFB372665EA56CE13D8559BF3B1DD9E40F619EE0C" }, { name = "gleam_json", version = "3.1.0", build_tools = ["gleam"], requirements = ["gleam_stdlib"], otp_app = "gleam_json", source = "hex", outer_checksum = "44FDAA8847BE8FC48CA7A1C089706BD54BADCC4C45B237A992EDDF9F2CDB2836" }, { name = "gleam_otp", version = "1.2.0", build_tools = ["gleam"], requirements = ["gleam_erlang", "gleam_stdlib"], otp_app = "gleam_otp", source = "hex", outer_checksum = "BA6A294E295E428EC1562DC1C11EA7530DCB981E8359134BEABC8493B7B2258E" }, - { name = "gleam_stdlib", version = "0.65.0", build_tools = ["gleam"], requirements = [], otp_app = "gleam_stdlib", source = "hex", outer_checksum = "7C69C71D8C493AE11A5184828A77110EB05A7786EBF8B25B36A72F879C3EE107" }, - { name = "gleam_time", version = "1.5.0", build_tools = ["gleam"], requirements = ["gleam_stdlib"], otp_app = "gleam_time", source = "hex", outer_checksum = "D560E672C7279C89908981E068DF07FD16D0C859DCA266F908B18F04DF0EB8E6" }, + { name = "gleam_stdlib", version = "0.70.0", build_tools = ["gleam"], requirements = [], otp_app = "gleam_stdlib", source = "hex", outer_checksum = "86949BF5D1F0E4AC0AB5B06F235D8A5CC11A2DFC33BF22F752156ED61CA7D0FF" }, + { name = "gleam_time", version = "1.7.0", build_tools = ["gleam"], requirements = ["gleam_stdlib"], otp_app = "gleam_time", source = "hex", outer_checksum = "56DB0EF9433826D3B99DB0B4AF7A2BFED13D09755EC64B1DAAB46F804A9AD47D" }, { name = "gleam_yielder", version = "1.1.0", build_tools = ["gleam"], requirements = ["gleam_stdlib"], otp_app = "gleam_yielder", source = "hex", outer_checksum = "8E4E4ECFA7982859F430C57F549200C7749823C106759F4A19A78AEA6687717A" }, - { name = "glisten", version = "8.0.1", build_tools = ["gleam"], requirements = ["gleam_erlang", "gleam_otp", "gleam_stdlib", "logging", "telemetry"], otp_app = "glisten", source = "hex", outer_checksum = "534BB27C71FB9E506345A767C0D76B17A9E9199934340C975DC003C710E3692D" }, + { name = "glisten", version = "9.0.0", build_tools = ["gleam"], requirements = ["gleam_erlang", "gleam_otp", "gleam_stdlib", "logging"], otp_app = "glisten", source = "hex", outer_checksum = "D92808C66F7D3F22F2289CD04CBA8151757AAE9CB3D86992F0C6DE32A41205E1" }, { name = "gramps", version = "6.0.0", build_tools = ["gleam"], requirements = ["gleam_crypto", "gleam_erlang", "gleam_http", "gleam_stdlib"], otp_app = "gramps", source = "hex", outer_checksum = "8B7195978FBFD30B43DF791A8A272041B81E45D245314D7A41FC57237AA882A0" }, { name = "hpack_erl", version = "0.3.0", build_tools = ["rebar3"], requirements = [], otp_app = "hpack", source = "hex", outer_checksum = "D6137D7079169D8C485C6962DFE261AF5B9EF60FBC557344511C1E65E3D95FB0" }, { name = "logging", version = "1.3.0", build_tools = ["gleam"], requirements = ["gleam_stdlib"], otp_app = "logging", source = "hex", outer_checksum = "1098FBF10B54B44C2C7FDF0B01C1253CAFACDACABEFB4B0D027803246753E06D" }, { name = "marceau", version = "1.3.0", build_tools = ["gleam"], requirements = [], otp_app = "marceau", source = "hex", outer_checksum = "2D1C27504BEF45005F5DFB18591F8610FB4BFA91744878210BDC464412EC44E9" }, - { name = "mist", version = "5.0.3", build_tools = ["gleam"], requirements = ["exception", "gleam_erlang", "gleam_http", "gleam_otp", "gleam_stdlib", "gleam_yielder", "glisten", "gramps", "hpack_erl", "logging"], otp_app = "mist", source = "hex", outer_checksum = "7C4BE717A81305323C47C8A591E6B9BA4AC7F56354BF70B4D3DF08CC01192668" }, - { name = "opentelemetry_api", version = "1.4.1", build_tools = ["rebar3", "mix"], requirements = [], otp_app = "opentelemetry_api", source = "hex", outer_checksum = "39BDB6AD740BC13B16215CB9F233D66796BBAE897F3BF6EB77ABB712E87C3C26" }, - { name = "pg_types", version = "0.5.0", build_tools = ["rebar3"], requirements = [], otp_app = "pg_types", source = "hex", outer_checksum = "A3023B464AA960BC1628635081E30CCA4F676F2D4C23CCD6179C1C11C9B4A642" }, - { name = "pgo", version = "0.15.0", build_tools = ["rebar3"], requirements = ["backoff", "opentelemetry_api", "pg_types"], otp_app = "pgo", source = "hex", outer_checksum = "4B883D751B8D4247F4D8A6FBAE58EDB6FA9DBC183371299BF84975EE36406388" }, + { name = "mist", version = "6.0.2", build_tools = ["gleam"], requirements = ["exception", "gleam_erlang", "gleam_http", "gleam_otp", "gleam_stdlib", "gleam_yielder", "glisten", "gramps", "hpack_erl", "logging"], source = "git", repo = "https://github.com/TrustBound/mist.git", commit = "ae358876d2d6763e8febb977221797fc42bffcc0" }, + { name = "opentelemetry_api", version = "1.5.0", build_tools = ["rebar3", "mix"], requirements = [], otp_app = "opentelemetry_api", source = "hex", outer_checksum = "F53EC8A1337AE4A487D43AC89DA4BD3A3C99DDF576655D071DEED8B56A2D5DDA" }, + { name = "pg_types", version = "0.6.0", build_tools = ["rebar3"], requirements = [], otp_app = "pg_types", source = "hex", outer_checksum = "9949A4849DD13408FA249AB7B745E0D2DFDB9532AEE2B9722326E33CD082A778" }, + { name = "pgo", version = "0.20.0", build_tools = ["rebar3"], requirements = ["backoff", "opentelemetry_api", "pg_types"], otp_app = "pgo", source = "hex", outer_checksum = "2F11E6649CEB38E569EF56B16BE1D04874AE5B11A02867080A2817CE423C683B" }, { name = "pog", version = "4.1.0", build_tools = ["gleam"], requirements = ["exception", "gleam_erlang", "gleam_otp", "gleam_stdlib", "gleam_time", "pgo"], otp_app = "pog", source = "hex", outer_checksum = "E4AFBA39A5FAA2E77291836C9683ADE882E65A06AB28CA7D61AE7A3AD61EBBD5" }, - { name = "simplifile", version = "2.3.1", build_tools = ["gleam"], requirements = ["filepath", "gleam_stdlib"], otp_app = "simplifile", source = "hex", outer_checksum = "957E0E5B75927659F1D2A1B7B75D7B9BA96FAA8D0C53EA71C4AD9CD0C6B848F6" }, - { name = "splitter", version = "1.1.0", build_tools = ["gleam"], requirements = ["gleam_stdlib"], otp_app = "splitter", source = "hex", outer_checksum = "05564A381580395DCDEFF4F88A64B021E8DAFA6540AE99B4623962F52976AA9D" }, - { name = "telemetry", version = "1.3.0", build_tools = ["rebar3"], requirements = [], otp_app = "telemetry", source = "hex", outer_checksum = "7015FC8919DBE63764F4B4B87A95B7C0996BD539E0D499BE6EC9D7F3875B79E6" }, - { name = "tom", version = "2.0.0", build_tools = ["gleam"], requirements = ["gleam_stdlib", "gleam_time"], otp_app = "tom", source = "hex", outer_checksum = "74D0C5A3761F7A7D06994755D4D5AD854122EF8E9F9F76A3E7547606D8C77091" }, + { name = "simplifile", version = "2.4.0", build_tools = ["gleam"], requirements = ["filepath", "gleam_stdlib"], otp_app = "simplifile", source = "hex", outer_checksum = "7C18AFA4FED0B4CE1FA5B0B4BAC1FA1744427054EA993565F6F3F82E5453170D" }, + { name = "splitter", version = "1.2.0", build_tools = ["gleam"], requirements = ["gleam_stdlib"], otp_app = "splitter", source = "hex", outer_checksum = "3DFD6B6C49E61EDAF6F7B27A42054A17CFF6CA2135FF553D0CB61C234D281DD0" }, + { name = "tom", version = "2.0.1", build_tools = ["gleam"], requirements = ["gleam_stdlib", "gleam_time"], otp_app = "tom", source = "hex", outer_checksum = "90791DA4AACE637E30081FE77049B8DB850FBC8CACC31515376BCC4E59BE1DD2" }, ] [requirements] @@ -45,5 +44,4 @@ gleam_json = { version = ">= 2.2.0 and < 4.0.0" } gleam_otp = { version = ">= 1.2.0 and < 2.0.0" } gleam_stdlib = { version = ">= 0.44.0 and < 2.0.0" } gleam_time = { version = ">= 1.5.0 and < 2.0.0" } -mist = { version = ">= 5.0.3 and < 6.0.0" } pog = { version = ">= 4.0.0 and < 5.0.0" } diff --git a/examples/multi_format/gleam.toml b/examples/multi_format/gleam.toml index e4d6d4f..86f9099 100644 --- a/examples/multi_format/gleam.toml +++ b/examples/multi_format/gleam.toml @@ -6,7 +6,6 @@ licences = ["MIT"] [dependencies] dream = { path = "../.." } dream_postgres = { path = "../../modules/postgres" } -mist = ">= 5.0.3 and < 6.0.0" pog = ">= 4.0.0 and < 5.0.0" marceau = ">= 1.3.0 and < 2.0.0" cigogne = ">= 5.0.0 and < 6.0.0" diff --git a/examples/multi_format/manifest.toml b/examples/multi_format/manifest.toml index bbb3441..29ff60a 100644 --- a/examples/multi_format/manifest.toml +++ b/examples/multi_format/manifest.toml @@ -4,10 +4,10 @@ packages = [ { name = "argv", version = "1.0.2", build_tools = ["gleam"], requirements = [], otp_app = "argv", source = "hex", outer_checksum = "BA1FF0929525DEBA1CE67256E5ADF77A7CDDFE729E3E3F57A5BDCAA031DED09D" }, { name = "backoff", version = "1.1.6", build_tools = ["rebar3"], requirements = [], otp_app = "backoff", source = "hex", outer_checksum = "CF0CFFF8995FB20562F822E5CC47D8CCF664C5ECDC26A684CBE85C225F9D7C39" }, - { name = "cigogne", version = "5.0.4", build_tools = ["gleam"], requirements = ["argv", "envoy", "gleam_crypto", "gleam_erlang", "gleam_otp", "gleam_stdlib", "gleam_time", "pog", "simplifile", "splitter", "tom"], otp_app = "cigogne", source = "hex", outer_checksum = "2138362840E0773213C5811DD6FB43B59E44F0DA3DF9FF2882223C789207C201" }, - { name = "dream", version = "0.0.1", build_tools = ["gleam"], requirements = ["gleam_erlang", "gleam_http", "gleam_json", "gleam_otp", "gleam_stdlib", "gleam_time", "gleam_yielder", "marceau", "mist", "simplifile"], source = "local", path = "../.." }, - { name = "dream_postgres", version = "0.1.0", build_tools = ["gleam"], requirements = ["gleam_erlang", "gleam_otp", "gleam_stdlib", "pog"], source = "local", path = "../../modules/postgres" }, - { name = "envoy", version = "1.0.2", build_tools = ["gleam"], requirements = ["gleam_stdlib"], otp_app = "envoy", source = "hex", outer_checksum = "95FD059345AA982E89A0B6E2A3BF1CF43E17A7048DCD85B5B65D3B9E4E39D359" }, + { name = "cigogne", version = "5.0.5", build_tools = ["gleam"], requirements = ["argv", "envoy", "gleam_crypto", "gleam_erlang", "gleam_otp", "gleam_stdlib", "gleam_time", "pog", "simplifile", "splitter", "tom"], otp_app = "cigogne", source = "hex", outer_checksum = "C65012988BDFE143D78DDFE65B1DC6B720E8F6E2FC6CD87959376002886D78E8" }, + { name = "dream", version = "2.4.1", build_tools = ["gleam"], requirements = ["gleam_erlang", "gleam_http", "gleam_json", "gleam_otp", "gleam_stdlib", "gleam_time", "gleam_yielder", "marceau", "mist", "simplifile"], source = "local", path = "../.." }, + { name = "dream_postgres", version = "1.0.2", build_tools = ["gleam"], requirements = ["gleam_erlang", "gleam_otp", "gleam_stdlib", "pog"], source = "local", path = "../../modules/postgres" }, + { name = "envoy", version = "1.1.0", build_tools = ["gleam"], requirements = ["gleam_stdlib"], otp_app = "envoy", source = "hex", outer_checksum = "850DA9D29D2E5987735872A2B5C81035146D7FE19EFC486129E44440D03FD832" }, { name = "exception", version = "2.1.0", build_tools = ["gleam"], requirements = ["gleam_stdlib"], otp_app = "exception", source = "hex", outer_checksum = "329D269D5C2A314F7364BD2711372B6F2C58FA6F39981572E5CA68624D291F8C" }, { name = "filepath", version = "1.1.2", build_tools = ["gleam"], requirements = ["gleam_stdlib"], otp_app = "filepath", source = "hex", outer_checksum = "B06A9AF0BF10E51401D64B98E4B627F1D2E48C154967DA7AF4D0914780A6D40A" }, { name = "gleam_crypto", version = "1.5.1", build_tools = ["gleam"], requirements = ["gleam_stdlib"], otp_app = "gleam_crypto", source = "hex", outer_checksum = "50774BAFFF1144E7872814C566C5D653D83A3EBF23ACC3156B757A1B6819086E" }, @@ -15,23 +15,22 @@ packages = [ { name = "gleam_http", version = "4.3.0", build_tools = ["gleam"], requirements = ["gleam_stdlib"], otp_app = "gleam_http", source = "hex", outer_checksum = "82EA6A717C842456188C190AFB372665EA56CE13D8559BF3B1DD9E40F619EE0C" }, { name = "gleam_json", version = "3.1.0", build_tools = ["gleam"], requirements = ["gleam_stdlib"], otp_app = "gleam_json", source = "hex", outer_checksum = "44FDAA8847BE8FC48CA7A1C089706BD54BADCC4C45B237A992EDDF9F2CDB2836" }, { name = "gleam_otp", version = "1.2.0", build_tools = ["gleam"], requirements = ["gleam_erlang", "gleam_stdlib"], otp_app = "gleam_otp", source = "hex", outer_checksum = "BA6A294E295E428EC1562DC1C11EA7530DCB981E8359134BEABC8493B7B2258E" }, - { name = "gleam_stdlib", version = "0.65.0", build_tools = ["gleam"], requirements = [], otp_app = "gleam_stdlib", source = "hex", outer_checksum = "7C69C71D8C493AE11A5184828A77110EB05A7786EBF8B25B36A72F879C3EE107" }, - { name = "gleam_time", version = "1.5.0", build_tools = ["gleam"], requirements = ["gleam_stdlib"], otp_app = "gleam_time", source = "hex", outer_checksum = "D560E672C7279C89908981E068DF07FD16D0C859DCA266F908B18F04DF0EB8E6" }, + { name = "gleam_stdlib", version = "0.70.0", build_tools = ["gleam"], requirements = [], otp_app = "gleam_stdlib", source = "hex", outer_checksum = "86949BF5D1F0E4AC0AB5B06F235D8A5CC11A2DFC33BF22F752156ED61CA7D0FF" }, + { name = "gleam_time", version = "1.7.0", build_tools = ["gleam"], requirements = ["gleam_stdlib"], otp_app = "gleam_time", source = "hex", outer_checksum = "56DB0EF9433826D3B99DB0B4AF7A2BFED13D09755EC64B1DAAB46F804A9AD47D" }, { name = "gleam_yielder", version = "1.1.0", build_tools = ["gleam"], requirements = ["gleam_stdlib"], otp_app = "gleam_yielder", source = "hex", outer_checksum = "8E4E4ECFA7982859F430C57F549200C7749823C106759F4A19A78AEA6687717A" }, - { name = "glisten", version = "8.0.1", build_tools = ["gleam"], requirements = ["gleam_erlang", "gleam_otp", "gleam_stdlib", "logging", "telemetry"], otp_app = "glisten", source = "hex", outer_checksum = "534BB27C71FB9E506345A767C0D76B17A9E9199934340C975DC003C710E3692D" }, + { name = "glisten", version = "9.0.0", build_tools = ["gleam"], requirements = ["gleam_erlang", "gleam_otp", "gleam_stdlib", "logging"], otp_app = "glisten", source = "hex", outer_checksum = "D92808C66F7D3F22F2289CD04CBA8151757AAE9CB3D86992F0C6DE32A41205E1" }, { name = "gramps", version = "6.0.0", build_tools = ["gleam"], requirements = ["gleam_crypto", "gleam_erlang", "gleam_http", "gleam_stdlib"], otp_app = "gramps", source = "hex", outer_checksum = "8B7195978FBFD30B43DF791A8A272041B81E45D245314D7A41FC57237AA882A0" }, { name = "hpack_erl", version = "0.3.0", build_tools = ["rebar3"], requirements = [], otp_app = "hpack", source = "hex", outer_checksum = "D6137D7079169D8C485C6962DFE261AF5B9EF60FBC557344511C1E65E3D95FB0" }, { name = "logging", version = "1.3.0", build_tools = ["gleam"], requirements = ["gleam_stdlib"], otp_app = "logging", source = "hex", outer_checksum = "1098FBF10B54B44C2C7FDF0B01C1253CAFACDACABEFB4B0D027803246753E06D" }, { name = "marceau", version = "1.3.0", build_tools = ["gleam"], requirements = [], otp_app = "marceau", source = "hex", outer_checksum = "2D1C27504BEF45005F5DFB18591F8610FB4BFA91744878210BDC464412EC44E9" }, - { name = "mist", version = "5.0.3", build_tools = ["gleam"], requirements = ["exception", "gleam_erlang", "gleam_http", "gleam_otp", "gleam_stdlib", "gleam_yielder", "glisten", "gramps", "hpack_erl", "logging"], otp_app = "mist", source = "hex", outer_checksum = "7C4BE717A81305323C47C8A591E6B9BA4AC7F56354BF70B4D3DF08CC01192668" }, - { name = "opentelemetry_api", version = "1.4.1", build_tools = ["rebar3", "mix"], requirements = [], otp_app = "opentelemetry_api", source = "hex", outer_checksum = "39BDB6AD740BC13B16215CB9F233D66796BBAE897F3BF6EB77ABB712E87C3C26" }, - { name = "pg_types", version = "0.5.0", build_tools = ["rebar3"], requirements = [], otp_app = "pg_types", source = "hex", outer_checksum = "A3023B464AA960BC1628635081E30CCA4F676F2D4C23CCD6179C1C11C9B4A642" }, - { name = "pgo", version = "0.15.0", build_tools = ["rebar3"], requirements = ["backoff", "opentelemetry_api", "pg_types"], otp_app = "pgo", source = "hex", outer_checksum = "4B883D751B8D4247F4D8A6FBAE58EDB6FA9DBC183371299BF84975EE36406388" }, + { name = "mist", version = "6.0.2", build_tools = ["gleam"], requirements = ["exception", "gleam_erlang", "gleam_http", "gleam_otp", "gleam_stdlib", "gleam_yielder", "glisten", "gramps", "hpack_erl", "logging"], source = "git", repo = "https://github.com/TrustBound/mist.git", commit = "ae358876d2d6763e8febb977221797fc42bffcc0" }, + { name = "opentelemetry_api", version = "1.5.0", build_tools = ["rebar3", "mix"], requirements = [], otp_app = "opentelemetry_api", source = "hex", outer_checksum = "F53EC8A1337AE4A487D43AC89DA4BD3A3C99DDF576655D071DEED8B56A2D5DDA" }, + { name = "pg_types", version = "0.6.0", build_tools = ["rebar3"], requirements = [], otp_app = "pg_types", source = "hex", outer_checksum = "9949A4849DD13408FA249AB7B745E0D2DFDB9532AEE2B9722326E33CD082A778" }, + { name = "pgo", version = "0.20.0", build_tools = ["rebar3"], requirements = ["backoff", "opentelemetry_api", "pg_types"], otp_app = "pgo", source = "hex", outer_checksum = "2F11E6649CEB38E569EF56B16BE1D04874AE5B11A02867080A2817CE423C683B" }, { name = "pog", version = "4.1.0", build_tools = ["gleam"], requirements = ["exception", "gleam_erlang", "gleam_otp", "gleam_stdlib", "gleam_time", "pgo"], otp_app = "pog", source = "hex", outer_checksum = "E4AFBA39A5FAA2E77291836C9683ADE882E65A06AB28CA7D61AE7A3AD61EBBD5" }, - { name = "simplifile", version = "2.3.1", build_tools = ["gleam"], requirements = ["filepath", "gleam_stdlib"], otp_app = "simplifile", source = "hex", outer_checksum = "957E0E5B75927659F1D2A1B7B75D7B9BA96FAA8D0C53EA71C4AD9CD0C6B848F6" }, - { name = "splitter", version = "1.1.0", build_tools = ["gleam"], requirements = ["gleam_stdlib"], otp_app = "splitter", source = "hex", outer_checksum = "05564A381580395DCDEFF4F88A64B021E8DAFA6540AE99B4623962F52976AA9D" }, - { name = "telemetry", version = "1.3.0", build_tools = ["rebar3"], requirements = [], otp_app = "telemetry", source = "hex", outer_checksum = "7015FC8919DBE63764F4B4B87A95B7C0996BD539E0D499BE6EC9D7F3875B79E6" }, - { name = "tom", version = "2.0.0", build_tools = ["gleam"], requirements = ["gleam_stdlib", "gleam_time"], otp_app = "tom", source = "hex", outer_checksum = "74D0C5A3761F7A7D06994755D4D5AD854122EF8E9F9F76A3E7547606D8C77091" }, + { name = "simplifile", version = "2.4.0", build_tools = ["gleam"], requirements = ["filepath", "gleam_stdlib"], otp_app = "simplifile", source = "hex", outer_checksum = "7C18AFA4FED0B4CE1FA5B0B4BAC1FA1744427054EA993565F6F3F82E5453170D" }, + { name = "splitter", version = "1.2.0", build_tools = ["gleam"], requirements = ["gleam_stdlib"], otp_app = "splitter", source = "hex", outer_checksum = "3DFD6B6C49E61EDAF6F7B27A42054A17CFF6CA2135FF553D0CB61C234D281DD0" }, + { name = "tom", version = "2.0.1", build_tools = ["gleam"], requirements = ["gleam_stdlib", "gleam_time"], otp_app = "tom", source = "hex", outer_checksum = "90791DA4AACE637E30081FE77049B8DB850FBC8CACC31515376BCC4E59BE1DD2" }, ] [requirements] @@ -45,5 +44,4 @@ gleam_stdlib = { version = ">= 0.44.0 and < 2.0.0" } gleam_time = { version = ">= 1.5.0 and < 2.0.0" } gleam_yielder = { version = ">= 1.1.0 and < 2.0.0" } marceau = { version = ">= 1.3.0 and < 2.0.0" } -mist = { version = ">= 5.0.3 and < 6.0.0" } pog = { version = ">= 4.0.0 and < 5.0.0" } diff --git a/examples/rate_limiter/gleam.toml b/examples/rate_limiter/gleam.toml index e19824c..1e2ea7e 100644 --- a/examples/rate_limiter/gleam.toml +++ b/examples/rate_limiter/gleam.toml @@ -6,6 +6,5 @@ licences = ["MIT"] [dependencies] dream = { path = "../.." } dream_ets = { path = "../../modules/ets" } -mist = ">= 5.0.3 and < 6.0.0" gleam_time = ">= 1.5.0 and < 2.0.0" gleam_stdlib = ">= 0.44.0 and < 2.0.0" diff --git a/examples/rate_limiter/manifest.toml b/examples/rate_limiter/manifest.toml index a509154..5eaeaa8 100644 --- a/examples/rate_limiter/manifest.toml +++ b/examples/rate_limiter/manifest.toml @@ -2,26 +2,25 @@ # You typically do not need to edit this file packages = [ - { name = "dream", version = "0.0.1", build_tools = ["gleam"], requirements = ["gleam_erlang", "gleam_http", "gleam_json", "gleam_otp", "gleam_stdlib", "gleam_time", "gleam_yielder", "marceau", "mist", "simplifile"], source = "local", path = "../.." }, - { name = "dream_ets", version = "0.1.0", build_tools = ["gleam"], requirements = ["gleam_erlang", "gleam_json", "gleam_stdlib"], source = "local", path = "../../modules/ets" }, + { name = "dream", version = "2.4.1", build_tools = ["gleam"], requirements = ["gleam_erlang", "gleam_http", "gleam_json", "gleam_otp", "gleam_stdlib", "gleam_time", "gleam_yielder", "marceau", "mist", "simplifile"], source = "local", path = "../.." }, + { name = "dream_ets", version = "2.0.0", build_tools = ["gleam"], requirements = ["gleam_erlang", "gleam_json", "gleam_stdlib"], source = "local", path = "../../modules/ets" }, { name = "exception", version = "2.1.0", build_tools = ["gleam"], requirements = ["gleam_stdlib"], otp_app = "exception", source = "hex", outer_checksum = "329D269D5C2A314F7364BD2711372B6F2C58FA6F39981572E5CA68624D291F8C" }, { name = "filepath", version = "1.1.2", build_tools = ["gleam"], requirements = ["gleam_stdlib"], otp_app = "filepath", source = "hex", outer_checksum = "B06A9AF0BF10E51401D64B98E4B627F1D2E48C154967DA7AF4D0914780A6D40A" }, { name = "gleam_crypto", version = "1.5.1", build_tools = ["gleam"], requirements = ["gleam_stdlib"], otp_app = "gleam_crypto", source = "hex", outer_checksum = "50774BAFFF1144E7872814C566C5D653D83A3EBF23ACC3156B757A1B6819086E" }, { name = "gleam_erlang", version = "1.3.0", build_tools = ["gleam"], requirements = ["gleam_stdlib"], otp_app = "gleam_erlang", source = "hex", outer_checksum = "1124AD3AA21143E5AF0FC5CF3D9529F6DB8CA03E43A55711B60B6B7B3874375C" }, { name = "gleam_http", version = "4.3.0", build_tools = ["gleam"], requirements = ["gleam_stdlib"], otp_app = "gleam_http", source = "hex", outer_checksum = "82EA6A717C842456188C190AFB372665EA56CE13D8559BF3B1DD9E40F619EE0C" }, - { name = "gleam_json", version = "3.0.2", build_tools = ["gleam"], requirements = ["gleam_stdlib"], otp_app = "gleam_json", source = "hex", outer_checksum = "874FA3C3BB6E22DD2BB111966BD40B3759E9094E05257899A7C08F5DE77EC049" }, + { name = "gleam_json", version = "3.1.0", build_tools = ["gleam"], requirements = ["gleam_stdlib"], otp_app = "gleam_json", source = "hex", outer_checksum = "44FDAA8847BE8FC48CA7A1C089706BD54BADCC4C45B237A992EDDF9F2CDB2836" }, { name = "gleam_otp", version = "1.2.0", build_tools = ["gleam"], requirements = ["gleam_erlang", "gleam_stdlib"], otp_app = "gleam_otp", source = "hex", outer_checksum = "BA6A294E295E428EC1562DC1C11EA7530DCB981E8359134BEABC8493B7B2258E" }, - { name = "gleam_stdlib", version = "0.65.0", build_tools = ["gleam"], requirements = [], otp_app = "gleam_stdlib", source = "hex", outer_checksum = "7C69C71D8C493AE11A5184828A77110EB05A7786EBF8B25B36A72F879C3EE107" }, - { name = "gleam_time", version = "1.5.0", build_tools = ["gleam"], requirements = ["gleam_stdlib"], otp_app = "gleam_time", source = "hex", outer_checksum = "D560E672C7279C89908981E068DF07FD16D0C859DCA266F908B18F04DF0EB8E6" }, + { name = "gleam_stdlib", version = "0.70.0", build_tools = ["gleam"], requirements = [], otp_app = "gleam_stdlib", source = "hex", outer_checksum = "86949BF5D1F0E4AC0AB5B06F235D8A5CC11A2DFC33BF22F752156ED61CA7D0FF" }, + { name = "gleam_time", version = "1.7.0", build_tools = ["gleam"], requirements = ["gleam_stdlib"], otp_app = "gleam_time", source = "hex", outer_checksum = "56DB0EF9433826D3B99DB0B4AF7A2BFED13D09755EC64B1DAAB46F804A9AD47D" }, { name = "gleam_yielder", version = "1.1.0", build_tools = ["gleam"], requirements = ["gleam_stdlib"], otp_app = "gleam_yielder", source = "hex", outer_checksum = "8E4E4ECFA7982859F430C57F549200C7749823C106759F4A19A78AEA6687717A" }, - { name = "glisten", version = "8.0.1", build_tools = ["gleam"], requirements = ["gleam_erlang", "gleam_otp", "gleam_stdlib", "logging", "telemetry"], otp_app = "glisten", source = "hex", outer_checksum = "534BB27C71FB9E506345A767C0D76B17A9E9199934340C975DC003C710E3692D" }, + { name = "glisten", version = "9.0.0", build_tools = ["gleam"], requirements = ["gleam_erlang", "gleam_otp", "gleam_stdlib", "logging"], otp_app = "glisten", source = "hex", outer_checksum = "D92808C66F7D3F22F2289CD04CBA8151757AAE9CB3D86992F0C6DE32A41205E1" }, { name = "gramps", version = "6.0.0", build_tools = ["gleam"], requirements = ["gleam_crypto", "gleam_erlang", "gleam_http", "gleam_stdlib"], otp_app = "gramps", source = "hex", outer_checksum = "8B7195978FBFD30B43DF791A8A272041B81E45D245314D7A41FC57237AA882A0" }, { name = "hpack_erl", version = "0.3.0", build_tools = ["rebar3"], requirements = [], otp_app = "hpack", source = "hex", outer_checksum = "D6137D7079169D8C485C6962DFE261AF5B9EF60FBC557344511C1E65E3D95FB0" }, { name = "logging", version = "1.3.0", build_tools = ["gleam"], requirements = ["gleam_stdlib"], otp_app = "logging", source = "hex", outer_checksum = "1098FBF10B54B44C2C7FDF0B01C1253CAFACDACABEFB4B0D027803246753E06D" }, { name = "marceau", version = "1.3.0", build_tools = ["gleam"], requirements = [], otp_app = "marceau", source = "hex", outer_checksum = "2D1C27504BEF45005F5DFB18591F8610FB4BFA91744878210BDC464412EC44E9" }, - { name = "mist", version = "5.0.3", build_tools = ["gleam"], requirements = ["exception", "gleam_erlang", "gleam_http", "gleam_otp", "gleam_stdlib", "gleam_yielder", "glisten", "gramps", "hpack_erl", "logging"], otp_app = "mist", source = "hex", outer_checksum = "7C4BE717A81305323C47C8A591E6B9BA4AC7F56354BF70B4D3DF08CC01192668" }, - { name = "simplifile", version = "2.3.1", build_tools = ["gleam"], requirements = ["filepath", "gleam_stdlib"], otp_app = "simplifile", source = "hex", outer_checksum = "957E0E5B75927659F1D2A1B7B75D7B9BA96FAA8D0C53EA71C4AD9CD0C6B848F6" }, - { name = "telemetry", version = "1.3.0", build_tools = ["rebar3"], requirements = [], otp_app = "telemetry", source = "hex", outer_checksum = "7015FC8919DBE63764F4B4B87A95B7C0996BD539E0D499BE6EC9D7F3875B79E6" }, + { name = "mist", version = "6.0.2", build_tools = ["gleam"], requirements = ["exception", "gleam_erlang", "gleam_http", "gleam_otp", "gleam_stdlib", "gleam_yielder", "glisten", "gramps", "hpack_erl", "logging"], source = "git", repo = "https://github.com/TrustBound/mist.git", commit = "ae358876d2d6763e8febb977221797fc42bffcc0" }, + { name = "simplifile", version = "2.4.0", build_tools = ["gleam"], requirements = ["filepath", "gleam_stdlib"], otp_app = "simplifile", source = "hex", outer_checksum = "7C18AFA4FED0B4CE1FA5B0B4BAC1FA1744427054EA993565F6F3F82E5453170D" }, ] [requirements] @@ -29,4 +28,3 @@ dream = { path = "../.." } dream_ets = { path = "../../modules/ets" } gleam_stdlib = { version = ">= 0.44.0 and < 2.0.0" } gleam_time = { version = ">= 1.5.0 and < 2.0.0" } -mist = { version = ">= 5.0.3 and < 6.0.0" } diff --git a/examples/simple/gleam.toml b/examples/simple/gleam.toml index dc47e1f..9458529 100644 --- a/examples/simple/gleam.toml +++ b/examples/simple/gleam.toml @@ -8,5 +8,3 @@ dream = { path = "../.." } dream_http_client = { path = "../../modules/http_client" } gleam_http = ">= 4.0.0 and < 5.0.0" gleam_stdlib = ">= 0.44.0 and < 2.0.0" -mist = ">= 5.0.3 and < 6.0.0" - diff --git a/examples/simple/manifest.toml b/examples/simple/manifest.toml index 1be2614..c185b7a 100644 --- a/examples/simple/manifest.toml +++ b/examples/simple/manifest.toml @@ -15,15 +15,14 @@ packages = [ { name = "gleam_stdlib", version = "0.70.0", build_tools = ["gleam"], requirements = [], otp_app = "gleam_stdlib", source = "hex", outer_checksum = "86949BF5D1F0E4AC0AB5B06F235D8A5CC11A2DFC33BF22F752156ED61CA7D0FF" }, { name = "gleam_time", version = "1.7.0", build_tools = ["gleam"], requirements = ["gleam_stdlib"], otp_app = "gleam_time", source = "hex", outer_checksum = "56DB0EF9433826D3B99DB0B4AF7A2BFED13D09755EC64B1DAAB46F804A9AD47D" }, { name = "gleam_yielder", version = "1.1.0", build_tools = ["gleam"], requirements = ["gleam_stdlib"], otp_app = "gleam_yielder", source = "hex", outer_checksum = "8E4E4ECFA7982859F430C57F549200C7749823C106759F4A19A78AEA6687717A" }, - { name = "glisten", version = "8.0.3", build_tools = ["gleam"], requirements = ["gleam_erlang", "gleam_otp", "gleam_stdlib", "logging", "telemetry"], otp_app = "glisten", source = "hex", outer_checksum = "86B838196592D9EBDE7A1D2369AE3A51E568F7DD2D168706C463C42D17B95312" }, + { name = "glisten", version = "9.0.0", build_tools = ["gleam"], requirements = ["gleam_erlang", "gleam_otp", "gleam_stdlib", "logging"], otp_app = "glisten", source = "hex", outer_checksum = "D92808C66F7D3F22F2289CD04CBA8151757AAE9CB3D86992F0C6DE32A41205E1" }, { name = "gramps", version = "6.0.0", build_tools = ["gleam"], requirements = ["gleam_crypto", "gleam_erlang", "gleam_http", "gleam_stdlib"], otp_app = "gramps", source = "hex", outer_checksum = "8B7195978FBFD30B43DF791A8A272041B81E45D245314D7A41FC57237AA882A0" }, { name = "gun", version = "2.2.0", build_tools = ["make", "rebar3"], requirements = ["cowlib"], otp_app = "gun", source = "hex", outer_checksum = "76022700C64287FEB4DF93A1795CFF6741B83FB37415C40C34C38D2A4645261A" }, { name = "hpack_erl", version = "0.3.0", build_tools = ["rebar3"], requirements = [], otp_app = "hpack", source = "hex", outer_checksum = "D6137D7079169D8C485C6962DFE261AF5B9EF60FBC557344511C1E65E3D95FB0" }, { name = "logging", version = "1.3.0", build_tools = ["gleam"], requirements = ["gleam_stdlib"], otp_app = "logging", source = "hex", outer_checksum = "1098FBF10B54B44C2C7FDF0B01C1253CAFACDACABEFB4B0D027803246753E06D" }, { name = "marceau", version = "1.3.0", build_tools = ["gleam"], requirements = [], otp_app = "marceau", source = "hex", outer_checksum = "2D1C27504BEF45005F5DFB18591F8610FB4BFA91744878210BDC464412EC44E9" }, - { name = "mist", version = "5.0.4", build_tools = ["gleam"], requirements = ["exception", "gleam_erlang", "gleam_http", "gleam_otp", "gleam_stdlib", "gleam_yielder", "glisten", "gramps", "hpack_erl", "logging"], otp_app = "mist", source = "hex", outer_checksum = "7CED4B2D81FD547ADB093D97B9928B9419A7F58B8562A30A6CC17A252B31AD05" }, + { name = "mist", version = "6.0.2", build_tools = ["gleam"], requirements = ["exception", "gleam_erlang", "gleam_http", "gleam_otp", "gleam_stdlib", "gleam_yielder", "glisten", "gramps", "hpack_erl", "logging"], source = "git", repo = "https://github.com/TrustBound/mist.git", commit = "ae358876d2d6763e8febb977221797fc42bffcc0" }, { name = "simplifile", version = "2.4.0", build_tools = ["gleam"], requirements = ["filepath", "gleam_stdlib"], otp_app = "simplifile", source = "hex", outer_checksum = "7C18AFA4FED0B4CE1FA5B0B4BAC1FA1744427054EA993565F6F3F82E5453170D" }, - { name = "telemetry", version = "1.4.1", build_tools = ["rebar3"], requirements = [], otp_app = "telemetry", source = "hex", outer_checksum = "2172E05A27531D3D31DD9782841065C50DD5C3C7699D95266B2EDD54C2DAFA1C" }, ] [requirements] @@ -31,4 +30,3 @@ dream = { path = "../.." } dream_http_client = { path = "../../modules/http_client" } gleam_http = { version = ">= 4.0.0 and < 5.0.0" } gleam_stdlib = { version = ">= 0.44.0 and < 2.0.0" } -mist = { version = ">= 5.0.3 and < 6.0.0" } diff --git a/examples/simple/src/controllers/posts_controller.gleam b/examples/simple/src/controllers/posts_controller.gleam index fcab330..0f7ec04 100644 --- a/examples/simple/src/controllers/posts_controller.gleam +++ b/examples/simple/src/controllers/posts_controller.gleam @@ -55,7 +55,10 @@ fn make_request_and_respond(user_id: String, post_id: String) -> Response { text_response(status.ok, post_view.format_show(user_id, post_id, body)) Error(client.ResponseError(response: client.HttpResponse(body: body, ..))) -> text_response(status.internal_server_error, post_view.format_error(body)) - Error(client.RequestError(message: error)) -> - text_response(status.internal_server_error, post_view.format_error(error)) + Error(client.RequestError(error: transport_error)) -> + text_response( + status.internal_server_error, + post_view.format_error(client.transport_error_to_string(transport_error)), + ) } } diff --git a/examples/simplest/gleam.toml b/examples/simplest/gleam.toml index d7fa4fe..b2e8772 100644 --- a/examples/simplest/gleam.toml +++ b/examples/simplest/gleam.toml @@ -7,8 +7,6 @@ licences = ["MIT"] dream = { path = "../.." } gleam_stdlib = ">= 0.44.0 and < 2.0.0" gleam_http = ">= 4.0.0 and < 5.0.0" -mist = ">= 5.0.3 and < 6.0.0" - diff --git a/examples/simplest/manifest.toml b/examples/simplest/manifest.toml index a57a2c4..89da81f 100644 --- a/examples/simplest/manifest.toml +++ b/examples/simplest/manifest.toml @@ -2,7 +2,7 @@ # You typically do not need to edit this file packages = [ - { name = "dream", version = "0.0.1", build_tools = ["gleam"], requirements = ["gleam_erlang", "gleam_http", "gleam_json", "gleam_otp", "gleam_stdlib", "gleam_time", "gleam_yielder", "marceau", "mist", "simplifile"], source = "local", path = "../.." }, + { name = "dream", version = "2.4.1", build_tools = ["gleam"], requirements = ["gleam_erlang", "gleam_http", "gleam_json", "gleam_otp", "gleam_stdlib", "gleam_time", "gleam_yielder", "marceau", "mist", "simplifile"], source = "local", path = "../.." }, { name = "exception", version = "2.1.0", build_tools = ["gleam"], requirements = ["gleam_stdlib"], otp_app = "exception", source = "hex", outer_checksum = "329D269D5C2A314F7364BD2711372B6F2C58FA6F39981572E5CA68624D291F8C" }, { name = "filepath", version = "1.1.2", build_tools = ["gleam"], requirements = ["gleam_stdlib"], otp_app = "filepath", source = "hex", outer_checksum = "B06A9AF0BF10E51401D64B98E4B627F1D2E48C154967DA7AF4D0914780A6D40A" }, { name = "gleam_crypto", version = "1.5.1", build_tools = ["gleam"], requirements = ["gleam_stdlib"], otp_app = "gleam_crypto", source = "hex", outer_checksum = "50774BAFFF1144E7872814C566C5D653D83A3EBF23ACC3156B757A1B6819086E" }, @@ -10,24 +10,19 @@ packages = [ { name = "gleam_http", version = "4.3.0", build_tools = ["gleam"], requirements = ["gleam_stdlib"], otp_app = "gleam_http", source = "hex", outer_checksum = "82EA6A717C842456188C190AFB372665EA56CE13D8559BF3B1DD9E40F619EE0C" }, { name = "gleam_json", version = "3.1.0", build_tools = ["gleam"], requirements = ["gleam_stdlib"], otp_app = "gleam_json", source = "hex", outer_checksum = "44FDAA8847BE8FC48CA7A1C089706BD54BADCC4C45B237A992EDDF9F2CDB2836" }, { name = "gleam_otp", version = "1.2.0", build_tools = ["gleam"], requirements = ["gleam_erlang", "gleam_stdlib"], otp_app = "gleam_otp", source = "hex", outer_checksum = "BA6A294E295E428EC1562DC1C11EA7530DCB981E8359134BEABC8493B7B2258E" }, - { name = "gleam_stdlib", version = "0.65.0", build_tools = ["gleam"], requirements = [], otp_app = "gleam_stdlib", source = "hex", outer_checksum = "7C69C71D8C493AE11A5184828A77110EB05A7786EBF8B25B36A72F879C3EE107" }, - { name = "gleam_time", version = "1.5.0", build_tools = ["gleam"], requirements = ["gleam_stdlib"], otp_app = "gleam_time", source = "hex", outer_checksum = "D560E672C7279C89908981E068DF07FD16D0C859DCA266F908B18F04DF0EB8E6" }, + { name = "gleam_stdlib", version = "0.70.0", build_tools = ["gleam"], requirements = [], otp_app = "gleam_stdlib", source = "hex", outer_checksum = "86949BF5D1F0E4AC0AB5B06F235D8A5CC11A2DFC33BF22F752156ED61CA7D0FF" }, + { name = "gleam_time", version = "1.7.0", build_tools = ["gleam"], requirements = ["gleam_stdlib"], otp_app = "gleam_time", source = "hex", outer_checksum = "56DB0EF9433826D3B99DB0B4AF7A2BFED13D09755EC64B1DAAB46F804A9AD47D" }, { name = "gleam_yielder", version = "1.1.0", build_tools = ["gleam"], requirements = ["gleam_stdlib"], otp_app = "gleam_yielder", source = "hex", outer_checksum = "8E4E4ECFA7982859F430C57F549200C7749823C106759F4A19A78AEA6687717A" }, - { name = "glisten", version = "8.0.1", build_tools = ["gleam"], requirements = ["gleam_erlang", "gleam_otp", "gleam_stdlib", "logging", "telemetry"], otp_app = "glisten", source = "hex", outer_checksum = "534BB27C71FB9E506345A767C0D76B17A9E9199934340C975DC003C710E3692D" }, + { name = "glisten", version = "9.0.0", build_tools = ["gleam"], requirements = ["gleam_erlang", "gleam_otp", "gleam_stdlib", "logging"], otp_app = "glisten", source = "hex", outer_checksum = "D92808C66F7D3F22F2289CD04CBA8151757AAE9CB3D86992F0C6DE32A41205E1" }, { name = "gramps", version = "6.0.0", build_tools = ["gleam"], requirements = ["gleam_crypto", "gleam_erlang", "gleam_http", "gleam_stdlib"], otp_app = "gramps", source = "hex", outer_checksum = "8B7195978FBFD30B43DF791A8A272041B81E45D245314D7A41FC57237AA882A0" }, { name = "hpack_erl", version = "0.3.0", build_tools = ["rebar3"], requirements = [], otp_app = "hpack", source = "hex", outer_checksum = "D6137D7079169D8C485C6962DFE261AF5B9EF60FBC557344511C1E65E3D95FB0" }, { name = "logging", version = "1.3.0", build_tools = ["gleam"], requirements = ["gleam_stdlib"], otp_app = "logging", source = "hex", outer_checksum = "1098FBF10B54B44C2C7FDF0B01C1253CAFACDACABEFB4B0D027803246753E06D" }, { name = "marceau", version = "1.3.0", build_tools = ["gleam"], requirements = [], otp_app = "marceau", source = "hex", outer_checksum = "2D1C27504BEF45005F5DFB18591F8610FB4BFA91744878210BDC464412EC44E9" }, - { name = "mist", version = "5.0.3", build_tools = ["gleam"], requirements = ["exception", "gleam_erlang", "gleam_http", "gleam_otp", "gleam_stdlib", "gleam_yielder", "glisten", "gramps", "hpack_erl", "logging"], otp_app = "mist", source = "hex", outer_checksum = "7C4BE717A81305323C47C8A591E6B9BA4AC7F56354BF70B4D3DF08CC01192668" }, - { name = "simplifile", version = "2.3.1", build_tools = ["gleam"], requirements = ["filepath", "gleam_stdlib"], otp_app = "simplifile", source = "hex", outer_checksum = "957E0E5B75927659F1D2A1B7B75D7B9BA96FAA8D0C53EA71C4AD9CD0C6B848F6" }, - { name = "telemetry", version = "1.3.0", build_tools = ["rebar3"], requirements = [], otp_app = "telemetry", source = "hex", outer_checksum = "7015FC8919DBE63764F4B4B87A95B7C0996BD539E0D499BE6EC9D7F3875B79E6" }, + { name = "mist", version = "6.0.2", build_tools = ["gleam"], requirements = ["exception", "gleam_erlang", "gleam_http", "gleam_otp", "gleam_stdlib", "gleam_yielder", "glisten", "gramps", "hpack_erl", "logging"], source = "git", repo = "https://github.com/TrustBound/mist.git", commit = "ae358876d2d6763e8febb977221797fc42bffcc0" }, + { name = "simplifile", version = "2.4.0", build_tools = ["gleam"], requirements = ["filepath", "gleam_stdlib"], otp_app = "simplifile", source = "hex", outer_checksum = "7C18AFA4FED0B4CE1FA5B0B4BAC1FA1744427054EA993565F6F3F82E5453170D" }, ] [requirements] dream = { path = "../.." } gleam_http = { version = ">= 4.0.0 and < 5.0.0" } gleam_stdlib = { version = ">= 0.44.0 and < 2.0.0" } -mist = { version = ">= 5.0.3 and < 6.0.0" } - - - diff --git a/examples/sse/gleam.toml b/examples/sse/gleam.toml index 9ca8963..f611f3d 100644 --- a/examples/sse/gleam.toml +++ b/examples/sse/gleam.toml @@ -9,7 +9,6 @@ gleam_http = ">= 4.0.0 and < 5.0.0" gleam_stdlib = ">= 0.44.0 and < 2.0.0" gleam_erlang = ">= 0.30.0 and < 2.0.0" gleam_otp = ">= 0.10.0 and < 2.0.0" -mist = ">= 5.0.3 and < 6.0.0" gleam_json = ">= 3.0.0 and < 4.0.0" [dev-dependencies] diff --git a/examples/sse/manifest.toml b/examples/sse/manifest.toml index 5fe4037..1302e91 100644 --- a/examples/sse/manifest.toml +++ b/examples/sse/manifest.toml @@ -2,7 +2,7 @@ # You typically do not need to edit this file packages = [ - { name = "dream", version = "2.3.3", build_tools = ["gleam"], requirements = ["gleam_erlang", "gleam_http", "gleam_json", "gleam_otp", "gleam_stdlib", "gleam_time", "gleam_yielder", "marceau", "mist", "simplifile"], source = "local", path = "../.." }, + { name = "dream", version = "2.4.1", build_tools = ["gleam"], requirements = ["gleam_erlang", "gleam_http", "gleam_json", "gleam_otp", "gleam_stdlib", "gleam_time", "gleam_yielder", "marceau", "mist", "simplifile"], source = "local", path = "../.." }, { name = "exception", version = "2.1.0", build_tools = ["gleam"], requirements = ["gleam_stdlib"], otp_app = "exception", source = "hex", outer_checksum = "329D269D5C2A314F7364BD2711372B6F2C58FA6F39981572E5CA68624D291F8C" }, { name = "filepath", version = "1.1.2", build_tools = ["gleam"], requirements = ["gleam_stdlib"], otp_app = "filepath", source = "hex", outer_checksum = "B06A9AF0BF10E51401D64B98E4B627F1D2E48C154967DA7AF4D0914780A6D40A" }, { name = "gleam_crypto", version = "1.5.1", build_tools = ["gleam"], requirements = ["gleam_stdlib"], otp_app = "gleam_crypto", source = "hex", outer_checksum = "50774BAFFF1144E7872814C566C5D653D83A3EBF23ACC3156B757A1B6819086E" }, @@ -14,14 +14,13 @@ packages = [ { name = "gleam_time", version = "1.7.0", build_tools = ["gleam"], requirements = ["gleam_stdlib"], otp_app = "gleam_time", source = "hex", outer_checksum = "56DB0EF9433826D3B99DB0B4AF7A2BFED13D09755EC64B1DAAB46F804A9AD47D" }, { name = "gleam_yielder", version = "1.1.0", build_tools = ["gleam"], requirements = ["gleam_stdlib"], otp_app = "gleam_yielder", source = "hex", outer_checksum = "8E4E4ECFA7982859F430C57F549200C7749823C106759F4A19A78AEA6687717A" }, { name = "gleeunit", version = "1.9.0", build_tools = ["gleam"], requirements = ["gleam_stdlib"], otp_app = "gleeunit", source = "hex", outer_checksum = "DA9553CE58B67924B3C631F96FE3370C49EB6D6DC6B384EC4862CC4AAA718F3C" }, - { name = "glisten", version = "8.0.3", build_tools = ["gleam"], requirements = ["gleam_erlang", "gleam_otp", "gleam_stdlib", "logging", "telemetry"], otp_app = "glisten", source = "hex", outer_checksum = "86B838196592D9EBDE7A1D2369AE3A51E568F7DD2D168706C463C42D17B95312" }, + { name = "glisten", version = "9.0.0", build_tools = ["gleam"], requirements = ["gleam_erlang", "gleam_otp", "gleam_stdlib", "logging"], otp_app = "glisten", source = "hex", outer_checksum = "D92808C66F7D3F22F2289CD04CBA8151757AAE9CB3D86992F0C6DE32A41205E1" }, { name = "gramps", version = "6.0.0", build_tools = ["gleam"], requirements = ["gleam_crypto", "gleam_erlang", "gleam_http", "gleam_stdlib"], otp_app = "gramps", source = "hex", outer_checksum = "8B7195978FBFD30B43DF791A8A272041B81E45D245314D7A41FC57237AA882A0" }, { name = "hpack_erl", version = "0.3.0", build_tools = ["rebar3"], requirements = [], otp_app = "hpack", source = "hex", outer_checksum = "D6137D7079169D8C485C6962DFE261AF5B9EF60FBC557344511C1E65E3D95FB0" }, { name = "logging", version = "1.3.0", build_tools = ["gleam"], requirements = ["gleam_stdlib"], otp_app = "logging", source = "hex", outer_checksum = "1098FBF10B54B44C2C7FDF0B01C1253CAFACDACABEFB4B0D027803246753E06D" }, { name = "marceau", version = "1.3.0", build_tools = ["gleam"], requirements = [], otp_app = "marceau", source = "hex", outer_checksum = "2D1C27504BEF45005F5DFB18591F8610FB4BFA91744878210BDC464412EC44E9" }, - { name = "mist", version = "5.0.4", build_tools = ["gleam"], requirements = ["exception", "gleam_erlang", "gleam_http", "gleam_otp", "gleam_stdlib", "gleam_yielder", "glisten", "gramps", "hpack_erl", "logging"], otp_app = "mist", source = "hex", outer_checksum = "7CED4B2D81FD547ADB093D97B9928B9419A7F58B8562A30A6CC17A252B31AD05" }, + { name = "mist", version = "6.0.2", build_tools = ["gleam"], requirements = ["exception", "gleam_erlang", "gleam_http", "gleam_otp", "gleam_stdlib", "gleam_yielder", "glisten", "gramps", "hpack_erl", "logging"], source = "git", repo = "https://github.com/TrustBound/mist.git", commit = "ae358876d2d6763e8febb977221797fc42bffcc0" }, { name = "simplifile", version = "2.4.0", build_tools = ["gleam"], requirements = ["filepath", "gleam_stdlib"], otp_app = "simplifile", source = "hex", outer_checksum = "7C18AFA4FED0B4CE1FA5B0B4BAC1FA1744427054EA993565F6F3F82E5453170D" }, - { name = "telemetry", version = "1.4.1", build_tools = ["rebar3"], requirements = [], otp_app = "telemetry", source = "hex", outer_checksum = "2172E05A27531D3D31DD9782841065C50DD5C3C7699D95266B2EDD54C2DAFA1C" }, ] [requirements] @@ -32,4 +31,3 @@ gleam_json = { version = ">= 3.0.0 and < 4.0.0" } gleam_otp = { version = ">= 0.10.0 and < 2.0.0" } gleam_stdlib = { version = ">= 0.44.0 and < 2.0.0" } gleeunit = { version = ">= 1.0.0 and < 2.0.0" } -mist = { version = ">= 5.0.3 and < 6.0.0" } diff --git a/examples/static/gleam.toml b/examples/static/gleam.toml index 60c4e8a..0631610 100644 --- a/examples/static/gleam.toml +++ b/examples/static/gleam.toml @@ -5,7 +5,6 @@ licences = ["MIT"] [dependencies] dream = { path = "../.." } -mist = ">= 5.0.3 and < 6.0.0" marceau = ">= 1.3.0 and < 2.0.0" simplifile = ">= 2.3.0 and < 3.0.0" gleam_stdlib = ">= 0.44.0 and < 2.0.0" diff --git a/examples/static/manifest.toml b/examples/static/manifest.toml index 08e4aeb..9217de8 100644 --- a/examples/static/manifest.toml +++ b/examples/static/manifest.toml @@ -2,30 +2,28 @@ # You typically do not need to edit this file packages = [ - { name = "dream", version = "0.0.1", build_tools = ["gleam"], requirements = ["gleam_erlang", "gleam_http", "gleam_json", "gleam_otp", "gleam_stdlib", "gleam_time", "gleam_yielder", "marceau", "mist", "simplifile"], source = "local", path = "../.." }, + { name = "dream", version = "2.4.1", build_tools = ["gleam"], requirements = ["gleam_erlang", "gleam_http", "gleam_json", "gleam_otp", "gleam_stdlib", "gleam_time", "gleam_yielder", "marceau", "mist", "simplifile"], source = "local", path = "../.." }, { name = "exception", version = "2.1.0", build_tools = ["gleam"], requirements = ["gleam_stdlib"], otp_app = "exception", source = "hex", outer_checksum = "329D269D5C2A314F7364BD2711372B6F2C58FA6F39981572E5CA68624D291F8C" }, { name = "filepath", version = "1.1.2", build_tools = ["gleam"], requirements = ["gleam_stdlib"], otp_app = "filepath", source = "hex", outer_checksum = "B06A9AF0BF10E51401D64B98E4B627F1D2E48C154967DA7AF4D0914780A6D40A" }, { name = "gleam_crypto", version = "1.5.1", build_tools = ["gleam"], requirements = ["gleam_stdlib"], otp_app = "gleam_crypto", source = "hex", outer_checksum = "50774BAFFF1144E7872814C566C5D653D83A3EBF23ACC3156B757A1B6819086E" }, { name = "gleam_erlang", version = "1.3.0", build_tools = ["gleam"], requirements = ["gleam_stdlib"], otp_app = "gleam_erlang", source = "hex", outer_checksum = "1124AD3AA21143E5AF0FC5CF3D9529F6DB8CA03E43A55711B60B6B7B3874375C" }, { name = "gleam_http", version = "4.3.0", build_tools = ["gleam"], requirements = ["gleam_stdlib"], otp_app = "gleam_http", source = "hex", outer_checksum = "82EA6A717C842456188C190AFB372665EA56CE13D8559BF3B1DD9E40F619EE0C" }, - { name = "gleam_json", version = "3.0.2", build_tools = ["gleam"], requirements = ["gleam_stdlib"], otp_app = "gleam_json", source = "hex", outer_checksum = "874FA3C3BB6E22DD2BB111966BD40B3759E9094E05257899A7C08F5DE77EC049" }, + { name = "gleam_json", version = "3.1.0", build_tools = ["gleam"], requirements = ["gleam_stdlib"], otp_app = "gleam_json", source = "hex", outer_checksum = "44FDAA8847BE8FC48CA7A1C089706BD54BADCC4C45B237A992EDDF9F2CDB2836" }, { name = "gleam_otp", version = "1.2.0", build_tools = ["gleam"], requirements = ["gleam_erlang", "gleam_stdlib"], otp_app = "gleam_otp", source = "hex", outer_checksum = "BA6A294E295E428EC1562DC1C11EA7530DCB981E8359134BEABC8493B7B2258E" }, - { name = "gleam_stdlib", version = "0.65.0", build_tools = ["gleam"], requirements = [], otp_app = "gleam_stdlib", source = "hex", outer_checksum = "7C69C71D8C493AE11A5184828A77110EB05A7786EBF8B25B36A72F879C3EE107" }, - { name = "gleam_time", version = "1.5.0", build_tools = ["gleam"], requirements = ["gleam_stdlib"], otp_app = "gleam_time", source = "hex", outer_checksum = "D560E672C7279C89908981E068DF07FD16D0C859DCA266F908B18F04DF0EB8E6" }, + { name = "gleam_stdlib", version = "0.70.0", build_tools = ["gleam"], requirements = [], otp_app = "gleam_stdlib", source = "hex", outer_checksum = "86949BF5D1F0E4AC0AB5B06F235D8A5CC11A2DFC33BF22F752156ED61CA7D0FF" }, + { name = "gleam_time", version = "1.7.0", build_tools = ["gleam"], requirements = ["gleam_stdlib"], otp_app = "gleam_time", source = "hex", outer_checksum = "56DB0EF9433826D3B99DB0B4AF7A2BFED13D09755EC64B1DAAB46F804A9AD47D" }, { name = "gleam_yielder", version = "1.1.0", build_tools = ["gleam"], requirements = ["gleam_stdlib"], otp_app = "gleam_yielder", source = "hex", outer_checksum = "8E4E4ECFA7982859F430C57F549200C7749823C106759F4A19A78AEA6687717A" }, - { name = "glisten", version = "8.0.1", build_tools = ["gleam"], requirements = ["gleam_erlang", "gleam_otp", "gleam_stdlib", "logging", "telemetry"], otp_app = "glisten", source = "hex", outer_checksum = "534BB27C71FB9E506345A767C0D76B17A9E9199934340C975DC003C710E3692D" }, + { name = "glisten", version = "9.0.0", build_tools = ["gleam"], requirements = ["gleam_erlang", "gleam_otp", "gleam_stdlib", "logging"], otp_app = "glisten", source = "hex", outer_checksum = "D92808C66F7D3F22F2289CD04CBA8151757AAE9CB3D86992F0C6DE32A41205E1" }, { name = "gramps", version = "6.0.0", build_tools = ["gleam"], requirements = ["gleam_crypto", "gleam_erlang", "gleam_http", "gleam_stdlib"], otp_app = "gramps", source = "hex", outer_checksum = "8B7195978FBFD30B43DF791A8A272041B81E45D245314D7A41FC57237AA882A0" }, { name = "hpack_erl", version = "0.3.0", build_tools = ["rebar3"], requirements = [], otp_app = "hpack", source = "hex", outer_checksum = "D6137D7079169D8C485C6962DFE261AF5B9EF60FBC557344511C1E65E3D95FB0" }, { name = "logging", version = "1.3.0", build_tools = ["gleam"], requirements = ["gleam_stdlib"], otp_app = "logging", source = "hex", outer_checksum = "1098FBF10B54B44C2C7FDF0B01C1253CAFACDACABEFB4B0D027803246753E06D" }, { name = "marceau", version = "1.3.0", build_tools = ["gleam"], requirements = [], otp_app = "marceau", source = "hex", outer_checksum = "2D1C27504BEF45005F5DFB18591F8610FB4BFA91744878210BDC464412EC44E9" }, - { name = "mist", version = "5.0.3", build_tools = ["gleam"], requirements = ["exception", "gleam_erlang", "gleam_http", "gleam_otp", "gleam_stdlib", "gleam_yielder", "glisten", "gramps", "hpack_erl", "logging"], otp_app = "mist", source = "hex", outer_checksum = "7C4BE717A81305323C47C8A591E6B9BA4AC7F56354BF70B4D3DF08CC01192668" }, - { name = "simplifile", version = "2.3.1", build_tools = ["gleam"], requirements = ["filepath", "gleam_stdlib"], otp_app = "simplifile", source = "hex", outer_checksum = "957E0E5B75927659F1D2A1B7B75D7B9BA96FAA8D0C53EA71C4AD9CD0C6B848F6" }, - { name = "telemetry", version = "1.3.0", build_tools = ["rebar3"], requirements = [], otp_app = "telemetry", source = "hex", outer_checksum = "7015FC8919DBE63764F4B4B87A95B7C0996BD539E0D499BE6EC9D7F3875B79E6" }, + { name = "mist", version = "6.0.2", build_tools = ["gleam"], requirements = ["exception", "gleam_erlang", "gleam_http", "gleam_otp", "gleam_stdlib", "gleam_yielder", "glisten", "gramps", "hpack_erl", "logging"], source = "git", repo = "https://github.com/TrustBound/mist.git", commit = "ae358876d2d6763e8febb977221797fc42bffcc0" }, + { name = "simplifile", version = "2.4.0", build_tools = ["gleam"], requirements = ["filepath", "gleam_stdlib"], otp_app = "simplifile", source = "hex", outer_checksum = "7C18AFA4FED0B4CE1FA5B0B4BAC1FA1744427054EA993565F6F3F82E5453170D" }, ] [requirements] dream = { path = "../.." } gleam_stdlib = { version = ">= 0.44.0 and < 2.0.0" } marceau = { version = ">= 1.3.0 and < 2.0.0" } -mist = { version = ">= 5.0.3 and < 6.0.0" } simplifile = { version = ">= 2.3.0 and < 3.0.0" } diff --git a/examples/streaming/gleam.toml b/examples/streaming/gleam.toml index 497223c..7184d19 100644 --- a/examples/streaming/gleam.toml +++ b/examples/streaming/gleam.toml @@ -10,6 +10,5 @@ dream_mock_server = { path = "../../modules/mock_server" } gleam_http = ">= 4.0.0 and < 5.0.0" gleam_stdlib = ">= 0.44.0 and < 2.0.0" gleam_yielder = ">= 1.1.0 and < 2.0.0" -mist = ">= 5.0.3 and < 6.0.0" gleam_erlang = ">= 1.3.0 and < 2.0.0" diff --git a/examples/streaming/manifest.toml b/examples/streaming/manifest.toml index df5948f..d9a181f 100644 --- a/examples/streaming/manifest.toml +++ b/examples/streaming/manifest.toml @@ -16,15 +16,14 @@ packages = [ { name = "gleam_stdlib", version = "0.70.0", build_tools = ["gleam"], requirements = [], otp_app = "gleam_stdlib", source = "hex", outer_checksum = "86949BF5D1F0E4AC0AB5B06F235D8A5CC11A2DFC33BF22F752156ED61CA7D0FF" }, { name = "gleam_time", version = "1.7.0", build_tools = ["gleam"], requirements = ["gleam_stdlib"], otp_app = "gleam_time", source = "hex", outer_checksum = "56DB0EF9433826D3B99DB0B4AF7A2BFED13D09755EC64B1DAAB46F804A9AD47D" }, { name = "gleam_yielder", version = "1.1.0", build_tools = ["gleam"], requirements = ["gleam_stdlib"], otp_app = "gleam_yielder", source = "hex", outer_checksum = "8E4E4ECFA7982859F430C57F549200C7749823C106759F4A19A78AEA6687717A" }, - { name = "glisten", version = "8.0.3", build_tools = ["gleam"], requirements = ["gleam_erlang", "gleam_otp", "gleam_stdlib", "logging", "telemetry"], otp_app = "glisten", source = "hex", outer_checksum = "86B838196592D9EBDE7A1D2369AE3A51E568F7DD2D168706C463C42D17B95312" }, + { name = "glisten", version = "9.0.0", build_tools = ["gleam"], requirements = ["gleam_erlang", "gleam_otp", "gleam_stdlib", "logging"], otp_app = "glisten", source = "hex", outer_checksum = "D92808C66F7D3F22F2289CD04CBA8151757AAE9CB3D86992F0C6DE32A41205E1" }, { name = "gramps", version = "6.0.0", build_tools = ["gleam"], requirements = ["gleam_crypto", "gleam_erlang", "gleam_http", "gleam_stdlib"], otp_app = "gramps", source = "hex", outer_checksum = "8B7195978FBFD30B43DF791A8A272041B81E45D245314D7A41FC57237AA882A0" }, { name = "gun", version = "2.2.0", build_tools = ["make", "rebar3"], requirements = ["cowlib"], otp_app = "gun", source = "hex", outer_checksum = "76022700C64287FEB4DF93A1795CFF6741B83FB37415C40C34C38D2A4645261A" }, { name = "hpack_erl", version = "0.3.0", build_tools = ["rebar3"], requirements = [], otp_app = "hpack", source = "hex", outer_checksum = "D6137D7079169D8C485C6962DFE261AF5B9EF60FBC557344511C1E65E3D95FB0" }, { name = "logging", version = "1.3.0", build_tools = ["gleam"], requirements = ["gleam_stdlib"], otp_app = "logging", source = "hex", outer_checksum = "1098FBF10B54B44C2C7FDF0B01C1253CAFACDACABEFB4B0D027803246753E06D" }, { name = "marceau", version = "1.3.0", build_tools = ["gleam"], requirements = [], otp_app = "marceau", source = "hex", outer_checksum = "2D1C27504BEF45005F5DFB18591F8610FB4BFA91744878210BDC464412EC44E9" }, - { name = "mist", version = "5.0.4", build_tools = ["gleam"], requirements = ["exception", "gleam_erlang", "gleam_http", "gleam_otp", "gleam_stdlib", "gleam_yielder", "glisten", "gramps", "hpack_erl", "logging"], otp_app = "mist", source = "hex", outer_checksum = "7CED4B2D81FD547ADB093D97B9928B9419A7F58B8562A30A6CC17A252B31AD05" }, + { name = "mist", version = "6.0.2", build_tools = ["gleam"], requirements = ["exception", "gleam_erlang", "gleam_http", "gleam_otp", "gleam_stdlib", "gleam_yielder", "glisten", "gramps", "hpack_erl", "logging"], source = "git", repo = "https://github.com/TrustBound/mist.git", commit = "ae358876d2d6763e8febb977221797fc42bffcc0" }, { name = "simplifile", version = "2.4.0", build_tools = ["gleam"], requirements = ["filepath", "gleam_stdlib"], otp_app = "simplifile", source = "hex", outer_checksum = "7C18AFA4FED0B4CE1FA5B0B4BAC1FA1744427054EA993565F6F3F82E5453170D" }, - { name = "telemetry", version = "1.4.1", build_tools = ["rebar3"], requirements = [], otp_app = "telemetry", source = "hex", outer_checksum = "2172E05A27531D3D31DD9782841065C50DD5C3C7699D95266B2EDD54C2DAFA1C" }, ] [requirements] @@ -35,4 +34,3 @@ gleam_erlang = { version = ">= 1.3.0 and < 2.0.0" } gleam_http = { version = ">= 4.0.0 and < 5.0.0" } gleam_stdlib = { version = ">= 0.44.0 and < 2.0.0" } gleam_yielder = { version = ">= 1.1.0 and < 2.0.0" } -mist = { version = ">= 5.0.3 and < 6.0.0" } diff --git a/examples/streaming/src/controllers/stream_controller.gleam b/examples/streaming/src/controllers/stream_controller.gleam index 5695e5a..8f300b9 100644 --- a/examples/streaming/src/controllers/stream_controller.gleam +++ b/examples/streaming/src/controllers/stream_controller.gleam @@ -88,20 +88,22 @@ pub fn new( status.internal_server_error, stream_view.format_error(body), ) - Error(client.RequestError(message: error)) -> + Error(client.RequestError(error: transport_error)) -> text_response( status.internal_server_error, - stream_view.format_error(error), + stream_view.format_error(client.transport_error_to_string( + transport_error, + )), ) } } fn convert_chunk_result( - result: Result(bytes_tree.BytesTree, String), + result: Result(bytes_tree.BytesTree, client.StreamFailure), ) -> Result(String, String) { case result { Ok(chunk) -> Ok(chunk_to_string(chunk)) - Error(err) -> Error(err) + Error(failure) -> Error(client.stream_failure_to_string(failure)) } } diff --git a/examples/streaming_capabilities/manifest.toml b/examples/streaming_capabilities/manifest.toml index ed99aa7..64374f9 100644 --- a/examples/streaming_capabilities/manifest.toml +++ b/examples/streaming_capabilities/manifest.toml @@ -16,15 +16,14 @@ packages = [ { name = "gleam_stdlib", version = "0.70.0", build_tools = ["gleam"], requirements = [], otp_app = "gleam_stdlib", source = "hex", outer_checksum = "86949BF5D1F0E4AC0AB5B06F235D8A5CC11A2DFC33BF22F752156ED61CA7D0FF" }, { name = "gleam_time", version = "1.7.0", build_tools = ["gleam"], requirements = ["gleam_stdlib"], otp_app = "gleam_time", source = "hex", outer_checksum = "56DB0EF9433826D3B99DB0B4AF7A2BFED13D09755EC64B1DAAB46F804A9AD47D" }, { name = "gleam_yielder", version = "1.1.0", build_tools = ["gleam"], requirements = ["gleam_stdlib"], otp_app = "gleam_yielder", source = "hex", outer_checksum = "8E4E4ECFA7982859F430C57F549200C7749823C106759F4A19A78AEA6687717A" }, - { name = "glisten", version = "8.0.3", build_tools = ["gleam"], requirements = ["gleam_erlang", "gleam_otp", "gleam_stdlib", "logging", "telemetry"], otp_app = "glisten", source = "hex", outer_checksum = "86B838196592D9EBDE7A1D2369AE3A51E568F7DD2D168706C463C42D17B95312" }, + { name = "glisten", version = "9.0.0", build_tools = ["gleam"], requirements = ["gleam_erlang", "gleam_otp", "gleam_stdlib", "logging"], otp_app = "glisten", source = "hex", outer_checksum = "D92808C66F7D3F22F2289CD04CBA8151757AAE9CB3D86992F0C6DE32A41205E1" }, { name = "gramps", version = "6.0.0", build_tools = ["gleam"], requirements = ["gleam_crypto", "gleam_erlang", "gleam_http", "gleam_stdlib"], otp_app = "gramps", source = "hex", outer_checksum = "8B7195978FBFD30B43DF791A8A272041B81E45D245314D7A41FC57237AA882A0" }, { name = "gun", version = "2.2.0", build_tools = ["make", "rebar3"], requirements = ["cowlib"], otp_app = "gun", source = "hex", outer_checksum = "76022700C64287FEB4DF93A1795CFF6741B83FB37415C40C34C38D2A4645261A" }, { name = "hpack_erl", version = "0.3.0", build_tools = ["rebar3"], requirements = [], otp_app = "hpack", source = "hex", outer_checksum = "D6137D7079169D8C485C6962DFE261AF5B9EF60FBC557344511C1E65E3D95FB0" }, { name = "logging", version = "1.3.0", build_tools = ["gleam"], requirements = ["gleam_stdlib"], otp_app = "logging", source = "hex", outer_checksum = "1098FBF10B54B44C2C7FDF0B01C1253CAFACDACABEFB4B0D027803246753E06D" }, { name = "marceau", version = "1.3.0", build_tools = ["gleam"], requirements = [], otp_app = "marceau", source = "hex", outer_checksum = "2D1C27504BEF45005F5DFB18591F8610FB4BFA91744878210BDC464412EC44E9" }, - { name = "mist", version = "5.0.4", build_tools = ["gleam"], requirements = ["exception", "gleam_erlang", "gleam_http", "gleam_otp", "gleam_stdlib", "gleam_yielder", "glisten", "gramps", "hpack_erl", "logging"], otp_app = "mist", source = "hex", outer_checksum = "7CED4B2D81FD547ADB093D97B9928B9419A7F58B8562A30A6CC17A252B31AD05" }, + { name = "mist", version = "6.0.2", build_tools = ["gleam"], requirements = ["exception", "gleam_erlang", "gleam_http", "gleam_otp", "gleam_stdlib", "gleam_yielder", "glisten", "gramps", "hpack_erl", "logging"], source = "git", repo = "https://github.com/TrustBound/mist.git", commit = "ae358876d2d6763e8febb977221797fc42bffcc0" }, { name = "simplifile", version = "2.4.0", build_tools = ["gleam"], requirements = ["filepath", "gleam_stdlib"], otp_app = "simplifile", source = "hex", outer_checksum = "7C18AFA4FED0B4CE1FA5B0B4BAC1FA1744427054EA993565F6F3F82E5453170D" }, - { name = "telemetry", version = "1.4.1", build_tools = ["rebar3"], requirements = [], otp_app = "telemetry", source = "hex", outer_checksum = "2172E05A27531D3D31DD9782841065C50DD5C3C7699D95266B2EDD54C2DAFA1C" }, ] [requirements] diff --git a/examples/streaming_capabilities/src/controllers/stream_controller.gleam b/examples/streaming_capabilities/src/controllers/stream_controller.gleam index 6e3df44..7a6499f 100644 --- a/examples/streaming_capabilities/src/controllers/stream_controller.gleam +++ b/examples/streaming_capabilities/src/controllers/stream_controller.gleam @@ -96,7 +96,7 @@ fn create_line(n: Int) -> BitArray { } fn convert_to_bit_array( - result: Result(bytes_tree.BytesTree, String), + result: Result(bytes_tree.BytesTree, client.StreamFailure), ) -> Result(BitArray, Nil) { case result { Ok(chunk) -> Ok(bytes_tree.to_bit_array(chunk)) diff --git a/examples/tasks/gleam.toml b/examples/tasks/gleam.toml index 75c2a68..ba8ad5e 100644 --- a/examples/tasks/gleam.toml +++ b/examples/tasks/gleam.toml @@ -7,7 +7,6 @@ licences = ["MIT"] dream = { path = "../.." } dream_postgres = { path = "../../modules/postgres" } dream_config = { path = "../../modules/config" } -mist = ">= 5.0.3 and < 6.0.0" pog = ">= 4.0.0 and < 5.0.0" squirrel = ">= 4.0.0 and < 5.0.0" cigogne = ">= 5.0.0 and < 6.0.0" diff --git a/examples/tasks/manifest.toml b/examples/tasks/manifest.toml index 9d3d646..2a501cc 100644 --- a/examples/tasks/manifest.toml +++ b/examples/tasks/manifest.toml @@ -4,48 +4,47 @@ packages = [ { name = "argv", version = "1.0.2", build_tools = ["gleam"], requirements = [], otp_app = "argv", source = "hex", outer_checksum = "BA1FF0929525DEBA1CE67256E5ADF77A7CDDFE729E3E3F57A5BDCAA031DED09D" }, { name = "backoff", version = "1.1.6", build_tools = ["rebar3"], requirements = [], otp_app = "backoff", source = "hex", outer_checksum = "CF0CFFF8995FB20562F822E5CC47D8CCF664C5ECDC26A684CBE85C225F9D7C39" }, - { name = "cigogne", version = "5.0.4", build_tools = ["gleam"], requirements = ["argv", "envoy", "gleam_crypto", "gleam_erlang", "gleam_otp", "gleam_stdlib", "gleam_time", "pog", "simplifile", "splitter", "tom"], otp_app = "cigogne", source = "hex", outer_checksum = "2138362840E0773213C5811DD6FB43B59E44F0DA3DF9FF2882223C789207C201" }, + { name = "cigogne", version = "5.0.5", build_tools = ["gleam"], requirements = ["argv", "envoy", "gleam_crypto", "gleam_erlang", "gleam_otp", "gleam_stdlib", "gleam_time", "pog", "simplifile", "splitter", "tom"], otp_app = "cigogne", source = "hex", outer_checksum = "C65012988BDFE143D78DDFE65B1DC6B720E8F6E2FC6CD87959376002886D78E8" }, { name = "dot_env", version = "1.2.0", build_tools = ["gleam"], requirements = ["gleam_stdlib", "simplifile"], otp_app = "dot_env", source = "hex", outer_checksum = "F2B4815F1B5AF8F20A6EADBB393E715C4C35203EBD5BE8200F766EA83A0B18DE" }, - { name = "dream", version = "0.0.1", build_tools = ["gleam"], requirements = ["gleam_erlang", "gleam_http", "gleam_json", "gleam_otp", "gleam_stdlib", "gleam_time", "gleam_yielder", "marceau", "mist", "simplifile"], source = "local", path = "../.." }, - { name = "dream_config", version = "0.1.0", build_tools = ["gleam"], requirements = ["dot_env", "envoy", "gleam_stdlib"], source = "local", path = "../../modules/config" }, - { name = "dream_postgres", version = "0.1.0", build_tools = ["gleam"], requirements = ["gleam_erlang", "gleam_otp", "gleam_stdlib", "pog"], source = "local", path = "../../modules/postgres" }, + { name = "dream", version = "2.4.1", build_tools = ["gleam"], requirements = ["gleam_erlang", "gleam_http", "gleam_json", "gleam_otp", "gleam_stdlib", "gleam_time", "gleam_yielder", "marceau", "mist", "simplifile"], source = "local", path = "../.." }, + { name = "dream_config", version = "1.0.2", build_tools = ["gleam"], requirements = ["dot_env", "envoy", "gleam_stdlib"], source = "local", path = "../../modules/config" }, + { name = "dream_postgres", version = "1.0.2", build_tools = ["gleam"], requirements = ["gleam_erlang", "gleam_otp", "gleam_stdlib", "pog"], source = "local", path = "../../modules/postgres" }, { name = "envoy", version = "1.1.0", build_tools = ["gleam"], requirements = ["gleam_stdlib"], otp_app = "envoy", source = "hex", outer_checksum = "850DA9D29D2E5987735872A2B5C81035146D7FE19EFC486129E44440D03FD832" }, { name = "eval", version = "1.0.0", build_tools = ["gleam"], requirements = ["gleam_stdlib"], otp_app = "eval", source = "hex", outer_checksum = "264DAF4B49DF807F303CA4A4E4EBC012070429E40BE384C58FE094C4958F9BDA" }, { name = "exception", version = "2.1.0", build_tools = ["gleam"], requirements = ["gleam_stdlib"], otp_app = "exception", source = "hex", outer_checksum = "329D269D5C2A314F7364BD2711372B6F2C58FA6F39981572E5CA68624D291F8C" }, { name = "filepath", version = "1.1.2", build_tools = ["gleam"], requirements = ["gleam_stdlib"], otp_app = "filepath", source = "hex", outer_checksum = "B06A9AF0BF10E51401D64B98E4B627F1D2E48C154967DA7AF4D0914780A6D40A" }, { name = "glam", version = "2.0.3", build_tools = ["gleam"], requirements = ["gleam_stdlib"], otp_app = "glam", source = "hex", outer_checksum = "237C2CE218A2A0A5D46D625F8EF5B78F964BC91018B78D692B17E1AB84295229" }, - { name = "gleam_community_ansi", version = "1.4.3", build_tools = ["gleam"], requirements = ["gleam_community_colour", "gleam_regexp", "gleam_stdlib"], otp_app = "gleam_community_ansi", source = "hex", outer_checksum = "8A62AE9CC6EA65BEA630D95016D6C07E4F9973565FA3D0DE68DC4200D8E0DD27" }, - { name = "gleam_community_colour", version = "2.0.2", build_tools = ["gleam"], requirements = ["gleam_json", "gleam_stdlib"], otp_app = "gleam_community_colour", source = "hex", outer_checksum = "E34DD2C896AC3792151EDA939DA435FF3B69922F33415ED3C4406C932FBE9634" }, + { name = "gleam_community_ansi", version = "1.4.4", build_tools = ["gleam"], requirements = ["gleam_community_colour", "gleam_regexp", "gleam_stdlib"], otp_app = "gleam_community_ansi", source = "hex", outer_checksum = "1B3AEA6074AB34D5F0674744F36DDC7290303A03295507E2DEC61EDD6F5777FE" }, + { name = "gleam_community_colour", version = "2.0.4", build_tools = ["gleam"], requirements = ["gleam_json", "gleam_stdlib"], otp_app = "gleam_community_colour", source = "hex", outer_checksum = "6DB4665555D7D2B27F0EA32EF47E8BEBC4303821765F9C73D483F38EE24894F0" }, { name = "gleam_crypto", version = "1.5.1", build_tools = ["gleam"], requirements = ["gleam_stdlib"], otp_app = "gleam_crypto", source = "hex", outer_checksum = "50774BAFFF1144E7872814C566C5D653D83A3EBF23ACC3156B757A1B6819086E" }, { name = "gleam_erlang", version = "1.3.0", build_tools = ["gleam"], requirements = ["gleam_stdlib"], otp_app = "gleam_erlang", source = "hex", outer_checksum = "1124AD3AA21143E5AF0FC5CF3D9529F6DB8CA03E43A55711B60B6B7B3874375C" }, { name = "gleam_http", version = "4.3.0", build_tools = ["gleam"], requirements = ["gleam_stdlib"], otp_app = "gleam_http", source = "hex", outer_checksum = "82EA6A717C842456188C190AFB372665EA56CE13D8559BF3B1DD9E40F619EE0C" }, { name = "gleam_json", version = "3.1.0", build_tools = ["gleam"], requirements = ["gleam_stdlib"], otp_app = "gleam_json", source = "hex", outer_checksum = "44FDAA8847BE8FC48CA7A1C089706BD54BADCC4C45B237A992EDDF9F2CDB2836" }, { name = "gleam_otp", version = "1.2.0", build_tools = ["gleam"], requirements = ["gleam_erlang", "gleam_stdlib"], otp_app = "gleam_otp", source = "hex", outer_checksum = "BA6A294E295E428EC1562DC1C11EA7530DCB981E8359134BEABC8493B7B2258E" }, { name = "gleam_regexp", version = "1.1.1", build_tools = ["gleam"], requirements = ["gleam_stdlib"], otp_app = "gleam_regexp", source = "hex", outer_checksum = "9C215C6CA84A5B35BB934A9B61A9A306EC743153BE2B0425A0D032E477B062A9" }, - { name = "gleam_stdlib", version = "0.65.0", build_tools = ["gleam"], requirements = [], otp_app = "gleam_stdlib", source = "hex", outer_checksum = "7C69C71D8C493AE11A5184828A77110EB05A7786EBF8B25B36A72F879C3EE107" }, - { name = "gleam_time", version = "1.6.0", build_tools = ["gleam"], requirements = ["gleam_stdlib"], otp_app = "gleam_time", source = "hex", outer_checksum = "0DF3834D20193F0A38D0EB21F0A78D48F2EC276C285969131B86DF8D4EF9E762" }, + { name = "gleam_stdlib", version = "0.70.0", build_tools = ["gleam"], requirements = [], otp_app = "gleam_stdlib", source = "hex", outer_checksum = "86949BF5D1F0E4AC0AB5B06F235D8A5CC11A2DFC33BF22F752156ED61CA7D0FF" }, + { name = "gleam_time", version = "1.7.0", build_tools = ["gleam"], requirements = ["gleam_stdlib"], otp_app = "gleam_time", source = "hex", outer_checksum = "56DB0EF9433826D3B99DB0B4AF7A2BFED13D09755EC64B1DAAB46F804A9AD47D" }, { name = "gleam_yielder", version = "1.1.0", build_tools = ["gleam"], requirements = ["gleam_stdlib"], otp_app = "gleam_yielder", source = "hex", outer_checksum = "8E4E4ECFA7982859F430C57F549200C7749823C106759F4A19A78AEA6687717A" }, { name = "gleeunit", version = "1.9.0", build_tools = ["gleam"], requirements = ["gleam_stdlib"], otp_app = "gleeunit", source = "hex", outer_checksum = "DA9553CE58B67924B3C631F96FE3370C49EB6D6DC6B384EC4862CC4AAA718F3C" }, - { name = "glexer", version = "2.3.0", build_tools = ["gleam"], requirements = ["gleam_stdlib", "splitter"], otp_app = "glexer", source = "hex", outer_checksum = "40A1FB0919FA080AD6C5809B4C7DBA545841CAAC8168FACDFA0B0667C22475CC" }, - { name = "glisten", version = "8.0.1", build_tools = ["gleam"], requirements = ["gleam_erlang", "gleam_otp", "gleam_stdlib", "logging", "telemetry"], otp_app = "glisten", source = "hex", outer_checksum = "534BB27C71FB9E506345A767C0D76B17A9E9199934340C975DC003C710E3692D" }, + { name = "glexer", version = "2.3.1", build_tools = ["gleam"], requirements = ["gleam_stdlib", "splitter"], otp_app = "glexer", source = "hex", outer_checksum = "41D8D2E855AEA87ADC94B7AF26A5FEA3C90268D4CF2CCBBD64FD6863714EE085" }, + { name = "glisten", version = "9.0.0", build_tools = ["gleam"], requirements = ["gleam_erlang", "gleam_otp", "gleam_stdlib", "logging"], otp_app = "glisten", source = "hex", outer_checksum = "D92808C66F7D3F22F2289CD04CBA8151757AAE9CB3D86992F0C6DE32A41205E1" }, { name = "gramps", version = "6.0.0", build_tools = ["gleam"], requirements = ["gleam_crypto", "gleam_erlang", "gleam_http", "gleam_stdlib"], otp_app = "gramps", source = "hex", outer_checksum = "8B7195978FBFD30B43DF791A8A272041B81E45D245314D7A41FC57237AA882A0" }, { name = "hpack_erl", version = "0.3.0", build_tools = ["rebar3"], requirements = [], otp_app = "hpack", source = "hex", outer_checksum = "D6137D7079169D8C485C6962DFE261AF5B9EF60FBC557344511C1E65E3D95FB0" }, { name = "justin", version = "1.0.1", build_tools = ["gleam"], requirements = ["gleam_stdlib"], otp_app = "justin", source = "hex", outer_checksum = "7FA0C6DB78640C6DC5FBFD59BF3456009F3F8B485BF6825E97E1EB44E9A1E2CD" }, { name = "logging", version = "1.3.0", build_tools = ["gleam"], requirements = ["gleam_stdlib"], otp_app = "logging", source = "hex", outer_checksum = "1098FBF10B54B44C2C7FDF0B01C1253CAFACDACABEFB4B0D027803246753E06D" }, { name = "marceau", version = "1.3.0", build_tools = ["gleam"], requirements = [], otp_app = "marceau", source = "hex", outer_checksum = "2D1C27504BEF45005F5DFB18591F8610FB4BFA91744878210BDC464412EC44E9" }, - { name = "mist", version = "5.0.3", build_tools = ["gleam"], requirements = ["exception", "gleam_erlang", "gleam_http", "gleam_otp", "gleam_stdlib", "gleam_yielder", "glisten", "gramps", "hpack_erl", "logging"], otp_app = "mist", source = "hex", outer_checksum = "7C4BE717A81305323C47C8A591E6B9BA4AC7F56354BF70B4D3DF08CC01192668" }, + { name = "mist", version = "6.0.2", build_tools = ["gleam"], requirements = ["exception", "gleam_erlang", "gleam_http", "gleam_otp", "gleam_stdlib", "gleam_yielder", "glisten", "gramps", "hpack_erl", "logging"], source = "git", repo = "https://github.com/TrustBound/mist.git", commit = "ae358876d2d6763e8febb977221797fc42bffcc0" }, { name = "mug", version = "3.1.0", build_tools = ["gleam"], requirements = ["gleam_erlang", "gleam_stdlib"], otp_app = "mug", source = "hex", outer_checksum = "C01279D98E40371DA23461774B63F0E3581B8F1396049D881B0C7EB32799D93F" }, - { name = "non_empty_list", version = "2.2.0", build_tools = ["gleam"], requirements = ["gleam_stdlib"], otp_app = "non_empty_list", source = "hex", outer_checksum = "1CA43D18C07E98E9ED5A60D9CB2FFE0FF40DEFFA45D58A3FF589589F05658F7B" }, - { name = "opentelemetry_api", version = "1.4.1", build_tools = ["rebar3", "mix"], requirements = [], otp_app = "opentelemetry_api", source = "hex", outer_checksum = "39BDB6AD740BC13B16215CB9F233D66796BBAE897F3BF6EB77ABB712E87C3C26" }, - { name = "pg_types", version = "0.5.0", build_tools = ["rebar3"], requirements = [], otp_app = "pg_types", source = "hex", outer_checksum = "A3023B464AA960BC1628635081E30CCA4F676F2D4C23CCD6179C1C11C9B4A642" }, - { name = "pgo", version = "0.15.0", build_tools = ["rebar3"], requirements = ["backoff", "opentelemetry_api", "pg_types"], otp_app = "pgo", source = "hex", outer_checksum = "4B883D751B8D4247F4D8A6FBAE58EDB6FA9DBC183371299BF84975EE36406388" }, + { name = "non_empty_list", version = "2.3.0", build_tools = ["gleam"], requirements = ["gleam_stdlib"], otp_app = "non_empty_list", source = "hex", outer_checksum = "06F9D3AC751CF7853AD5D24B8139CEB30E42D8799DE8D49F966A6197DF0B01CC" }, + { name = "opentelemetry_api", version = "1.5.0", build_tools = ["rebar3", "mix"], requirements = [], otp_app = "opentelemetry_api", source = "hex", outer_checksum = "F53EC8A1337AE4A487D43AC89DA4BD3A3C99DDF576655D071DEED8B56A2D5DDA" }, + { name = "pg_types", version = "0.6.0", build_tools = ["rebar3"], requirements = [], otp_app = "pg_types", source = "hex", outer_checksum = "9949A4849DD13408FA249AB7B745E0D2DFDB9532AEE2B9722326E33CD082A778" }, + { name = "pgo", version = "0.20.0", build_tools = ["rebar3"], requirements = ["backoff", "opentelemetry_api", "pg_types"], otp_app = "pgo", source = "hex", outer_checksum = "2F11E6649CEB38E569EF56B16BE1D04874AE5B11A02867080A2817CE423C683B" }, { name = "pog", version = "4.1.0", build_tools = ["gleam"], requirements = ["exception", "gleam_erlang", "gleam_otp", "gleam_stdlib", "gleam_time", "pgo"], otp_app = "pog", source = "hex", outer_checksum = "E4AFBA39A5FAA2E77291836C9683ADE882E65A06AB28CA7D61AE7A3AD61EBBD5" }, - { name = "simplifile", version = "2.3.1", build_tools = ["gleam"], requirements = ["filepath", "gleam_stdlib"], otp_app = "simplifile", source = "hex", outer_checksum = "957E0E5B75927659F1D2A1B7B75D7B9BA96FAA8D0C53EA71C4AD9CD0C6B848F6" }, + { name = "simplifile", version = "2.4.0", build_tools = ["gleam"], requirements = ["filepath", "gleam_stdlib"], otp_app = "simplifile", source = "hex", outer_checksum = "7C18AFA4FED0B4CE1FA5B0B4BAC1FA1744427054EA993565F6F3F82E5453170D" }, { name = "splitter", version = "1.2.0", build_tools = ["gleam"], requirements = ["gleam_stdlib"], otp_app = "splitter", source = "hex", outer_checksum = "3DFD6B6C49E61EDAF6F7B27A42054A17CFF6CA2135FF553D0CB61C234D281DD0" }, { name = "squirrel", version = "4.6.0", build_tools = ["gleam"], requirements = ["argv", "envoy", "eval", "filepath", "glam", "gleam_community_ansi", "gleam_crypto", "gleam_json", "gleam_regexp", "gleam_stdlib", "gleam_time", "glexer", "justin", "mug", "non_empty_list", "pog", "simplifile", "term_size", "tom", "tote", "youid"], otp_app = "squirrel", source = "hex", outer_checksum = "0ED10A868BDD1A5D4B68D99CD1C72DC3F23C6E36E16D33454C5F0C31BAC9CB1E" }, - { name = "telemetry", version = "1.3.0", build_tools = ["rebar3"], requirements = [], otp_app = "telemetry", source = "hex", outer_checksum = "7015FC8919DBE63764F4B4B87A95B7C0996BD539E0D499BE6EC9D7F3875B79E6" }, { name = "term_size", version = "1.0.1", build_tools = ["gleam"], requirements = ["gleam_stdlib"], otp_app = "term_size", source = "hex", outer_checksum = "D00BD2BC8FB3EBB7E6AE076F3F1FF2AC9D5ED1805F004D0896C784D06C6645F1" }, - { name = "tom", version = "2.0.0", build_tools = ["gleam"], requirements = ["gleam_stdlib", "gleam_time"], otp_app = "tom", source = "hex", outer_checksum = "74D0C5A3761F7A7D06994755D4D5AD854122EF8E9F9F76A3E7547606D8C77091" }, + { name = "tom", version = "2.0.1", build_tools = ["gleam"], requirements = ["gleam_stdlib", "gleam_time"], otp_app = "tom", source = "hex", outer_checksum = "90791DA4AACE637E30081FE77049B8DB850FBC8CACC31515376BCC4E59BE1DD2" }, { name = "tote", version = "1.0.2", build_tools = ["gleam"], requirements = ["gleam_stdlib"], otp_app = "tote", source = "hex", outer_checksum = "A249892E26A53C668897F8D47845B0007EEE07707A1A03437487F0CD5A452CA5" }, { name = "youid", version = "1.5.1", build_tools = ["gleam"], requirements = ["gleam_crypto", "gleam_stdlib", "gleam_time"], otp_app = "youid", source = "hex", outer_checksum = "580E909FD704DB16416D5CB080618EDC2DA0F1BE4D21B490C0683335E3FFC5AF" }, ] @@ -64,6 +63,5 @@ gleam_stdlib = { version = ">= 0.44.0 and < 2.0.0" } gleam_time = { version = ">= 1.5.0 and < 2.0.0" } gleeunit = { version = ">= 1.9.0 and < 2.0.0" } marceau = { version = ">= 1.3.0 and < 2.0.0" } -mist = { version = ">= 5.0.3 and < 6.0.0" } pog = { version = ">= 4.0.0 and < 5.0.0" } squirrel = { version = ">= 4.0.0 and < 5.0.0" } diff --git a/examples/websocket_chat/gleam.toml b/examples/websocket_chat/gleam.toml index 55b7ad9..6d44a84 100644 --- a/examples/websocket_chat/gleam.toml +++ b/examples/websocket_chat/gleam.toml @@ -9,7 +9,6 @@ gleam_http = ">= 4.0.0 and < 5.0.0" gleam_stdlib = ">= 0.44.0 and < 2.0.0" gleam_erlang = ">= 0.30.0 and < 2.0.0" gleam_otp = ">= 0.10.0 and < 2.0.0" -mist = ">= 5.0.3 and < 6.0.0" gleam_json = ">= 3.0.0 and < 4.0.0" [dev-dependencies] diff --git a/examples/websocket_chat/manifest.toml b/examples/websocket_chat/manifest.toml index f623f1a..1302e91 100644 --- a/examples/websocket_chat/manifest.toml +++ b/examples/websocket_chat/manifest.toml @@ -2,7 +2,7 @@ # You typically do not need to edit this file packages = [ - { name = "dream", version = "2.1.0", build_tools = ["gleam"], requirements = ["gleam_erlang", "gleam_http", "gleam_json", "gleam_otp", "gleam_stdlib", "gleam_time", "gleam_yielder", "marceau", "mist", "simplifile"], source = "local", path = "../.." }, + { name = "dream", version = "2.4.1", build_tools = ["gleam"], requirements = ["gleam_erlang", "gleam_http", "gleam_json", "gleam_otp", "gleam_stdlib", "gleam_time", "gleam_yielder", "marceau", "mist", "simplifile"], source = "local", path = "../.." }, { name = "exception", version = "2.1.0", build_tools = ["gleam"], requirements = ["gleam_stdlib"], otp_app = "exception", source = "hex", outer_checksum = "329D269D5C2A314F7364BD2711372B6F2C58FA6F39981572E5CA68624D291F8C" }, { name = "filepath", version = "1.1.2", build_tools = ["gleam"], requirements = ["gleam_stdlib"], otp_app = "filepath", source = "hex", outer_checksum = "B06A9AF0BF10E51401D64B98E4B627F1D2E48C154967DA7AF4D0914780A6D40A" }, { name = "gleam_crypto", version = "1.5.1", build_tools = ["gleam"], requirements = ["gleam_stdlib"], otp_app = "gleam_crypto", source = "hex", outer_checksum = "50774BAFFF1144E7872814C566C5D653D83A3EBF23ACC3156B757A1B6819086E" }, @@ -10,18 +10,17 @@ packages = [ { name = "gleam_http", version = "4.3.0", build_tools = ["gleam"], requirements = ["gleam_stdlib"], otp_app = "gleam_http", source = "hex", outer_checksum = "82EA6A717C842456188C190AFB372665EA56CE13D8559BF3B1DD9E40F619EE0C" }, { name = "gleam_json", version = "3.1.0", build_tools = ["gleam"], requirements = ["gleam_stdlib"], otp_app = "gleam_json", source = "hex", outer_checksum = "44FDAA8847BE8FC48CA7A1C089706BD54BADCC4C45B237A992EDDF9F2CDB2836" }, { name = "gleam_otp", version = "1.2.0", build_tools = ["gleam"], requirements = ["gleam_erlang", "gleam_stdlib"], otp_app = "gleam_otp", source = "hex", outer_checksum = "BA6A294E295E428EC1562DC1C11EA7530DCB981E8359134BEABC8493B7B2258E" }, - { name = "gleam_stdlib", version = "0.67.0", build_tools = ["gleam"], requirements = [], otp_app = "gleam_stdlib", source = "hex", outer_checksum = "6368313DB35963DC02F677A513BB0D95D58A34ED0A9436C8116820BF94BE3511" }, - { name = "gleam_time", version = "1.6.0", build_tools = ["gleam"], requirements = ["gleam_stdlib"], otp_app = "gleam_time", source = "hex", outer_checksum = "0DF3834D20193F0A38D0EB21F0A78D48F2EC276C285969131B86DF8D4EF9E762" }, + { name = "gleam_stdlib", version = "0.70.0", build_tools = ["gleam"], requirements = [], otp_app = "gleam_stdlib", source = "hex", outer_checksum = "86949BF5D1F0E4AC0AB5B06F235D8A5CC11A2DFC33BF22F752156ED61CA7D0FF" }, + { name = "gleam_time", version = "1.7.0", build_tools = ["gleam"], requirements = ["gleam_stdlib"], otp_app = "gleam_time", source = "hex", outer_checksum = "56DB0EF9433826D3B99DB0B4AF7A2BFED13D09755EC64B1DAAB46F804A9AD47D" }, { name = "gleam_yielder", version = "1.1.0", build_tools = ["gleam"], requirements = ["gleam_stdlib"], otp_app = "gleam_yielder", source = "hex", outer_checksum = "8E4E4ECFA7982859F430C57F549200C7749823C106759F4A19A78AEA6687717A" }, { name = "gleeunit", version = "1.9.0", build_tools = ["gleam"], requirements = ["gleam_stdlib"], otp_app = "gleeunit", source = "hex", outer_checksum = "DA9553CE58B67924B3C631F96FE3370C49EB6D6DC6B384EC4862CC4AAA718F3C" }, - { name = "glisten", version = "8.0.1", build_tools = ["gleam"], requirements = ["gleam_erlang", "gleam_otp", "gleam_stdlib", "logging", "telemetry"], otp_app = "glisten", source = "hex", outer_checksum = "534BB27C71FB9E506345A767C0D76B17A9E9199934340C975DC003C710E3692D" }, + { name = "glisten", version = "9.0.0", build_tools = ["gleam"], requirements = ["gleam_erlang", "gleam_otp", "gleam_stdlib", "logging"], otp_app = "glisten", source = "hex", outer_checksum = "D92808C66F7D3F22F2289CD04CBA8151757AAE9CB3D86992F0C6DE32A41205E1" }, { name = "gramps", version = "6.0.0", build_tools = ["gleam"], requirements = ["gleam_crypto", "gleam_erlang", "gleam_http", "gleam_stdlib"], otp_app = "gramps", source = "hex", outer_checksum = "8B7195978FBFD30B43DF791A8A272041B81E45D245314D7A41FC57237AA882A0" }, { name = "hpack_erl", version = "0.3.0", build_tools = ["rebar3"], requirements = [], otp_app = "hpack", source = "hex", outer_checksum = "D6137D7079169D8C485C6962DFE261AF5B9EF60FBC557344511C1E65E3D95FB0" }, { name = "logging", version = "1.3.0", build_tools = ["gleam"], requirements = ["gleam_stdlib"], otp_app = "logging", source = "hex", outer_checksum = "1098FBF10B54B44C2C7FDF0B01C1253CAFACDACABEFB4B0D027803246753E06D" }, { name = "marceau", version = "1.3.0", build_tools = ["gleam"], requirements = [], otp_app = "marceau", source = "hex", outer_checksum = "2D1C27504BEF45005F5DFB18591F8610FB4BFA91744878210BDC464412EC44E9" }, - { name = "mist", version = "5.0.3", build_tools = ["gleam"], requirements = ["exception", "gleam_erlang", "gleam_http", "gleam_otp", "gleam_stdlib", "gleam_yielder", "glisten", "gramps", "hpack_erl", "logging"], otp_app = "mist", source = "hex", outer_checksum = "7C4BE717A81305323C47C8A591E6B9BA4AC7F56354BF70B4D3DF08CC01192668" }, - { name = "simplifile", version = "2.3.1", build_tools = ["gleam"], requirements = ["filepath", "gleam_stdlib"], otp_app = "simplifile", source = "hex", outer_checksum = "957E0E5B75927659F1D2A1B7B75D7B9BA96FAA8D0C53EA71C4AD9CD0C6B848F6" }, - { name = "telemetry", version = "1.3.0", build_tools = ["rebar3"], requirements = [], otp_app = "telemetry", source = "hex", outer_checksum = "7015FC8919DBE63764F4B4B87A95B7C0996BD539E0D499BE6EC9D7F3875B79E6" }, + { name = "mist", version = "6.0.2", build_tools = ["gleam"], requirements = ["exception", "gleam_erlang", "gleam_http", "gleam_otp", "gleam_stdlib", "gleam_yielder", "glisten", "gramps", "hpack_erl", "logging"], source = "git", repo = "https://github.com/TrustBound/mist.git", commit = "ae358876d2d6763e8febb977221797fc42bffcc0" }, + { name = "simplifile", version = "2.4.0", build_tools = ["gleam"], requirements = ["filepath", "gleam_stdlib"], otp_app = "simplifile", source = "hex", outer_checksum = "7C18AFA4FED0B4CE1FA5B0B4BAC1FA1744427054EA993565F6F3F82E5453170D" }, ] [requirements] @@ -32,4 +31,3 @@ gleam_json = { version = ">= 3.0.0 and < 4.0.0" } gleam_otp = { version = ">= 0.10.0 and < 2.0.0" } gleam_stdlib = { version = ">= 0.44.0 and < 2.0.0" } gleeunit = { version = ">= 1.0.0 and < 2.0.0" } -mist = { version = ">= 5.0.3 and < 6.0.0" } diff --git a/gleam.toml b/gleam.toml index 3247ed6..da05a26 100644 --- a/gleam.toml +++ b/gleam.toml @@ -10,7 +10,7 @@ links = [ [dependencies] gleam_stdlib = ">= 0.44.0 and < 2.0.0" -mist = ">= 5.0.3 and < 6.0.0" +mist = { git = "https://github.com/TrustBound/mist.git", ref = "fix/http2-support" } gleam_http = ">= 4.3.0 and < 5.0.0" gleam_otp = ">= 1.2.0 and < 2.0.0" gleam_erlang = ">= 1.0.0 and < 2.0.0" @@ -24,4 +24,4 @@ simplifile = ">= 2.3.0 and < 3.0.0" squirrel = ">= 4.0.0 and < 5.0.0" mockth = { git = "https://github.com/bondiano/mockth.git", ref = "master" } dream_test = ">= 1.0.3 and < 2.0.0" -dream_http_client = ">= 2.1.1 and < 3.0.0" +dream_http_client = { path = "modules/http_client" } diff --git a/manifest.toml b/manifest.toml index 0dd78c3..864b897 100644 --- a/manifest.toml +++ b/manifest.toml @@ -4,53 +4,54 @@ packages = [ { name = "argv", version = "1.0.2", build_tools = ["gleam"], requirements = [], otp_app = "argv", source = "hex", outer_checksum = "BA1FF0929525DEBA1CE67256E5ADF77A7CDDFE729E3E3F57A5BDCAA031DED09D" }, { name = "backoff", version = "1.1.6", build_tools = ["rebar3"], requirements = [], otp_app = "backoff", source = "hex", outer_checksum = "CF0CFFF8995FB20562F822E5CC47D8CCF664C5ECDC26A684CBE85C225F9D7C39" }, - { name = "dream_http_client", version = "2.1.1", build_tools = ["gleam"], requirements = ["gleam_erlang", "gleam_http", "gleam_json", "gleam_otp", "gleam_stdlib", "gleam_yielder", "simplifile"], otp_app = "dream_http_client", source = "hex", outer_checksum = "D43BC0DFD411CAB707345A6DF4E6B1EA299DCDBFF9230CB124917D2D6FE1775A" }, - { name = "dream_test", version = "1.0.3", build_tools = ["gleam"], requirements = ["gleam_erlang", "gleam_otp", "gleam_stdlib"], otp_app = "dream_test", source = "hex", outer_checksum = "7C8E31A5608960F284FC0156D108AD3024DB264CC2EAC41A916346AD22B98C4E" }, - { name = "envoy", version = "1.0.2", build_tools = ["gleam"], requirements = ["gleam_stdlib"], otp_app = "envoy", source = "hex", outer_checksum = "95FD059345AA982E89A0B6E2A3BF1CF43E17A7048DCD85B5B65D3B9E4E39D359" }, + { name = "cowlib", version = "2.16.0", build_tools = ["make", "rebar3"], requirements = [], otp_app = "cowlib", source = "hex", outer_checksum = "7F478D80D66B747344F0EA7708C187645CFCC08B11AA424632F78E25BF05DB51" }, + { name = "dream_http_client", version = "5.2.0", build_tools = ["gleam"], requirements = ["gleam_crypto", "gleam_erlang", "gleam_http", "gleam_json", "gleam_otp", "gleam_stdlib", "gleam_yielder", "gun", "simplifile"], source = "local", path = "modules/http_client" }, + { name = "dream_test", version = "1.2.0", build_tools = ["gleam"], requirements = ["gleam_erlang", "gleam_json", "gleam_otp", "gleam_regexp", "gleam_stdlib"], otp_app = "dream_test", source = "hex", outer_checksum = "761B472ECF65785239611AE8E27104B57B8697018576060114574F5CF8DFE2A2" }, + { name = "envoy", version = "1.1.0", build_tools = ["gleam"], requirements = ["gleam_stdlib"], otp_app = "envoy", source = "hex", outer_checksum = "850DA9D29D2E5987735872A2B5C81035146D7FE19EFC486129E44440D03FD832" }, { name = "eval", version = "1.0.0", build_tools = ["gleam"], requirements = ["gleam_stdlib"], otp_app = "eval", source = "hex", outer_checksum = "264DAF4B49DF807F303CA4A4E4EBC012070429E40BE384C58FE094C4958F9BDA" }, { name = "exception", version = "2.1.0", build_tools = ["gleam"], requirements = ["gleam_stdlib"], otp_app = "exception", source = "hex", outer_checksum = "329D269D5C2A314F7364BD2711372B6F2C58FA6F39981572E5CA68624D291F8C" }, { name = "filepath", version = "1.1.2", build_tools = ["gleam"], requirements = ["gleam_stdlib"], otp_app = "filepath", source = "hex", outer_checksum = "B06A9AF0BF10E51401D64B98E4B627F1D2E48C154967DA7AF4D0914780A6D40A" }, { name = "glam", version = "2.0.3", build_tools = ["gleam"], requirements = ["gleam_stdlib"], otp_app = "glam", source = "hex", outer_checksum = "237C2CE218A2A0A5D46D625F8EF5B78F964BC91018B78D692B17E1AB84295229" }, - { name = "gleam_community_ansi", version = "1.4.3", build_tools = ["gleam"], requirements = ["gleam_community_colour", "gleam_regexp", "gleam_stdlib"], otp_app = "gleam_community_ansi", source = "hex", outer_checksum = "8A62AE9CC6EA65BEA630D95016D6C07E4F9973565FA3D0DE68DC4200D8E0DD27" }, - { name = "gleam_community_colour", version = "2.0.2", build_tools = ["gleam"], requirements = ["gleam_json", "gleam_stdlib"], otp_app = "gleam_community_colour", source = "hex", outer_checksum = "E34DD2C896AC3792151EDA939DA435FF3B69922F33415ED3C4406C932FBE9634" }, + { name = "gleam_community_ansi", version = "1.4.4", build_tools = ["gleam"], requirements = ["gleam_community_colour", "gleam_regexp", "gleam_stdlib"], otp_app = "gleam_community_ansi", source = "hex", outer_checksum = "1B3AEA6074AB34D5F0674744F36DDC7290303A03295507E2DEC61EDD6F5777FE" }, + { name = "gleam_community_colour", version = "2.0.4", build_tools = ["gleam"], requirements = ["gleam_json", "gleam_stdlib"], otp_app = "gleam_community_colour", source = "hex", outer_checksum = "6DB4665555D7D2B27F0EA32EF47E8BEBC4303821765F9C73D483F38EE24894F0" }, { name = "gleam_crypto", version = "1.5.1", build_tools = ["gleam"], requirements = ["gleam_stdlib"], otp_app = "gleam_crypto", source = "hex", outer_checksum = "50774BAFFF1144E7872814C566C5D653D83A3EBF23ACC3156B757A1B6819086E" }, { name = "gleam_erlang", version = "1.3.0", build_tools = ["gleam"], requirements = ["gleam_stdlib"], otp_app = "gleam_erlang", source = "hex", outer_checksum = "1124AD3AA21143E5AF0FC5CF3D9529F6DB8CA03E43A55711B60B6B7B3874375C" }, { name = "gleam_http", version = "4.3.0", build_tools = ["gleam"], requirements = ["gleam_stdlib"], otp_app = "gleam_http", source = "hex", outer_checksum = "82EA6A717C842456188C190AFB372665EA56CE13D8559BF3B1DD9E40F619EE0C" }, - { name = "gleam_json", version = "3.0.2", build_tools = ["gleam"], requirements = ["gleam_stdlib"], otp_app = "gleam_json", source = "hex", outer_checksum = "874FA3C3BB6E22DD2BB111966BD40B3759E9094E05257899A7C08F5DE77EC049" }, + { name = "gleam_json", version = "3.1.0", build_tools = ["gleam"], requirements = ["gleam_stdlib"], otp_app = "gleam_json", source = "hex", outer_checksum = "44FDAA8847BE8FC48CA7A1C089706BD54BADCC4C45B237A992EDDF9F2CDB2836" }, { name = "gleam_otp", version = "1.2.0", build_tools = ["gleam"], requirements = ["gleam_erlang", "gleam_stdlib"], otp_app = "gleam_otp", source = "hex", outer_checksum = "BA6A294E295E428EC1562DC1C11EA7530DCB981E8359134BEABC8493B7B2258E" }, { name = "gleam_regexp", version = "1.1.1", build_tools = ["gleam"], requirements = ["gleam_stdlib"], otp_app = "gleam_regexp", source = "hex", outer_checksum = "9C215C6CA84A5B35BB934A9B61A9A306EC743153BE2B0425A0D032E477B062A9" }, - { name = "gleam_stdlib", version = "0.65.0", build_tools = ["gleam"], requirements = [], otp_app = "gleam_stdlib", source = "hex", outer_checksum = "7C69C71D8C493AE11A5184828A77110EB05A7786EBF8B25B36A72F879C3EE107" }, - { name = "gleam_time", version = "1.5.0", build_tools = ["gleam"], requirements = ["gleam_stdlib"], otp_app = "gleam_time", source = "hex", outer_checksum = "D560E672C7279C89908981E068DF07FD16D0C859DCA266F908B18F04DF0EB8E6" }, + { name = "gleam_stdlib", version = "0.70.0", build_tools = ["gleam"], requirements = [], otp_app = "gleam_stdlib", source = "hex", outer_checksum = "86949BF5D1F0E4AC0AB5B06F235D8A5CC11A2DFC33BF22F752156ED61CA7D0FF" }, + { name = "gleam_time", version = "1.7.0", build_tools = ["gleam"], requirements = ["gleam_stdlib"], otp_app = "gleam_time", source = "hex", outer_checksum = "56DB0EF9433826D3B99DB0B4AF7A2BFED13D09755EC64B1DAAB46F804A9AD47D" }, { name = "gleam_yielder", version = "1.1.0", build_tools = ["gleam"], requirements = ["gleam_stdlib"], otp_app = "gleam_yielder", source = "hex", outer_checksum = "8E4E4ECFA7982859F430C57F549200C7749823C106759F4A19A78AEA6687717A" }, - { name = "gleeunit", version = "1.8.0", build_tools = ["gleam"], requirements = ["gleam_stdlib"], otp_app = "gleeunit", source = "hex", outer_checksum = "7AE0F64B26CC065ED705FF7CA5F4EDAB8015E72A883736FE251E46FACCCE1E08" }, - { name = "glexer", version = "2.3.0", build_tools = ["gleam"], requirements = ["gleam_stdlib", "splitter"], otp_app = "glexer", source = "hex", outer_checksum = "40A1FB0919FA080AD6C5809B4C7DBA545841CAAC8168FACDFA0B0667C22475CC" }, - { name = "glisten", version = "8.0.1", build_tools = ["gleam"], requirements = ["gleam_erlang", "gleam_otp", "gleam_stdlib", "logging", "telemetry"], otp_app = "glisten", source = "hex", outer_checksum = "534BB27C71FB9E506345A767C0D76B17A9E9199934340C975DC003C710E3692D" }, + { name = "gleeunit", version = "1.9.0", build_tools = ["gleam"], requirements = ["gleam_stdlib"], otp_app = "gleeunit", source = "hex", outer_checksum = "DA9553CE58B67924B3C631F96FE3370C49EB6D6DC6B384EC4862CC4AAA718F3C" }, + { name = "glexer", version = "2.3.1", build_tools = ["gleam"], requirements = ["gleam_stdlib", "splitter"], otp_app = "glexer", source = "hex", outer_checksum = "41D8D2E855AEA87ADC94B7AF26A5FEA3C90268D4CF2CCBBD64FD6863714EE085" }, + { name = "glisten", version = "9.0.0", build_tools = ["gleam"], requirements = ["gleam_erlang", "gleam_otp", "gleam_stdlib", "logging"], otp_app = "glisten", source = "hex", outer_checksum = "D92808C66F7D3F22F2289CD04CBA8151757AAE9CB3D86992F0C6DE32A41205E1" }, { name = "gramps", version = "6.0.0", build_tools = ["gleam"], requirements = ["gleam_crypto", "gleam_erlang", "gleam_http", "gleam_stdlib"], otp_app = "gramps", source = "hex", outer_checksum = "8B7195978FBFD30B43DF791A8A272041B81E45D245314D7A41FC57237AA882A0" }, + { name = "gun", version = "2.2.0", build_tools = ["make", "rebar3"], requirements = ["cowlib"], otp_app = "gun", source = "hex", outer_checksum = "76022700C64287FEB4DF93A1795CFF6741B83FB37415C40C34C38D2A4645261A" }, { name = "hpack_erl", version = "0.3.0", build_tools = ["rebar3"], requirements = [], otp_app = "hpack", source = "hex", outer_checksum = "D6137D7079169D8C485C6962DFE261AF5B9EF60FBC557344511C1E65E3D95FB0" }, { name = "justin", version = "1.0.1", build_tools = ["gleam"], requirements = ["gleam_stdlib"], otp_app = "justin", source = "hex", outer_checksum = "7FA0C6DB78640C6DC5FBFD59BF3456009F3F8B485BF6825E97E1EB44E9A1E2CD" }, { name = "logging", version = "1.3.0", build_tools = ["gleam"], requirements = ["gleam_stdlib"], otp_app = "logging", source = "hex", outer_checksum = "1098FBF10B54B44C2C7FDF0B01C1253CAFACDACABEFB4B0D027803246753E06D" }, { name = "marceau", version = "1.3.0", build_tools = ["gleam"], requirements = [], otp_app = "marceau", source = "hex", outer_checksum = "2D1C27504BEF45005F5DFB18591F8610FB4BFA91744878210BDC464412EC44E9" }, { name = "meck", version = "1.0.0", build_tools = ["rebar3"], requirements = [], otp_app = "meck", source = "hex", outer_checksum = "680A9BCFE52764350BEB9FB0335FB75FEE8E7329821416CEE0A19FEC35433882" }, - { name = "mist", version = "5.0.3", build_tools = ["gleam"], requirements = ["exception", "gleam_erlang", "gleam_http", "gleam_otp", "gleam_stdlib", "gleam_yielder", "glisten", "gramps", "hpack_erl", "logging"], otp_app = "mist", source = "hex", outer_checksum = "7C4BE717A81305323C47C8A591E6B9BA4AC7F56354BF70B4D3DF08CC01192668" }, + { name = "mist", version = "6.0.2", build_tools = ["gleam"], requirements = ["exception", "gleam_erlang", "gleam_http", "gleam_otp", "gleam_stdlib", "gleam_yielder", "glisten", "gramps", "hpack_erl", "logging"], source = "git", repo = "https://github.com/TrustBound/mist.git", commit = "ae358876d2d6763e8febb977221797fc42bffcc0" }, { name = "mockth", version = "2.0.0", build_tools = ["gleam"], requirements = ["gleam_erlang", "gleam_stdlib", "gleeunit", "meck"], source = "git", repo = "https://github.com/bondiano/mockth.git", commit = "bacecbc7cd7ffac806d84154b07360c627d235ec" }, { name = "mug", version = "3.1.0", build_tools = ["gleam"], requirements = ["gleam_erlang", "gleam_stdlib"], otp_app = "mug", source = "hex", outer_checksum = "C01279D98E40371DA23461774B63F0E3581B8F1396049D881B0C7EB32799D93F" }, - { name = "non_empty_list", version = "2.1.0", build_tools = ["gleam"], requirements = ["gleam_stdlib"], otp_app = "non_empty_list", source = "hex", outer_checksum = "3BF1496B475F3F2CE2EA18157587329812493369B2A7C5B9DCFEA5C007670A3B" }, - { name = "opentelemetry_api", version = "1.4.1", build_tools = ["rebar3", "mix"], requirements = [], otp_app = "opentelemetry_api", source = "hex", outer_checksum = "39BDB6AD740BC13B16215CB9F233D66796BBAE897F3BF6EB77ABB712E87C3C26" }, - { name = "pg_types", version = "0.5.0", build_tools = ["rebar3"], requirements = [], otp_app = "pg_types", source = "hex", outer_checksum = "A3023B464AA960BC1628635081E30CCA4F676F2D4C23CCD6179C1C11C9B4A642" }, - { name = "pgo", version = "0.15.0", build_tools = ["rebar3"], requirements = ["backoff", "opentelemetry_api", "pg_types"], otp_app = "pgo", source = "hex", outer_checksum = "4B883D751B8D4247F4D8A6FBAE58EDB6FA9DBC183371299BF84975EE36406388" }, + { name = "non_empty_list", version = "2.3.0", build_tools = ["gleam"], requirements = ["gleam_stdlib"], otp_app = "non_empty_list", source = "hex", outer_checksum = "06F9D3AC751CF7853AD5D24B8139CEB30E42D8799DE8D49F966A6197DF0B01CC" }, + { name = "opentelemetry_api", version = "1.5.0", build_tools = ["rebar3", "mix"], requirements = [], otp_app = "opentelemetry_api", source = "hex", outer_checksum = "F53EC8A1337AE4A487D43AC89DA4BD3A3C99DDF576655D071DEED8B56A2D5DDA" }, + { name = "pg_types", version = "0.6.0", build_tools = ["rebar3"], requirements = [], otp_app = "pg_types", source = "hex", outer_checksum = "9949A4849DD13408FA249AB7B745E0D2DFDB9532AEE2B9722326E33CD082A778" }, + { name = "pgo", version = "0.20.0", build_tools = ["rebar3"], requirements = ["backoff", "opentelemetry_api", "pg_types"], otp_app = "pgo", source = "hex", outer_checksum = "2F11E6649CEB38E569EF56B16BE1D04874AE5B11A02867080A2817CE423C683B" }, { name = "pog", version = "4.1.0", build_tools = ["gleam"], requirements = ["exception", "gleam_erlang", "gleam_otp", "gleam_stdlib", "gleam_time", "pgo"], otp_app = "pog", source = "hex", outer_checksum = "E4AFBA39A5FAA2E77291836C9683ADE882E65A06AB28CA7D61AE7A3AD61EBBD5" }, - { name = "simplifile", version = "2.3.0", build_tools = ["gleam"], requirements = ["filepath", "gleam_stdlib"], otp_app = "simplifile", source = "hex", outer_checksum = "0A868DAC6063D9E983477981839810DC2E553285AB4588B87E3E9C96A7FB4CB4" }, - { name = "splitter", version = "1.1.0", build_tools = ["gleam"], requirements = ["gleam_stdlib"], otp_app = "splitter", source = "hex", outer_checksum = "05564A381580395DCDEFF4F88A64B021E8DAFA6540AE99B4623962F52976AA9D" }, - { name = "squirrel", version = "4.5.0", build_tools = ["gleam"], requirements = ["argv", "envoy", "eval", "filepath", "glam", "gleam_community_ansi", "gleam_crypto", "gleam_json", "gleam_regexp", "gleam_stdlib", "gleam_time", "glexer", "justin", "mug", "non_empty_list", "pog", "simplifile", "term_size", "tom", "tote", "youid"], otp_app = "squirrel", source = "hex", outer_checksum = "6B0F52F467BD0E5B7686F4D1AC04A221DB27B84CA0BECB4E806B82D7260A4BFD" }, - { name = "telemetry", version = "1.3.0", build_tools = ["rebar3"], requirements = [], otp_app = "telemetry", source = "hex", outer_checksum = "7015FC8919DBE63764F4B4B87A95B7C0996BD539E0D499BE6EC9D7F3875B79E6" }, + { name = "simplifile", version = "2.4.0", build_tools = ["gleam"], requirements = ["filepath", "gleam_stdlib"], otp_app = "simplifile", source = "hex", outer_checksum = "7C18AFA4FED0B4CE1FA5B0B4BAC1FA1744427054EA993565F6F3F82E5453170D" }, + { name = "splitter", version = "1.2.0", build_tools = ["gleam"], requirements = ["gleam_stdlib"], otp_app = "splitter", source = "hex", outer_checksum = "3DFD6B6C49E61EDAF6F7B27A42054A17CFF6CA2135FF553D0CB61C234D281DD0" }, + { name = "squirrel", version = "4.6.0", build_tools = ["gleam"], requirements = ["argv", "envoy", "eval", "filepath", "glam", "gleam_community_ansi", "gleam_crypto", "gleam_json", "gleam_regexp", "gleam_stdlib", "gleam_time", "glexer", "justin", "mug", "non_empty_list", "pog", "simplifile", "term_size", "tom", "tote", "youid"], otp_app = "squirrel", source = "hex", outer_checksum = "0ED10A868BDD1A5D4B68D99CD1C72DC3F23C6E36E16D33454C5F0C31BAC9CB1E" }, { name = "term_size", version = "1.0.1", build_tools = ["gleam"], requirements = ["gleam_stdlib"], otp_app = "term_size", source = "hex", outer_checksum = "D00BD2BC8FB3EBB7E6AE076F3F1FF2AC9D5ED1805F004D0896C784D06C6645F1" }, - { name = "tom", version = "2.0.0", build_tools = ["gleam"], requirements = ["gleam_stdlib", "gleam_time"], otp_app = "tom", source = "hex", outer_checksum = "74D0C5A3761F7A7D06994755D4D5AD854122EF8E9F9F76A3E7547606D8C77091" }, + { name = "tom", version = "2.0.1", build_tools = ["gleam"], requirements = ["gleam_stdlib", "gleam_time"], otp_app = "tom", source = "hex", outer_checksum = "90791DA4AACE637E30081FE77049B8DB850FBC8CACC31515376BCC4E59BE1DD2" }, { name = "tote", version = "1.0.2", build_tools = ["gleam"], requirements = ["gleam_stdlib"], otp_app = "tote", source = "hex", outer_checksum = "A249892E26A53C668897F8D47845B0007EEE07707A1A03437487F0CD5A452CA5" }, { name = "youid", version = "1.5.1", build_tools = ["gleam"], requirements = ["gleam_crypto", "gleam_stdlib", "gleam_time"], otp_app = "youid", source = "hex", outer_checksum = "580E909FD704DB16416D5CB080618EDC2DA0F1BE4D21B490C0683335E3FFC5AF" }, ] [requirements] -dream_http_client = { version = ">= 2.1.1 and < 3.0.0" } +dream_http_client = { path = "modules/http_client" } dream_test = { version = ">= 1.0.3 and < 2.0.0" } gleam_erlang = { version = ">= 1.0.0 and < 2.0.0" } gleam_http = { version = ">= 4.3.0 and < 5.0.0" } @@ -60,7 +61,7 @@ gleam_stdlib = { version = ">= 0.44.0 and < 2.0.0" } gleam_time = { version = ">= 1.5.0 and < 2.0.0" } gleam_yielder = { version = ">= 1.1.0 and < 2.0.0" } marceau = { version = ">= 1.3.0 and < 2.0.0" } -mist = { version = ">= 5.0.3 and < 6.0.0" } +mist = { git = "https://github.com/TrustBound/mist.git", ref = "fix/http2-support" } mockth = { git = "https://github.com/bondiano/mockth.git", ref = "master" } simplifile = { version = ">= 2.3.0 and < 3.0.0" } squirrel = { version = ">= 4.0.0 and < 5.0.0" } diff --git a/modules/http_client/CHANGELOG.md b/modules/http_client/CHANGELOG.md index 3eef4aa..decf328 100644 --- a/modules/http_client/CHANGELOG.md +++ b/modules/http_client/CHANGELOG.md @@ -16,9 +16,31 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 a single TCP connection to HTTP/2 servers). All public API contracts are preserved — `send()`, `stream_yielder()`, and `start_stream()` behave identically from the caller's perspective. +- **`SendError.RequestError` from `message: String` to `error: TransportError`** + for structured transport error classification. Pattern match on `TransportError` + variants for programmatic error handling, or use `transport_error_to_string` + for the old string behavior. +- **`StreamMessage.StreamError` from `reason: String` to `error: StreamFailure`** + distinguishing HTTP failures (non-2xx response with headers/body) from + transport errors (connection drop, timeout, etc.). +- **`on_stream_error` callback from `fn(String) -> Nil` to `fn(StreamFailure) -> Nil`.** + The callback now receives a `StreamFailure` with full error context instead of + a formatted string. +- **`stream_yielder` error type from `String` to `StreamFailure`.** Error results + now carry structured failure information instead of formatted strings. +- **Logging uses OTP `logger` instead of `error_logger`/`io:format`.** Connection + events and decompression warnings now go through OTP's logger, enabling + level-based filtering. Use `log_level()` on `TransportConfig` to control + verbosity — defaults to `LogInfo`. ### Added +- **Per-request protocol preference.** `protocols(preference)` controls which + HTTP protocol version gun negotiates per connection. `Http1Only` forces + HTTP/1.1, `Http2Only` enables h2c (HTTP/2 over cleartext, RFC 7540 + Section 3.4) for plaintext connections or h2-only for TLS, `Http2Preferred` + prefers HTTP/2 with HTTP/1.1 fallback. Defaults to HTTP/2 preferred for + HTTPS (via ALPN) and HTTP/1.1 for HTTP when not set. - **Per-request TCP connection timeout.** `connect_timeout(ms)` controls how long to wait for the TCP connection to be established, separate from the existing `timeout()` which controls the entire request/response cycle. Defaults to @@ -57,10 +79,28 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 (`{stream_error, closed}`) are automatically retried once on a fresh connection, preventing spurious failures when connection pool entries outlive the server-side keep-alive. +- **`TransportError` type (8 variants)** preserving all gun error details: + `StreamReset`, `Goaway`, `ConnectionError`, `RemoteClosed`, `TimedOut`, + `ProcessDown`, `ConnectFailed`, `Unexpected`. Each variant carries the full + structured information from gun (HTTP/2 error codes, human-readable + descriptions, GOAWAY fields) instead of flattening to formatted strings. +- **`StreamFailure` type** with `HttpFailure(response: HttpResponse)` and + `TransportFailure(error: TransportError)`. Distinguishes HTTP-level + rejections (where response headers like `retry-after` and `x-request-id` + are available) from transport-level errors (connection drops, timeouts). +- **`transport_error_to_string` and `stream_failure_to_string` helpers** for + converting structured errors to human-readable log strings. +- **Response headers preserved in non-2xx streaming errors.** When a streaming + request receives a non-2xx response, the full `HttpResponse` (status, headers, + body) is now available via `HttpFailure` instead of a formatted string. +- **`gun_down` connection events now logged.** The connection manager logs + `gun_down` events with connection PID, protocol, reason, and killed/unprocessed + stream counts via `error_logger:warning_msg`. - **Getter functions** for all 13 `TransportConfig` fields. -- **18 new tests** covering builder/getter round-trips, default values, edge - cases (zero values), builder chaining, transport application, and concurrent - streaming scenarios. +- **24 new tests** covering structured error types, HttpFailure headers/body + preservation, ConnectFailed variant checks, helper function output, plus + builder/getter round-trips, default values, edge cases, builder chaining, + transport application, and concurrent streaming scenarios. ## 5.1.3 - 2026-03-17 diff --git a/modules/http_client/manifest.toml b/modules/http_client/manifest.toml index 7ebda6a..4523ec6 100644 --- a/modules/http_client/manifest.toml +++ b/modules/http_client/manifest.toml @@ -16,17 +16,16 @@ packages = [ { name = "gleam_time", version = "1.7.0", build_tools = ["gleam"], requirements = ["gleam_stdlib"], otp_app = "gleam_time", source = "hex", outer_checksum = "56DB0EF9433826D3B99DB0B4AF7A2BFED13D09755EC64B1DAAB46F804A9AD47D" }, { name = "gleam_yielder", version = "1.1.0", build_tools = ["gleam"], requirements = ["gleam_stdlib"], otp_app = "gleam_yielder", source = "hex", outer_checksum = "8E4E4ECFA7982859F430C57F549200C7749823C106759F4A19A78AEA6687717A" }, { name = "gleeunit", version = "1.9.0", build_tools = ["gleam"], requirements = ["gleam_stdlib"], otp_app = "gleeunit", source = "hex", outer_checksum = "DA9553CE58B67924B3C631F96FE3370C49EB6D6DC6B384EC4862CC4AAA718F3C" }, - { name = "glisten", version = "8.0.3", build_tools = ["gleam"], requirements = ["gleam_erlang", "gleam_otp", "gleam_stdlib", "logging", "telemetry"], otp_app = "glisten", source = "hex", outer_checksum = "86B838196592D9EBDE7A1D2369AE3A51E568F7DD2D168706C463C42D17B95312" }, + { name = "glisten", version = "9.0.0", build_tools = ["gleam"], requirements = ["gleam_erlang", "gleam_otp", "gleam_stdlib", "logging"], otp_app = "glisten", source = "hex", outer_checksum = "D92808C66F7D3F22F2289CD04CBA8151757AAE9CB3D86992F0C6DE32A41205E1" }, { name = "gramps", version = "6.0.0", build_tools = ["gleam"], requirements = ["gleam_crypto", "gleam_erlang", "gleam_http", "gleam_stdlib"], otp_app = "gramps", source = "hex", outer_checksum = "8B7195978FBFD30B43DF791A8A272041B81E45D245314D7A41FC57237AA882A0" }, { name = "gun", version = "2.2.0", build_tools = ["make", "rebar3"], requirements = ["cowlib"], otp_app = "gun", source = "hex", outer_checksum = "76022700C64287FEB4DF93A1795CFF6741B83FB37415C40C34C38D2A4645261A" }, { name = "hpack_erl", version = "0.3.0", build_tools = ["rebar3"], requirements = [], otp_app = "hpack", source = "hex", outer_checksum = "D6137D7079169D8C485C6962DFE261AF5B9EF60FBC557344511C1E65E3D95FB0" }, { name = "logging", version = "1.3.0", build_tools = ["gleam"], requirements = ["gleam_stdlib"], otp_app = "logging", source = "hex", outer_checksum = "1098FBF10B54B44C2C7FDF0B01C1253CAFACDACABEFB4B0D027803246753E06D" }, { name = "marceau", version = "1.3.0", build_tools = ["gleam"], requirements = [], otp_app = "marceau", source = "hex", outer_checksum = "2D1C27504BEF45005F5DFB18591F8610FB4BFA91744878210BDC464412EC44E9" }, { name = "meck", version = "1.0.0", build_tools = ["rebar3"], requirements = [], otp_app = "meck", source = "hex", outer_checksum = "680A9BCFE52764350BEB9FB0335FB75FEE8E7329821416CEE0A19FEC35433882" }, - { name = "mist", version = "5.0.4", build_tools = ["gleam"], requirements = ["exception", "gleam_erlang", "gleam_http", "gleam_otp", "gleam_stdlib", "gleam_yielder", "glisten", "gramps", "hpack_erl", "logging"], otp_app = "mist", source = "hex", outer_checksum = "7CED4B2D81FD547ADB093D97B9928B9419A7F58B8562A30A6CC17A252B31AD05" }, + { name = "mist", version = "6.0.2", build_tools = ["gleam"], requirements = ["exception", "gleam_erlang", "gleam_http", "gleam_otp", "gleam_stdlib", "gleam_yielder", "glisten", "gramps", "hpack_erl", "logging"], source = "local", path = "../../../mist" }, { name = "mockth", version = "2.0.0", build_tools = ["gleam"], requirements = ["gleam_erlang", "gleam_stdlib", "gleeunit", "meck"], source = "git", repo = "https://github.com/bondiano/mockth.git", commit = "bacecbc7cd7ffac806d84154b07360c627d235ec" }, { name = "simplifile", version = "2.4.0", build_tools = ["gleam"], requirements = ["filepath", "gleam_stdlib"], otp_app = "simplifile", source = "hex", outer_checksum = "7C18AFA4FED0B4CE1FA5B0B4BAC1FA1744427054EA993565F6F3F82E5453170D" }, - { name = "telemetry", version = "1.4.1", build_tools = ["rebar3"], requirements = [], otp_app = "telemetry", source = "hex", outer_checksum = "2172E05A27531D3D31DD9782841065C50DD5C3C7699D95266B2EDD54C2DAFA1C" }, ] [requirements] diff --git a/modules/http_client/releases/release-5.2.0.md b/modules/http_client/releases/release-5.2.0.md index 80f8bb2..b770591 100644 --- a/modules/http_client/releases/release-5.2.0.md +++ b/modules/http_client/releases/release-5.2.0.md @@ -5,12 +5,104 @@ This release replaces the underlying HTTP backend from Erlang's `httpc` to [gun](https://github.com/ninenines/gun), enabling native HTTP/2 multiplexing for high-concurrency workloads. Per-request settings (`connect_timeout`, -`auto_redirect`) are added to the `ClientRequest` builder. Connection pool -settings are managed through a redesigned `TransportConfig` type with 13 -gun-native fields. All defaults are production-reasonable — existing code -behaves identically without changes. +`auto_redirect`, `protocols`) are added to the `ClientRequest` builder. +`protocols()` enables h2c (HTTP/2 over cleartext) and per-request protocol +preference. Connection pool settings are managed through a redesigned +`TransportConfig` type with 13 gun-native fields plus `log_level` for +controlling log verbosity. All defaults are production-reasonable — existing +code behaves identically without changes. -No breaking changes to the public API. Minor version bump (5.1.3 → 5.2.0). +**Breaking changes** to error types (see Structured Error Types below). +Minor version bump (5.1.3 → 5.2.0) since the version has not been published. + +--- + +## Structured Error Types + +All string-based error representations have been replaced with structured Gleam +types that preserve the full detail gun provides. + +### Motivation + +String errors (`"econnrefused"`, `"timeout"`, `"HTTP 500 Internal Server Error: ..."`) +hide production issues. Consumers cannot reliably pattern-match on error categories +for retry logic, alerting, or circuit breakers without fragile string parsing. + +### New types + +**`TransportError`** — 8 variants preserving all gun error details: + +```gleam +pub type TransportError { + StreamReset(code: String, description: String) + Goaway(code: String, last_stream_id: Int, debug_data: String) + ConnectionError(code: String, description: String) + RemoteClosed + TimedOut(timeout_ms: Int) + ProcessDown(reason: String) + ConnectFailed(reason: String) + Unexpected(raw: String) +} +``` + +**`StreamFailure`** — distinguishes HTTP failures from transport errors: + +```gleam +pub type StreamFailure { + HttpFailure(response: HttpResponse) + TransportFailure(error: TransportError) +} +``` + +### Breaking changes + +| Before | After | +|--------|-------| +| `RequestError(message: String)` | `RequestError(error: TransportError)` | +| `StreamError(request_id, reason: String)` | `StreamError(request_id, error: StreamFailure)` | +| `on_stream_error(fn(String) -> Nil)` | `on_stream_error(fn(StreamFailure) -> Nil)` | +| `stream_yielder() -> Yielder(Result(BytesTree, String))` | `stream_yielder() -> Yielder(Result(BytesTree, StreamFailure))` | + +### Migration guide + +**Quick migration** — use the helper functions for the old string behavior: + +```gleam +// Before: +Error(client.RequestError(message: msg)) -> + io.println("Failed: " <> msg) + +// After: +Error(client.RequestError(error: err)) -> + io.println("Failed: " <> client.transport_error_to_string(err)) +``` + +**Recommended** — pattern match on error variants for structured handling: + +```gleam +Error(client.RequestError(error: err)) -> + case err { + client.ConnectFailed(reason: reason) -> + io.println("Cannot connect: " <> reason) + client.TimedOut(timeout_ms: ms) -> + io.println("Timed out after " <> int.to_string(ms) <> "ms") + _ -> + io.println(client.transport_error_to_string(err)) + } +``` + +**Streaming errors** — distinguish HTTP failures from transport errors: + +```gleam +|> client.on_stream_error(fn(failure) { + case failure { + client.HttpFailure(response: resp) -> + io.println("HTTP " <> int.to_string(resp.status) <> ": " <> resp.body) + client.TransportFailure(error: err) -> + io.println(client.transport_error_to_string(err)) + } +}) +``` --- @@ -124,6 +216,71 @@ supervised by `dream_http_client_sup`. It uses an ETS `bag` table --- +## Feature: Per-request protocol preference + +Gun supports HTTP/2 over cleartext (h2c) but the connection manager previously +hardcoded HTTP/1.1 for all TCP connections. The new `protocols()` builder on +`ClientRequest` makes protocol preference configurable per-request. + +### Usage + +```gleam +import dream_http_client/client.{Http2Only} +import gleam/http + +// h2c: HTTP/2 over cleartext using "prior knowledge" mode (RFC 7540 Section 3.4) +client.new() + |> client.scheme(http.Http) + |> client.host("internal-service.local") + |> client.protocols(Http2Only) + |> client.send() +``` + +### Variants + +- `Http1Only` — forces HTTP/1.1 (`protocols => [http]`) +- `Http2Only` — forces HTTP/2 (`protocols => [http2]`); enables h2c for TCP, h2-only ALPN for TLS +- `Http2Preferred` — prefers HTTP/2 with fallback (`protocols => [http2, http]`) + +### Defaults + +When `protocols()` is not called, existing defaults apply: HTTP/2 preferred +(via ALPN) for HTTPS, HTTP/1.1 only for HTTP. No behavior change for +existing code. + +### Implementation + +ALPN advertisement is automatically aligned with the configured protocol +preference — if you set `Http2Only`, only `h2` is advertised during the TLS +handshake. Connections with different protocol preferences get separate pool +entries to prevent protocol mismatch. + +--- + +## Logging migration: OTP `logger` + +Internal logging has been migrated from `error_logger` and raw `io:format` to +OTP's `logger` module. Connection events (graceful closures at `info` level, +unexpected disconnects at `warning` level) and decompression warnings now go +through `logger`, enabling standard OTP log level filtering. + +A new `log_level` field on `TransportConfig` controls the minimum severity for +dream_http_client's log output. It uses `logger:set_module_level/2` to scope +filtering to dream's own modules without affecting the rest of your application. + +```gleam +import dream_http_client/client.{LogWarning} + +client.transport_config() +|> client.log_level(LogWarning) +|> client.configure_transport() +``` + +Defaults to `LogInfo`. Available levels: `LogDebug`, `LogInfo`, `LogWarning`, +`LogError`, `LogNone`. + +--- + ## Architecture ### Connection lifecycle @@ -152,13 +309,16 @@ with proper method/body handling for 301/302/303/307/308. ## Test coverage -218 tests (218 total across the module): +235 tests (235 total across the module): -All existing tests pass without modification. New tests cover: +All existing tests updated for structured error types. New tests cover: - All 13 `TransportConfig` builder/getter round-trips - Default values, edge cases (zero/one values), builder chaining - `configure_transport` application - Concurrent streaming scenarios with connection pool management +- `HttpFailure` carries response headers and body +- `ConnectFailed` variant with descriptive reason +- `transport_error_to_string` and `stream_failure_to_string` helper output --- @@ -199,9 +359,9 @@ Then run: gleam deps download ``` -No breaking changes to the public API. The HTTP backend has been swapped -from `httpc` to `gun` but all public functions, types, and behaviors are -preserved. +Error types have changed (see Structured Error Types above). The HTTP +backend has been swapped from `httpc` to `gun`. Use the migration guide +above to update error handling code. ## Documentation diff --git a/modules/http_client/src/dream_http_client/client.gleam b/modules/http_client/src/dream_http_client/client.gleam index e183ba2..9f38c66 100644 --- a/modules/http_client/src/dream_http_client/client.gleam +++ b/modules/http_client/src/dream_http_client/client.gleam @@ -77,6 +77,7 @@ import dream_http_client/recorder import dream_http_client/recording import gleam/bit_array import gleam/bytes_tree +import gleam/dynamic import gleam/dynamic/decode as d import gleam/erlang/atom import gleam/erlang/process @@ -151,6 +152,151 @@ pub type HttpResponse { HttpResponse(status: Int, headers: List(Header), body: String) } +/// Transport-level error from the underlying gun HTTP client. +/// +/// Each variant preserves ALL structured information that gun provides for that +/// error class. Use `transport_error_to_string` for human-readable log output, +/// or pattern match on variants for programmatic error handling (retry logic, +/// alerting, circuit breakers). +/// +/// ## Variants +/// +/// - `StreamReset` — HTTP/2 RST_STREAM frame. The `code` field is the HTTP/2 +/// error code as a string (e.g. `"internal_error"`, `"cancel"`, +/// `"refused_stream"`, `"enhance_your_calm"`). The `description` field is +/// gun's human-readable explanation. +/// +/// - `Goaway` — HTTP/2 GOAWAY frame. The server is shutting down gracefully. +/// `last_stream_id` indicates the last stream the server will process. +/// `debug_data` may contain additional context from the server. +/// +/// - `ConnectionError` — Connection-level protocol error. The `code` field is +/// the error code and `description` is gun's human-readable explanation. +/// +/// - `RemoteClosed` — The remote peer closed the connection without sending +/// an explicit error code. +/// +/// - `TimedOut` — No response was received within the configured timeout. +/// `timeout_ms` is the value that was configured. +/// +/// - `ProcessDown` — The connection or stream owner process died unexpectedly. +/// `reason` describes why the process exited. +/// +/// - `ConnectFailed` — Could not establish a connection to the server. Common +/// causes include connection refused (server not running), DNS resolution +/// failure (NXDOMAIN), and TLS handshake errors. +/// +/// - `Unexpected` — An error that doesn't match any known pattern. `raw` +/// contains the Erlang term formatted as a string for debugging. +/// +/// ## Example +/// +/// ```gleam +/// case error { +/// ConnectFailed(reason: reason) -> +/// io.println("Cannot reach server: " <> reason) +/// TimedOut(timeout_ms: ms) -> +/// io.println("Timed out after " <> int.to_string(ms) <> "ms") +/// StreamReset(code: code, description: desc) -> +/// io.println("Stream reset: " <> code <> " — " <> desc) +/// _ -> +/// io.println(transport_error_to_string(error)) +/// } +/// ``` +pub type TransportError { + StreamReset(code: String, description: String) + Goaway(code: String, last_stream_id: Int, debug_data: String) + ConnectionError(code: String, description: String) + RemoteClosed + TimedOut(timeout_ms: Int) + ProcessDown(reason: String) + ConnectFailed(reason: String) + Unexpected(raw: String) +} + +/// Failure during streaming, distinguishing HTTP errors from transport errors. +/// +/// When a streaming request (`stream_yielder` or `start_stream`) fails, the +/// failure is either an HTTP-level rejection (the server responded with a +/// non-2xx status) or a transport-level error (the connection broke). +/// +/// ## Variants +/// +/// - `HttpFailure` — The server returned a non-2xx status code before streaming +/// began. The full `HttpResponse` is available including status, headers +/// (useful for `retry-after`, `x-request-id`), and body. +/// +/// - `TransportFailure` — A transport-level error occurred during connection +/// or streaming. See `TransportError` for the specific error variants. +/// +/// ## Example +/// +/// ```gleam +/// case failure { +/// HttpFailure(response: response) -> +/// io.println("HTTP " <> int.to_string(response.status)) +/// TransportFailure(error: ConnectFailed(reason: reason)) -> +/// io.println("Cannot connect: " <> reason) +/// TransportFailure(error: error) -> +/// io.println(transport_error_to_string(error)) +/// } +/// ``` +pub type StreamFailure { + HttpFailure(response: HttpResponse) + TransportFailure(error: TransportError) +} + +/// Convert a `TransportError` to a human-readable string for logging. +/// +/// Produces a single-line summary suitable for log messages. For programmatic +/// error handling, pattern match on the `TransportError` variants instead. +/// +/// ## Example +/// +/// ```gleam +/// let msg = transport_error_to_string(ConnectFailed(reason: "Connection refused")) +/// // => "Connection failed: Connection refused" +/// ``` +pub fn transport_error_to_string(error: TransportError) -> String { + case error { + StreamReset(code: code, description: description) -> + "Stream reset (" <> code <> "): " <> description + Goaway(code: code, last_stream_id: last_id, debug_data: debug) -> + "GOAWAY (" + <> code + <> ", last_stream_id=" + <> int.to_string(last_id) + <> "): " + <> debug + ConnectionError(code: code, description: description) -> + "Connection error (" <> code <> "): " <> description + RemoteClosed -> "Remote closed connection" + TimedOut(timeout_ms: ms) -> "Timed out after " <> int.to_string(ms) <> "ms" + ProcessDown(reason: reason) -> "Process down: " <> reason + ConnectFailed(reason: reason) -> "Connection failed: " <> reason + Unexpected(raw: raw) -> "Unexpected error: " <> raw + } +} + +/// Convert a `StreamFailure` to a human-readable string for logging. +/// +/// Produces a single-line summary suitable for log messages. For programmatic +/// error handling, pattern match on the `StreamFailure` variants instead. +/// +/// ## Example +/// +/// ```gleam +/// let msg = stream_failure_to_string(TransportFailure(error: TimedOut(timeout_ms: 5000))) +/// // => "Timed out after 5000ms" +/// ``` +pub fn stream_failure_to_string(failure: StreamFailure) -> String { + case failure { + HttpFailure(response: response) -> + "HTTP " <> int.to_string(response.status) <> ": " <> response.body + TransportFailure(error: error) -> transport_error_to_string(error) + } +} + /// Error types returned by `send()`. /// /// ## Variants @@ -160,32 +306,101 @@ pub type HttpResponse { /// headers, and body. The body typically contains error details (e.g. JSON /// error messages from an API, or HTML error pages). /// -/// - `RequestError(message: String)` — the request could not be completed. -/// The `message` describes what went wrong. Common causes: -/// - Connection refused (server not running) -/// - DNS resolution failure (hostname not found) -/// - Timeout (server did not respond in time) -/// - Recorder errors (ambiguous recording match, missing fixture) -/// - Streaming response found when blocking response expected +/// - `RequestError(error: TransportError)` — the request could not be +/// completed due to a transport-level failure. Pattern match on the +/// `TransportError` for the specific cause, or use +/// `transport_error_to_string` for logging. /// /// ## Example /// /// ```gleam /// case client.send(request) { /// Ok(response) -> -/// // Guaranteed status < 400 /// io.println("Got " <> int.to_string(response.status)) /// Error(ResponseError(response: response)) -> -/// // HTTP error — inspect response.status, response.body /// io.println("HTTP " <> int.to_string(response.status)) -/// Error(RequestError(message: message)) -> -/// // Transport failure — no HTTP response at all -/// io.println("Failed: " <> message) +/// Error(RequestError(error: ConnectFailed(reason: reason))) -> +/// io.println("Cannot connect: " <> reason) +/// Error(RequestError(error: error)) -> +/// io.println("Failed: " <> transport_error_to_string(error)) /// } /// ``` pub type SendError { ResponseError(response: HttpResponse) - RequestError(message: String) + RequestError(error: TransportError) +} + +/// Per-request protocol preference for HTTP connections. +/// +/// Controls which HTTP protocol version gun negotiates when opening a connection. +/// The connection manager uses this to set gun's `protocols` option and, for TLS +/// connections, to align ALPN advertisement accordingly. +/// +/// ## Variants +/// +/// - `Http1Only` — HTTP/1.1 only. Maps to gun `protocols => [http]`. +/// +/// - `Http2Only` — HTTP/2 only. For cleartext (TCP) connections this enables +/// h2c "prior knowledge" mode per RFC 7540 Section 3.4 — gun speaks HTTP/2 +/// directly without an upgrade dance. For TLS connections this advertises +/// only `h2` via ALPN. +/// +/// - `Http2Preferred` — Prefer HTTP/2, fall back to HTTP/1.1. Maps to gun +/// `protocols => [http2, http]`. This is the default for TLS connections +/// (ALPN negotiation). For TCP, this uses the HTTP/1.1 Upgrade mechanism +/// which most servers do not support — use `Http2Only` for cleartext +/// HTTP/2 instead. +/// +/// ## Example +/// +/// ```gleam +/// import dream_http_client/client.{Http2Only} +/// import gleam/http +/// +/// client.new() +/// |> client.scheme(http.Http) +/// |> client.host("internal-service.local") +/// |> client.protocols(Http2Only) +/// |> client.send() +/// ``` +pub type Protocols { + Http1Only + Http2Only + Http2Preferred +} + +/// Minimum log level for dream_http_client's internal log output. +/// +/// Controls which log messages are emitted by the library's connection manager +/// and HTTP shim. Uses OTP's `logger:set_module_level/2` under the hood, so +/// filtering is scoped to dream's own modules and does not affect the rest of +/// your application. +/// +/// ## Variants +/// +/// - `LogDebug` — All messages including debug-level detail. +/// - `LogInfo` — Informational messages and above (default). Includes graceful +/// connection closures. +/// - `LogWarning` — Warnings and above only. Unexpected disconnects, +/// decompression failures, unrecognized content encodings. +/// - `LogError` — Errors only. +/// - `LogNone` — Suppress all log output from dream_http_client. +/// +/// ## Example +/// +/// ```gleam +/// import dream_http_client/client.{LogWarning} +/// +/// client.transport_config() +/// |> client.log_level(LogWarning) +/// |> client.configure_transport() +/// ``` +pub type LogLevel { + LogDebug + LogInfo + LogWarning + LogError + LogNone } /// HTTP client request configuration @@ -228,7 +443,8 @@ pub opaque type ClientRequest { on_stream_start: Option(fn(List(Header)) -> Nil), on_stream_chunk: Option(fn(BitArray) -> Nil), on_stream_end: Option(fn(List(Header)) -> Nil), - on_stream_error: Option(fn(String) -> Nil), + on_stream_error: Option(fn(StreamFailure) -> Nil), + protocols: Option(Protocols), ) } @@ -276,6 +492,7 @@ pub fn new() -> ClientRequest { on_stream_chunk: None, on_stream_end: None, on_stream_error: None, + protocols: None, ) } @@ -622,16 +839,43 @@ pub fn auto_redirect( ClientRequest(..client_request, auto_redirect: option.Some(enabled)) } +/// Set the protocol preference for the connection. +/// +/// Controls which HTTP protocol version gun negotiates when opening a connection. +/// When not set, defaults to HTTP/2 preferred (via ALPN) for HTTPS connections +/// and HTTP/1.1 only for HTTP connections. +/// +/// For HTTP/2 over cleartext (h2c), use `Http2Only` with `scheme(http.Http)`: +/// +/// ```gleam +/// import dream_http_client/client.{Http2Only} +/// import gleam/http +/// +/// client.new() +/// |> client.scheme(http.Http) +/// |> client.host("internal-service.local") +/// |> client.protocols(Http2Only) +/// |> client.send() +/// ``` +pub fn protocols( + client_request: ClientRequest, + preference: Protocols, +) -> ClientRequest { + ClientRequest(..client_request, protocols: option.Some(preference)) +} + // ============================================================================ // Transport Configuration // ============================================================================ /// Configuration for the HTTP transport layer /// -/// Controls connection pool behavior and gun client options. +/// Controls connection pool behavior, gun client options, and log verbosity. /// These settings are global and affect all subsequent HTTP requests. -/// Gun negotiates HTTP/2 automatically when available (via ALPN), -/// falling back to HTTP/1.1. +/// Gun negotiates HTTP/2 automatically for TLS connections via ALPN, +/// falling back to HTTP/1.1. For cleartext (TCP) connections, the default +/// is HTTP/1.1 only; use `protocols(Http2Only)` on `ClientRequest` for +/// h2c (HTTP/2 over cleartext) per-request. /// /// Create with `transport_config()`, configure with builder functions, /// and apply with `configure_transport()`. @@ -661,6 +905,7 @@ pub opaque type TransportConfig { initial_connection_window_size: Int, initial_stream_window_size: Int, closing_timeout: Int, + log_level: LogLevel, ) } @@ -680,6 +925,7 @@ pub opaque type TransportConfig { /// - initial_connection_window_size: 65_535 (HTTP/2 connection flow control) /// - initial_stream_window_size: 65_535 (HTTP/2 per-stream flow control) /// - closing_timeout: 15_000ms (graceful shutdown wait) +/// - log_level: LogInfo (minimum severity for dream log output) pub fn transport_config() -> TransportConfig { TransportConfig( max_connections: 50, @@ -695,6 +941,7 @@ pub fn transport_config() -> TransportConfig { initial_connection_window_size: 65_535, initial_stream_window_size: 65_535, closing_timeout: 15_000, + log_level: LogInfo, ) } @@ -889,6 +1136,30 @@ pub fn closing_timeout(config: TransportConfig, ms: Int) -> TransportConfig { TransportConfig(..config, closing_timeout: ms) } +/// Set the minimum log level for dream_http_client's internal log output +/// +/// Controls which log messages are emitted by the library's connection manager +/// and HTTP shim. Filtering is scoped to dream's own modules via OTP's +/// `logger:set_module_level/2` and does not affect the rest of your application. +/// +/// ## Parameters +/// +/// - `config`: The transport config to modify +/// - `level`: Minimum log level (default: `LogInfo`) +/// +/// ## Example +/// +/// ```gleam +/// import dream_http_client/client.{LogWarning} +/// +/// client.transport_config() +/// |> client.log_level(LogWarning) +/// |> client.configure_transport() +/// ``` +pub fn log_level(config: TransportConfig, level: LogLevel) -> TransportConfig { + TransportConfig(..config, log_level: level) +} + /// Get the configured maximum TCP connections per host pub fn get_max_connections(config: TransportConfig) -> Int { config.max_connections @@ -954,6 +1225,22 @@ pub fn get_closing_timeout(config: TransportConfig) -> Int { config.closing_timeout } +/// Get the configured minimum log level +/// +/// Returns the `LogLevel` that controls which internal log messages are emitted. +/// Only messages at this level or above are output. +/// +/// ## Example +/// +/// ```gleam +/// let config = client.transport_config() +/// client.get_log_level(config) +/// // -> LogInfo +/// ``` +pub fn get_log_level(config: TransportConfig) -> LogLevel { + config.log_level +} + /// Apply transport configuration to the HTTP client /// /// Stores transport settings for use by all subsequent HTTP requests. @@ -1059,26 +1346,32 @@ pub fn on_stream_end( /// Set callback for stream error event /// /// Sets a function to be called if the stream fails with an error. -/// Handles both HTTP errors and network errors. +/// The callback receives a `StreamFailure` which distinguishes HTTP failures +/// (non-2xx response) from transport failures (connection errors, timeouts). /// /// ## Parameters /// /// - `client_request`: The request to modify -/// - `callback`: Function called with error reason if stream fails +/// - `callback`: Function called with a `StreamFailure` if stream fails /// /// ## Example /// /// ```gleam /// client.new() /// |> client.host("api.example.com") -/// |> client.on_stream_error(fn(reason) { -/// io.println_error("Stream failed: " <> reason) +/// |> client.on_stream_error(fn(failure) { +/// case failure { +/// client.HttpFailure(response: resp) -> +/// io.println_error("HTTP " <> int.to_string(resp.status)) +/// client.TransportFailure(error: err) -> +/// io.println_error("Transport: " <> client.transport_error_to_string(err)) +/// } /// }) /// |> client.start_stream() /// ``` pub fn on_stream_error( client_request: ClientRequest, - callback: fn(String) -> Nil, + callback: fn(StreamFailure) -> Nil, ) -> ClientRequest { ClientRequest(..client_request, on_stream_error: Some(callback)) } @@ -1296,6 +1589,20 @@ pub fn get_auto_redirect(client_request: ClientRequest) -> Option(Bool) { client_request.auto_redirect } +/// Get the configured protocol preference for the connection. +/// +/// Returns `None` if no explicit protocol preference has been set, +/// meaning transport-specific defaults will apply. +/// +/// ```gleam +/// let req = client.new() |> client.protocols(Http2Only) +/// client.get_protocols(req) +/// // -> Some(Http2Only) +/// ``` +pub fn get_protocols(client_request: ClientRequest) -> Option(Protocols) { + client_request.protocols +} + /// Get the recorder from a request /// /// Returns the optional recorder attached to the request for recording or playback. @@ -1386,7 +1693,7 @@ pub type StreamMessage { /// Stream completed successfully StreamEnd(request_id: RequestId, headers: List(Header)) /// Stream failed with error (connection drop, timeout, HTTP error, etc.) - StreamError(request_id: RequestId, reason: String) + StreamError(request_id: RequestId, error: StreamFailure) /// Failed to decode stream message from Erlang FFI (indicates library bug) DecodeError(reason: String) } @@ -1463,14 +1770,14 @@ pub opaque type StreamHandle { /// /// - `Ok(HttpResponse)`: Successful response (status < 400) with status, headers, and body /// - `Error(ResponseError(response))`: HTTP error response (status >= 400) with full response -/// - `Error(RequestError(message))`: Connection failure, timeout, or other transport error +/// - `Error(RequestError(error: transport_error))`: Connection failure, timeout, or other transport error /// /// ## Example /// /// ```gleam /// import dream_http_client/client.{ -/// HttpResponse, RequestError, ResponseError, -/// host, path, add_header, send, +/// HttpResponse, RequestError, ResponseError, ConnectFailed, TimedOut, +/// host, path, add_header, send, transport_error_to_string, /// } /// /// let result = client.new() @@ -1489,8 +1796,12 @@ pub opaque type StreamHandle { /// } /// Error(ResponseError(response)) -> /// Error("HTTP " <> int.to_string(response.status) <> ": " <> response.body) -/// Error(RequestError(message)) -> -/// Error("Request failed: " <> message) +/// Error(RequestError(error: ConnectFailed(reason))) -> +/// Error("Connection failed: " <> reason) +/// Error(RequestError(error: TimedOut(timeout_ms))) -> +/// Error("Timed out after " <> int.to_string(timeout_ms) <> "ms") +/// Error(RequestError(error: transport_error)) -> +/// Error("Request failed: " <> transport_error_to_string(transport_error)) /// } /// ``` pub fn send(client_request: ClientRequest) -> Result(HttpResponse, SendError) { @@ -1518,7 +1829,7 @@ fn send_with_recorder( handle_recorded_blocking_response(response) Ok(option.None) -> send_and_maybe_record(client_request, recorder_instance, recorded_request) - Error(reason) -> Error(RequestError(message: reason)) + Error(reason) -> Error(RequestError(error: Unexpected(raw: reason))) } } @@ -1529,9 +1840,11 @@ fn handle_recorded_blocking_response( recording.BlockingResponse(status, headers, body) -> response_result(status, headers, body) recording.StreamingResponse(_, _, _) -> - Error(RequestError( - message: "Recording contains streaming response, use stream_yielder() instead", - )) + Error( + RequestError(error: Unexpected( + raw: "Recording contains streaming response, use stream_yielder() instead", + )), + ) } } @@ -1564,7 +1877,8 @@ fn send_and_maybe_record( response_result(status, headers, body) } - Error(error_message) -> Error(RequestError(message: error_message)) + Error(error_dyn) -> + Error(RequestError(error: decode_transport_error(error_dyn))) } } @@ -1588,13 +1902,14 @@ fn send_client_request_via_gun( ) -> Result(HttpResponse, SendError) { case send_client_request_via_gun_with_meta(client_request) { Ok(#(status, headers, body)) -> response_result(status, headers, body) - Error(error_message) -> Error(RequestError(message: error_message)) + Error(error_dyn) -> + Error(RequestError(error: decode_transport_error(error_dyn))) } } fn send_client_request_via_gun_with_meta( client_request: ClientRequest, -) -> Result(#(Int, List(#(String, String)), String), String) { +) -> Result(#(Int, List(#(String, String)), String), d.Dynamic) { let http_request = to_http_request(client_request) let url = build_url(http_request) let method_atom = internal.atomize_method(http_request.method) @@ -1603,6 +1918,7 @@ fn send_client_request_via_gun_with_meta( let timeout_value = resolve_timeout(client_request) let connect_timeout_value = resolve_connect_timeout(client_request) let auto_redirect_value = resolve_auto_redirect(client_request) + let protocols_value = resolve_protocols(client_request) case send_sync( @@ -1613,15 +1929,22 @@ fn send_client_request_via_gun_with_meta( timeout_value, connect_timeout_value, auto_redirect_value, + protocols_value, ) { Ok(#(status, headers, response_body)) -> { - response_body - |> bit_array.to_string - |> result.map_error(convert_string_error) - |> result.map(fn(body_str) { #(status, headers, body_str) }) + case bit_array.to_string(response_body) { + Ok(body_str) -> Ok(#(status, headers, body_str)) + Error(_) -> + Error( + to_dynamic(#( + atom.create("unexpected"), + "Response body is not valid UTF-8", + )), + ) + } } - Error(error_message) -> Error(error_message) + Error(error_dyn) -> Error(error_dyn) } } @@ -1640,12 +1963,6 @@ fn client_request_to_recorded_request( ) } -fn convert_string_error(_unused: Nil) -> String { - // BitArray.to_string uses Nil for string conversion errors, so there is no - // additional error information to surface here. - "Failed to convert response to string" -} - fn resolve_timeout(client_request: ClientRequest) -> Int { case client_request.timeout { Some(timeout_value) -> timeout_value @@ -1667,6 +1984,18 @@ fn resolve_auto_redirect(client_request: ClientRequest) -> Bool { } } +fn resolve_protocols(request: ClientRequest) -> atom.Atom { + case request.protocols { + option.Some(Http1Only) -> atom.create("http1_only") + option.Some(Http2Only) -> atom.create("http2_only") + option.Some(Http2Preferred) -> atom.create("http2_preferred") + option.None -> atom.create("default") + } +} + +@external(erlang, "gleam_stdlib", "identity") +fn to_dynamic(value: a) -> d.Dynamic + @external(erlang, "dream_http_shim", "request_sync") fn send_sync( method: d.Dynamic, @@ -1676,7 +2005,8 @@ fn send_sync( timeout_ms: Int, connect_timeout_ms: Int, autoredirect: Bool, -) -> Result(#(Int, List(#(String, String)), BitArray), String) + protocols: atom.Atom, +) -> Result(#(Int, List(#(String, String)), BitArray), d.Dynamic) /// Stream HTTP response chunks using a yielder /// @@ -1706,9 +2036,9 @@ fn send_sync( /// /// ## Error Semantics /// -/// The yielder produces `Result(BytesTree, String)` for each chunk: +/// The yielder produces `Result(BytesTree, StreamFailure)` for each chunk: /// - `Ok(chunk)` - Successful chunk, more may follow -/// - `Error(reason)` - **Terminal error**, stream is done +/// - `Error(failure)` - **Terminal error**, stream is done /// /// After an `Error`, the yielder immediately returns `Done` on the next call. /// This design reflects that HTTP stream errors (timeouts, connection drops, @@ -1717,9 +2047,9 @@ fn send_sync( /// **Normal stream completion**: When the stream finishes successfully, the yielder /// returns `Done` (no more items). The stream does NOT yield an error for normal completion. /// -/// Possible error reasons (actual errors only): -/// - `"timeout"` - Request timed out -/// - Connection errors from `gun` +/// Errors are structured as `StreamFailure`: +/// - `HttpFailure(response)` - Server returned non-2xx (status, headers, body available) +/// - `TransportFailure(error)` - Transport-level failure (timeout, connection drop, etc.) /// /// ## Parameters /// @@ -1727,7 +2057,7 @@ fn send_sync( /// /// ## Returns /// -/// A `Yielder` that produces `Result(BytesTree, String)`. Always check each +/// A `Yielder` that produces `Result(BytesTree, StreamFailure)`. Always check each /// result - errors are terminal and mean the stream has ended. /// /// ## Examples @@ -1747,8 +2077,8 @@ fn send_sync( /// |> each(fn(result) { /// case result { /// Ok(chunk) -> print(to_string(chunk)) -/// Error(error_reason) -> { -/// println_error("Stream error: " <> error_reason) +/// Error(failure) -> { +/// println_error("Stream error: " <> client.stream_failure_to_string(failure)) /// // Stream is now done, no more chunks will arrive /// } /// } @@ -1783,12 +2113,12 @@ fn send_sync( /// |> string.join("") /// Ok(body) /// } -/// Error(error_reason) -> Error("Stream failed: " <> error_reason) +/// Error(failure) -> Error(client.stream_failure_to_string(failure)) /// } /// ``` pub fn stream_yielder( client_request: ClientRequest, -) -> yielder.Yielder(Result(bytes_tree.BytesTree, String)) { +) -> yielder.Yielder(Result(bytes_tree.BytesTree, StreamFailure)) { case client_request.recorder { option.Some(recorder_instance) -> stream_yielder_with_recorder(client_request, recorder_instance) @@ -1799,20 +2129,21 @@ pub fn stream_yielder( fn stream_yielder_with_recorder( client_request: ClientRequest, recorder_instance: recorder.Recorder, -) -> yielder.Yielder(Result(bytes_tree.BytesTree, String)) { +) -> yielder.Yielder(Result(bytes_tree.BytesTree, StreamFailure)) { let recorded_request = client_request_to_recorded_request(client_request) case recorder.find_recording(recorder_instance, recorded_request) { Ok(option.Some(recording.Recording(_, response))) -> create_yielder_from_recorded_response(response) Ok(option.None) -> create_stream_yielder_from_client_request(client_request) - Error(reason) -> yielder.single(Error(reason)) + Error(reason) -> + yielder.single(Error(TransportFailure(error: Unexpected(raw: reason)))) } } fn create_yielder_from_recorded_response( response: recording.RecordedResponse, -) -> yielder.Yielder(Result(bytes_tree.BytesTree, String)) { +) -> yielder.Yielder(Result(bytes_tree.BytesTree, StreamFailure)) { case response { recording.StreamingResponse(_, _, chunks) -> create_yielder_from_chunks(chunks) @@ -1826,11 +2157,12 @@ fn create_yielder_from_recorded_response( fn create_stream_yielder_from_client_request( client_request: ClientRequest, -) -> yielder.Yielder(Result(bytes_tree.BytesTree, String)) { +) -> yielder.Yielder(Result(bytes_tree.BytesTree, StreamFailure)) { let http_request = to_http_request(client_request) let timeout_value = resolve_timeout(client_request) let connect_timeout_value = resolve_connect_timeout(client_request) let auto_redirect_value = resolve_auto_redirect(client_request) + let protocols_value = resolve_protocols(client_request) case client_request.recorder { option.Some(recorder_instance) -> @@ -1841,6 +2173,7 @@ fn create_stream_yielder_from_client_request( timeout_value, connect_timeout_value, auto_redirect_value, + protocols_value, ) option.None -> create_plain_yielder( @@ -1848,6 +2181,7 @@ fn create_stream_yielder_from_client_request( timeout_value, connect_timeout_value, auto_redirect_value, + protocols_value, ) } } @@ -1859,7 +2193,8 @@ fn stream_yielder_with_record_mode( timeout_value: Int, connect_timeout_value: Int, auto_redirect_value: Bool, -) -> yielder.Yielder(Result(bytes_tree.BytesTree, String)) { + protocols_value: atom.Atom, +) -> yielder.Yielder(Result(bytes_tree.BytesTree, StreamFailure)) { case recorder.is_record_mode(recorder_instance) { True -> { let recorded_request = client_request_to_recorded_request(client_request) @@ -1870,6 +2205,7 @@ fn stream_yielder_with_record_mode( timeout_ms: timeout_value, connect_timeout_ms: connect_timeout_value, auto_redirect: auto_redirect_value, + protocols_atom: protocols_value, recorder: recorder_instance, recorded_request: recorded_request, start_headers: [], @@ -1884,6 +2220,7 @@ fn stream_yielder_with_record_mode( timeout_value, connect_timeout_value, auto_redirect_value, + protocols_value, ) } } @@ -1893,7 +2230,8 @@ fn create_plain_yielder( timeout_value: Int, connect_timeout_value: Int, auto_redirect_value: Bool, -) -> yielder.Yielder(Result(bytes_tree.BytesTree, String)) { + protocols_value: atom.Atom, +) -> yielder.Yielder(Result(bytes_tree.BytesTree, StreamFailure)) { let initial_state = YielderState( owner: None, @@ -1901,13 +2239,14 @@ fn create_plain_yielder( timeout_ms: timeout_value, connect_timeout_ms: connect_timeout_value, auto_redirect: auto_redirect_value, + protocols_atom: protocols_value, ) yielder.unfold(initial_state, handle_yielder_unfold_with_deps) } fn create_yielder_from_chunks( chunks: List(recording.Chunk), -) -> yielder.Yielder(Result(bytes_tree.BytesTree, String)) { +) -> yielder.Yielder(Result(bytes_tree.BytesTree, StreamFailure)) { chunks |> yielder.from_list |> yielder.map(convert_chunk_to_result) @@ -1915,7 +2254,7 @@ fn create_yielder_from_chunks( fn convert_chunk_to_result( chunk: recording.Chunk, -) -> Result(bytes_tree.BytesTree, String) { +) -> Result(bytes_tree.BytesTree, StreamFailure) { // TODO: Add delay based on chunk.delay_ms let data = bytes_tree.from_bit_array(chunk.data) Ok(data) @@ -1928,6 +2267,7 @@ type YielderState { timeout_ms: Int, connect_timeout_ms: Int, auto_redirect: Bool, + protocols_atom: atom.Atom, ) } @@ -1938,6 +2278,7 @@ type RecordingYielderState { timeout_ms: Int, connect_timeout_ms: Int, auto_redirect: Bool, + protocols_atom: atom.Atom, recorder: recorder.Recorder, recorded_request: recording.RecordedRequest, start_headers: List(#(String, String)), @@ -1948,7 +2289,7 @@ type RecordingYielderState { fn handle_yielder_unfold_with_deps( state: YielderState, -) -> yielder.Step(Result(bytes_tree.BytesTree, String), YielderState) { +) -> yielder.Step(Result(bytes_tree.BytesTree, StreamFailure), YielderState) { case state.owner { None -> handle_yielder_start_with_state(state) Some(owner) -> handle_yielder_next_with_state(owner, state) @@ -1995,13 +2336,14 @@ fn response_result( fn handle_yielder_start_with_state( state: YielderState, -) -> yielder.Step(Result(bytes_tree.BytesTree, String), YielderState) { +) -> yielder.Step(Result(bytes_tree.BytesTree, StreamFailure), YielderState) { let request_result = internal.start_gun_stream( state.http_req, state.timeout_ms, state.connect_timeout_ms, state.auto_redirect, + state.protocols_atom, ) let owner = internal.extract_owner_pid(request_result) case internal.receive_next(owner, state.timeout_ms) { @@ -2011,19 +2353,21 @@ fn handle_yielder_start_with_state( YielderState(..state, owner: Some(owner)), ) Ok(option.None) -> yielder.Done - Error(error_reason) -> yielder.Next(Error(error_reason), state) + Error(error_dyn) -> + yielder.Next(Error(decode_stream_failure(error_dyn)), state) } } fn handle_yielder_next_with_state( owner: d.Dynamic, state: YielderState, -) -> yielder.Step(Result(bytes_tree.BytesTree, String), YielderState) { +) -> yielder.Step(Result(bytes_tree.BytesTree, StreamFailure), YielderState) { case internal.receive_next(owner, state.timeout_ms) { Ok(option.Some(bin)) -> yielder.Next(Ok(bytes_tree.from_bit_array(bin)), state) Ok(option.None) -> yielder.Done - Error(error_reason) -> yielder.Next(Error(error_reason), state) + Error(error_dyn) -> + yielder.Next(Error(decode_stream_failure(error_dyn)), state) } } @@ -2043,7 +2387,10 @@ fn convert_time_unit(time: Int, from_unit: atom.Atom, to_unit: atom.Atom) -> Int fn handle_recording_yielder_unfold( state: RecordingYielderState, -) -> yielder.Step(Result(bytes_tree.BytesTree, String), RecordingYielderState) { +) -> yielder.Step( + Result(bytes_tree.BytesTree, StreamFailure), + RecordingYielderState, +) { case state.owner { None -> handle_recording_yielder_start(state) Some(owner) -> handle_recording_yielder_next(owner, state) @@ -2052,13 +2399,17 @@ fn handle_recording_yielder_unfold( fn handle_recording_yielder_start( state: RecordingYielderState, -) -> yielder.Step(Result(bytes_tree.BytesTree, String), RecordingYielderState) { +) -> yielder.Step( + Result(bytes_tree.BytesTree, StreamFailure), + RecordingYielderState, +) { let request_result = internal.start_gun_stream( state.http_req, state.timeout_ms, state.connect_timeout_ms, state.auto_redirect, + state.protocols_atom, ) let owner = internal.extract_owner_pid(request_result) let start_headers = case @@ -2096,9 +2447,8 @@ fn handle_recording_yielder_start( ) yielder.Done } - Error(error_reason) -> { - // Error on first chunk - don't record, just pass through error - yielder.Next(Error(error_reason), state) + Error(error_dyn) -> { + yielder.Next(Error(decode_stream_failure(error_dyn)), state) } } } @@ -2106,18 +2456,19 @@ fn handle_recording_yielder_start( fn handle_recording_yielder_next( owner: d.Dynamic, state: RecordingYielderState, -) -> yielder.Step(Result(bytes_tree.BytesTree, String), RecordingYielderState) { +) -> yielder.Step( + Result(bytes_tree.BytesTree, StreamFailure), + RecordingYielderState, +) { let now = get_time_ms() case internal.receive_next(owner, state.timeout_ms) { Ok(option.Some(bin)) -> { - // Calculate delay since last chunk let delay = case state.last_chunk_time { Some(last_time) -> now - last_time None -> 0 } - // Record the chunk let chunk = recording.Chunk(data: bin, delay_ms: delay) let new_state = RecordingYielderState( @@ -2128,14 +2479,12 @@ fn handle_recording_yielder_next( yielder.Next(Ok(bytes_tree.from_bit_array(bin)), new_state) } Ok(option.None) -> { - // Stream finished - save recording save_streaming_recording(state, state.chunks) yielder.Done } - Error(error_reason) -> { - // Stream error - save what we have so far + Error(error_dyn) -> { save_streaming_recording(state, state.chunks) - yielder.Next(Error(error_reason), state) + yielder.Next(Error(decode_stream_failure(error_dyn)), state) } } } @@ -2175,7 +2524,9 @@ fn save_streaming_recording( // Used by start_stream() to initiate the low-level HTTP stream via gun. // Note: start_stream() already handles playback via maybe_replay_from_recording() // before reaching this function. This path is for live HTTP requests only. -fn stream_messages(client_request: ClientRequest) -> Result(RequestId, String) { +fn stream_messages( + client_request: ClientRequest, +) -> Result(RequestId, StreamFailure) { case client_request.recorder { option.Some(recorder_instance) -> stream_messages_with_recorder(client_request, recorder_instance) @@ -2186,16 +2537,15 @@ fn stream_messages(client_request: ClientRequest) -> Result(RequestId, String) { fn stream_messages_with_recorder( client_request: ClientRequest, recorder_instance: recorder.Recorder, -) -> Result(RequestId, String) { +) -> Result(RequestId, StreamFailure) { let recorded_request = client_request_to_recorded_request(client_request) case recorder.find_recording(recorder_instance, recorded_request) { Ok(option.Some(_recording)) -> - // Safety net: start_stream() replays via maybe_replay_from_recording() - // before reaching here. If we land here anyway, it means the low-level - // gun message path cannot replay recordings. Error( - "Unexpected: recording found in stream_messages path. This should have been handled by start_stream() playback.", + TransportFailure(error: Unexpected( + raw: "Unexpected: recording found in stream_messages path. This should have been handled by start_stream() playback.", + )), ) Ok(option.None) -> send_stream_messages_via_gun( @@ -2203,13 +2553,13 @@ fn stream_messages_with_recorder( option.Some(recorder_instance), recorded_request, ) - Error(reason) -> Error(reason) + Error(reason) -> Error(TransportFailure(error: Unexpected(raw: reason))) } } fn stream_messages_without_recorder( client_request: ClientRequest, -) -> Result(RequestId, String) { +) -> Result(RequestId, StreamFailure) { let recorded_request = client_request_to_recorded_request(client_request) send_stream_messages_via_gun(client_request, option.None, recorded_request) } @@ -2218,7 +2568,7 @@ fn send_stream_messages_via_gun( client_request: ClientRequest, recorder_option: Option(recorder.Recorder), recorded_request: recording.RecordedRequest, -) -> Result(RequestId, String) { +) -> Result(RequestId, StreamFailure) { let http_request = to_http_request(client_request) let url = build_url(http_request) let method_atom = internal.atomize_method(http_request.method) @@ -2227,6 +2577,7 @@ fn send_stream_messages_via_gun( let timeout_value = resolve_timeout(client_request) let connect_timeout_value = resolve_connect_timeout(client_request) let auto_redirect_value = resolve_auto_redirect(client_request) + let protocols_value = resolve_protocols(client_request) let start_result = internal.start_stream_messages( @@ -2238,6 +2589,7 @@ fn send_stream_messages_via_gun( timeout_value, connect_timeout_value, auto_redirect_value, + protocols_value, ) case parse_stream_start_result(start_result) { @@ -2291,53 +2643,55 @@ fn build_url(request: request.Request(String)) -> String { <> query_string } -fn parse_stream_start_result(result: d.Dynamic) -> Result(RequestId, String) { +fn parse_stream_start_result( + result: d.Dynamic, +) -> Result(RequestId, StreamFailure) { let tag_result = d.run(result, d.at([0], d.dynamic)) case tag_result { Ok(tag_dyn) -> parse_stream_start_tag(tag_dyn, result) Error(decode_errors) -> - Error("Failed to parse gun response: " <> string.inspect(decode_errors)) + Error( + TransportFailure(error: Unexpected( + raw: "Failed to parse gun response: " <> string.inspect(decode_errors), + )), + ) } } fn parse_stream_start_tag( tag_dyn: d.Dynamic, result: d.Dynamic, -) -> Result(RequestId, String) { +) -> Result(RequestId, StreamFailure) { let tag = atom.cast_from_dynamic(tag_dyn) |> atom.to_string case tag { "ok" -> extract_request_id(result) - "error" -> extract_error_reason(result) - _ -> Error("Unknown response from gun") + "error" -> Error(extract_error_reason(result)) + _ -> + Error( + TransportFailure(error: Unexpected(raw: "Unknown response from gun")), + ) } } -fn extract_request_id(result: d.Dynamic) -> Result(RequestId, String) { +fn extract_request_id(result: d.Dynamic) -> Result(RequestId, StreamFailure) { let id_result = d.run(result, d.at([1], d.string)) case id_result { Ok(id_string) -> Ok(RequestId(id: id_string)) Error(decode_errors) -> - Error("Failed to extract request ID: " <> string.inspect(decode_errors)) - } -} - -fn extract_error_reason(result: d.Dynamic) -> Result(RequestId, String) { - let reason_result = d.run(result, d.at([1], d.dynamic)) - case reason_result { - Ok(reason_dyn) -> { - let reason = string.inspect(reason_dyn) - Error("Failed to start stream: " <> reason) - } - Error(decode_error) -> { Error( - "Failed to start stream (decode error: " - <> string.inspect(decode_error) - <> ")", + TransportFailure(error: Unexpected( + raw: "Failed to extract request ID: " <> string.inspect(decode_errors), + )), ) - } } } +fn extract_error_reason(result: d.Dynamic) -> StreamFailure { + let error_dyn = + d.run(result, d.at([1], d.dynamic)) |> result.unwrap(dynamic.nil()) + decode_stream_failure(error_dyn) +} + // Internal: Add stream message handling to a selector // Used by start_stream() to receive HTTP messages in spawned process fn select_stream_messages( @@ -2398,7 +2752,7 @@ fn handle_tag_decode_error( case req_id_result { Ok(req_id_string) -> { let req_id = RequestId(id: req_id_string) - StreamError(req_id, error_msg) + StreamError(req_id, TransportFailure(error: Unexpected(raw: error_msg))) } Error(req_id_error) -> { let full_error_msg = @@ -2443,7 +2797,12 @@ fn decode_by_tag( "stream_end" -> decode_stream_end(req_id, data_result) "stream_error" -> decode_stream_error(req_id, data_result) _ -> - StreamError(req_id, "Internal error: Unknown stream message tag: " <> tag) + StreamError( + req_id, + TransportFailure(error: Unexpected( + raw: "Internal error: Unknown stream message tag: " <> tag, + )), + ) } } @@ -2453,12 +2812,14 @@ fn decode_stream_start( ) -> StreamMessage { case data_result { Ok(headers_dyn) -> decode_stream_start_headers(req_id, headers_dyn) - Error(decode_error) -> { - let error_msg = - "Failed to get headers data in StreamStart: " - <> string.inspect(decode_error) - StreamError(req_id, error_msg) - } + Error(decode_error) -> + StreamError( + req_id, + TransportFailure(error: Unexpected( + raw: "Failed to get headers data in StreamStart: " + <> string.inspect(decode_error), + )), + ) } } @@ -2468,12 +2829,14 @@ fn decode_stream_start_headers( ) -> StreamMessage { case decode_headers(headers_dyn) { Ok(headers) -> StreamStart(req_id, tuples_to_headers(headers)) - Error(header_decode_error) -> { - let error_msg = - "Failed to decode headers in StreamStart: " - <> string.inspect(header_decode_error) - StreamError(req_id, error_msg) - } + Error(header_decode_error) -> + StreamError( + req_id, + TransportFailure(error: Unexpected( + raw: "Failed to decode headers in StreamStart: " + <> string.inspect(header_decode_error), + )), + ) } } @@ -2483,24 +2846,28 @@ fn decode_chunk( ) -> StreamMessage { case data_result { Ok(data_dyn) -> decode_chunk_data(req_id, data_dyn) - Error(decode_error) -> { - let error_msg = - "Internal error: Failed to get chunk data: " - <> string.inspect(decode_error) - StreamError(req_id, error_msg) - } + Error(decode_error) -> + StreamError( + req_id, + TransportFailure(error: Unexpected( + raw: "Internal error: Failed to get chunk data: " + <> string.inspect(decode_error), + )), + ) } } fn decode_chunk_data(req_id: RequestId, data_dyn: d.Dynamic) -> StreamMessage { case d.run(data_dyn, d.bit_array) { Ok(data) -> Chunk(req_id, data) - Error(decode_error) -> { - let error_msg = - "Internal error: Failed to decode chunk data: " - <> string.inspect(decode_error) - StreamError(req_id, error_msg) - } + Error(decode_error) -> + StreamError( + req_id, + TransportFailure(error: Unexpected( + raw: "Internal error: Failed to decode chunk data: " + <> string.inspect(decode_error), + )), + ) } } @@ -2510,12 +2877,14 @@ fn decode_stream_end( ) -> StreamMessage { case data_result { Ok(headers_dyn) -> decode_stream_end_headers(req_id, headers_dyn) - Error(decode_error) -> { - let error_msg = - "Failed to get trailing headers data in StreamEnd: " - <> string.inspect(decode_error) - StreamError(req_id, error_msg) - } + Error(decode_error) -> + StreamError( + req_id, + TransportFailure(error: Unexpected( + raw: "Failed to get trailing headers data in StreamEnd: " + <> string.inspect(decode_error), + )), + ) } } @@ -2525,46 +2894,100 @@ fn decode_stream_end_headers( ) -> StreamMessage { case decode_headers(headers_dyn) { Ok(headers) -> StreamEnd(req_id, tuples_to_headers(headers)) - Error(header_decode_error) -> { - let error_msg = - "Failed to decode trailing headers in StreamEnd: " - <> string.inspect(header_decode_error) - StreamError(req_id, error_msg) + Error(header_decode_error) -> + StreamError( + req_id, + TransportFailure(error: Unexpected( + raw: "Failed to decode trailing headers in StreamEnd: " + <> string.inspect(header_decode_error), + )), + ) + } +} + +fn decode_transport_error(dyn: d.Dynamic) -> TransportError { + let tag = + d.run(dyn, d.at([0], d.dynamic)) + |> result.map(fn(t) { atom.to_string(atom.cast_from_dynamic(t)) }) + |> result.unwrap("") + case tag { + "stream_reset" -> { + let code = d.run(dyn, d.at([1], d.string)) |> result.unwrap("unknown") + let description = d.run(dyn, d.at([2], d.string)) |> result.unwrap("") + StreamReset(code: code, description: description) } + "goaway" -> { + let code = d.run(dyn, d.at([1], d.string)) |> result.unwrap("unknown") + let last_stream_id = d.run(dyn, d.at([2], d.int)) |> result.unwrap(0) + let debug_data = d.run(dyn, d.at([3], d.string)) |> result.unwrap("") + Goaway(code: code, last_stream_id: last_stream_id, debug_data: debug_data) + } + "connection_error" -> { + let code = d.run(dyn, d.at([1], d.string)) |> result.unwrap("unknown") + let description = d.run(dyn, d.at([2], d.string)) |> result.unwrap("") + ConnectionError(code: code, description: description) + } + "remote_closed" -> RemoteClosed + "timed_out" -> { + let timeout_ms = d.run(dyn, d.at([1], d.int)) |> result.unwrap(0) + TimedOut(timeout_ms: timeout_ms) + } + "process_down" -> { + let reason = d.run(dyn, d.at([1], d.string)) |> result.unwrap("unknown") + ProcessDown(reason: reason) + } + "connect_failed" -> { + let reason = d.run(dyn, d.at([1], d.string)) |> result.unwrap("unknown") + ConnectFailed(reason: reason) + } + "unexpected" -> { + let raw = + d.run(dyn, d.at([1], d.string)) |> result.unwrap(string.inspect(dyn)) + Unexpected(raw: raw) + } + _ -> Unexpected(raw: "Unknown error tag: " <> tag) } } -fn decode_stream_error( - req_id: RequestId, - data_result: Result(d.Dynamic, List(d.DecodeError)), -) -> StreamMessage { - case data_result { - Ok(reason_dyn) -> decode_error_reason(req_id, reason_dyn) - Error(decode_error) -> { - let error_msg = - "Stream error (failed to decode error reason: " - <> string.inspect(decode_error) - <> ")" - StreamError(req_id, error_msg) +fn decode_stream_failure(dyn: d.Dynamic) -> StreamFailure { + let tag = + d.run(dyn, d.at([0], d.dynamic)) + |> result.map(fn(t) { atom.to_string(atom.cast_from_dynamic(t)) }) + |> result.unwrap("") + case tag { + "http_failure" -> { + let status = d.run(dyn, d.at([1], d.int)) |> result.unwrap(0) + let headers = + d.run(dyn, d.at([2], d.list(d.dynamic))) + |> result.unwrap([]) + |> list.filter_map(fn(h) { + case d.run(h, d.at([0], d.string)), d.run(h, d.at([1], d.string)) { + Ok(name), Ok(value) -> Ok(Header(name: name, value: value)) + _, _ -> Error(Nil) + } + }) + let body = d.run(dyn, d.at([3], d.string)) |> result.unwrap("") + HttpFailure(response: HttpResponse( + status: status, + headers: headers, + body: body, + )) } + _ -> TransportFailure(error: decode_transport_error(dyn)) } } -fn decode_error_reason( +fn decode_stream_error( req_id: RequestId, - reason_dyn: d.Dynamic, + data_result: Result(d.Dynamic, List(d.DecodeError)), ) -> StreamMessage { - case d.run(reason_dyn, d.string) { - Ok(reason) -> StreamError(req_id, reason) - Error(_) -> - case d.run(reason_dyn, d.bit_array) { - Ok(bytes) -> - case bit_array.to_string(bytes) { - Ok(s) -> StreamError(req_id, s) - Error(_) -> StreamError(req_id, string.inspect(reason_dyn)) - } - Error(_) -> StreamError(req_id, string.inspect(reason_dyn)) - } + case data_result { + Ok(error_dyn) -> StreamError(req_id, decode_stream_failure(error_dyn)) + Error(decode_error) -> + StreamError( + req_id, + TransportFailure(error: Unexpected(raw: string.inspect(decode_error))), + ) } } @@ -2632,8 +3055,8 @@ fn pair_with_name(value: String, name: String) -> #(String, String) { /// Error(_) -> Nil /// } /// }) -/// |> client.on_stream_error(fn(reason) { -/// io.println_error("Error: " <> reason) +/// |> client.on_stream_error(fn(failure) { +/// io.println_error("Error: " <> client.stream_failure_to_string(failure)) /// }) /// |> client.start_stream() /// @@ -2660,10 +3083,9 @@ fn run_stream_process(request: ClientRequest) -> Nil { // Start the stream using internal API case stream_messages(request) { - Error(reason) -> { - // Call error callback if set + Error(failure) -> { case request.on_stream_error { - Some(on_error) -> on_error(reason) + Some(on_error) -> on_error(failure) None -> Nil } } @@ -2747,9 +3169,9 @@ fn process_stream_loop( handle_stream_message(message, req_id, request, selector, timeout_ms) } Error(Nil) -> { - // Timeout waiting for messages case request.on_stream_error { - Some(on_error) -> on_error("Timeout waiting for stream messages") + Some(on_error) -> + on_error(TransportFailure(error: TimedOut(timeout_ms: timeout_ms))) None -> Nil } } @@ -2803,11 +3225,11 @@ fn handle_stream_message( } } - StreamError(stream_req_id, reason) -> { + StreamError(stream_req_id, failure) -> { case stream_req_id == req_id { True -> { case request.on_stream_error { - Some(on_error) -> on_error(reason) + Some(on_error) -> on_error(failure) None -> Nil } Nil @@ -2818,7 +3240,10 @@ fn handle_stream_message( DecodeError(reason) -> { case request.on_stream_error { - Some(on_error) -> on_error("DecodeError: " <> reason) + Some(on_error) -> + on_error( + TransportFailure(error: Unexpected(raw: "DecodeError: " <> reason)), + ) None -> Nil } Nil @@ -3024,13 +3449,13 @@ fn record_stream_message(message: StreamMessage) -> Nil { option.None -> Nil } } - StreamError(request_id, error_reason) -> { + StreamError(request_id, failure) -> { let RequestId(request_id_string) = request_id io.println_error( "HTTP stream error while recording messages for request " <> request_id_string <> ": " - <> error_reason, + <> stream_failure_to_string(failure), ) case get_message_stream_recorder(request_id) { option.Some(state) -> { diff --git a/modules/http_client/src/dream_http_client/dream_http_client_app.erl b/modules/http_client/src/dream_http_client/dream_http_client_app.erl index 2ed1e40..9b21d48 100644 --- a/modules/http_client/src/dream_http_client/dream_http_client_app.erl +++ b/modules/http_client/src/dream_http_client/dream_http_client_app.erl @@ -7,6 +7,7 @@ start(_Type, _Args) -> ets:new(dream_http_client_stream_recorders, [set, public, named_table]), ets:new(dream_http_client_transport_config, [set, public, named_table]), ets:new(dream_http_client_connections, [bag, public, named_table]), + logger:set_module_level([dream_http_conn_manager, dream_http_shim], info), dream_http_client_sup:start_link(). stop(_State) -> diff --git a/modules/http_client/src/dream_http_client/dream_http_conn_manager.erl b/modules/http_client/src/dream_http_client/dream_http_conn_manager.erl index 4549f30..3b4f36b 100644 --- a/modules/http_client/src/dream_http_client/dream_http_conn_manager.erl +++ b/modules/http_client/src/dream_http_client/dream_http_conn_manager.erl @@ -1,7 +1,7 @@ -module(dream_http_conn_manager). -behaviour(gen_server). --export([start_link/0, get_connection/3, ensure_connection/4]). +-export([start_link/0, get_connection/4, ensure_connection/4]). -export([init/1, handle_call/3, handle_cast/2, handle_info/2, terminate/2]). -define(TABLE, dream_http_client_connections). @@ -11,8 +11,8 @@ start_link() -> %% Hot path: lock-free ETS lookup with round-robin selection. %% Returns {ok, ConnPid, Protocol} | none. -get_connection(Scheme, Host, Port) -> - case ets:lookup(?TABLE, {Scheme, Host, Port}) of +get_connection(Scheme, Host, Port, Protocols) -> + case ets:lookup(?TABLE, {Scheme, Host, Port, Protocols}) of [] -> none; Entries -> @@ -69,7 +69,13 @@ init([]) -> {ok, #{}}. handle_call({ensure_connection, Scheme, Host, Port, GunOpts}, _From, State) -> - Key = {Scheme, Host, Port}, + Transport = case Scheme of + https -> tls; + _ -> tcp + end, + Protocols = resolve_protocols(GunOpts, Transport), + ResolvedOpts = GunOpts#{protocols => Protocols}, + Key = {Scheme, Host, Port, Protocols}, Existing = ets:lookup(?TABLE, Key), %% Clean up dead connections first Alive = [E || {_, Pid, _, _, _} = E <- Existing, erlang:is_process_alive(Pid)], @@ -88,7 +94,7 @@ handle_call({ensure_connection, Scheme, Host, Port, GunOpts}, _From, State) -> AliveCount = length(Alive), case AliveCount < MaxConns of true -> - case open_connection(Scheme, Host, Port, GunOpts) of + case open_connection(Scheme, Host, Port, ResolvedOpts) of {ok, ConnPid, Protocol} -> MonRef = erlang:monitor(process, ConnPid), Now = erlang:monotonic_time(millisecond), @@ -123,10 +129,15 @@ handle_info(check_idle, State) -> handle_info({gun_up, _ConnPid, _Protocol}, State) -> {noreply, State}; -handle_info({gun_down, _ConnPid, _Protocol, _Reason, _KilledStreams}, State) -> +handle_info({gun_down, ConnPid, Protocol, Reason, KilledStreams}, State) -> + Killed = length(KilledStreams), + log_gun_down(ConnPid, Protocol, Reason, Killed, 0), {noreply, State}; -handle_info({gun_down, _ConnPid, _Protocol, _Reason, _KilledStreams, _UnprocessedStreams}, State) -> +handle_info({gun_down, ConnPid, Protocol, Reason, KilledStreams, UnprocessedStreams}, State) -> + Killed = length(KilledStreams), + Unprocessed = length(UnprocessedStreams), + log_gun_down(ConnPid, Protocol, Reason, Killed, Unprocessed), {noreply, State}; handle_info(_Info, State) -> @@ -149,10 +160,10 @@ open_connection(Scheme, Host, Port, GunOpts) -> true -> binary_to_list(Host); false -> Host end, - Protocols = case Transport of + Protocols = maps:get(protocols, GunOpts, case Transport of tls -> [http2, http]; tcp -> [http] - end, + end), BaseOpts = #{ transport => Transport, connect_timeout => ConnectTimeout, @@ -169,12 +180,13 @@ open_connection(Scheme, Host, Port, GunOpts) -> end, TlsOpts = case Transport of tls -> + AlpnProtocols = resolve_alpn(Protocols), Opts#{tls_opts => [ {verify, verify_peer}, {cacerts, public_key:cacerts_get()}, {depth, 3}, {customize_hostname_check, [{match_fun, public_key:pkix_verify_hostname_match_fun(https)}]}, - {alpn_advertised_protocols, [<<"h2">>, <<"http/1.1">>]} + {alpn_advertised_protocols, AlpnProtocols} ]}; tcp -> Opts end, @@ -247,6 +259,45 @@ reap_idle_connections() -> error:badarg -> ok end. +log_gun_down(ConnPid, Protocol, Reason, Killed, Unprocessed) -> + case is_expected_disconnect(Reason, Killed) of + true -> + logger:info( + "[dream_http] connection closed: pid=~p protocol=~p reason=~p", + [ConnPid, Protocol, Reason]); + false when Unprocessed > 0 -> + logger:warning( + "[dream_http] connection down: pid=~p protocol=~p reason=~p killed=~p unprocessed=~p", + [ConnPid, Protocol, Reason, Killed, Unprocessed]); + false -> + logger:warning( + "[dream_http] connection down: pid=~p protocol=~p reason=~p killed_streams=~p", + [ConnPid, Protocol, Reason, Killed]) + end. + +is_expected_disconnect(closed, 0) -> true; +is_expected_disconnect(normal, 0) -> true; +is_expected_disconnect(_, _) -> false. + +resolve_protocols(GunOpts, Transport) -> + case maps:get(protocols, GunOpts, default) of + default -> + case Transport of + tls -> [http2, http]; + tcp -> [http] + end; + http1_only -> [http]; + http2_only -> [http2]; + http2_preferred -> [http2, http] + end. + +resolve_alpn(Protocols) -> + lists:filtermap(fun + (http2) -> {true, <<"h2">>}; + (http) -> {true, <<"http/1.1">>}; + (_) -> false + end, Protocols). + get_idle_timeout() -> case ets:lookup(dream_http_client_transport_config, config) of [{config, Config}] -> @@ -254,3 +305,4 @@ get_idle_timeout() -> [] -> 60000 end. + diff --git a/modules/http_client/src/dream_http_client/dream_http_shim.erl b/modules/http_client/src/dream_http_client/dream_http_shim.erl index 44c0ba9..42b2d34 100644 --- a/modules/http_client/src/dream_http_client/dream_http_shim.erl +++ b/modules/http_client/src/dream_http_client/dream_http_shim.erl @@ -1,8 +1,8 @@ -module(dream_http_shim). --export([request_stream/8, fetch_next/2, fetch_start_headers/2, request_stream_messages/8, +-export([request_stream/9, fetch_next/2, fetch_start_headers/2, request_stream_messages/9, cancel_stream/1, cancel_stream_by_string/1, receive_stream_message/1, - decode_stream_message_for_selector/1, normalize_headers/1, request_sync/7, + decode_stream_message_for_selector/1, normalize_headers/1, request_sync/8, configure_transport/1, ets_table_exists/1, ets_new/2, ets_insert/7, ets_lookup/2, ets_delete/2]). @@ -13,16 +13,16 @@ %% Synchronous (blocking) request %% ============================================================================ -request_sync(Method, Url, Headers, Body, TimeoutMs, ConnectTimeoutMs, AutoRedirect) -> +request_sync(Method, Url, Headers, Body, TimeoutMs, ConnectTimeoutMs, AutoRedirect, Protocols) -> NHeaders = maybe_add_accept_encoding(to_gun_headers(Headers)), - request_sync_impl(Method, Url, NHeaders, Body, TimeoutMs, ConnectTimeoutMs, AutoRedirect, 0, 1). + request_sync_impl(Method, Url, NHeaders, Body, TimeoutMs, ConnectTimeoutMs, AutoRedirect, 0, 1, Protocols). -request_sync_impl(_Method, _Url, _Headers, _Body, _TimeoutMs, _ConnectTimeoutMs, _AutoRedirect, Redirects, _RetriesLeft) when Redirects >= ?MAX_REDIRECTS -> - {error, <<"too_many_redirects">>}; -request_sync_impl(Method, Url, Headers, Body, TimeoutMs, ConnectTimeoutMs, AutoRedirect, Redirects, RetriesLeft) -> +request_sync_impl(_Method, _Url, _Headers, _Body, _TimeoutMs, _ConnectTimeoutMs, _AutoRedirect, Redirects, _RetriesLeft, _Protocols) when Redirects >= ?MAX_REDIRECTS -> + {error, {unexpected, <<"Too many redirects (max 5)">>}}; +request_sync_impl(Method, Url, Headers, Body, TimeoutMs, ConnectTimeoutMs, AutoRedirect, Redirects, RetriesLeft, Protocols) -> case parse_url(Url) of {ok, Scheme, Host, Port, PathQs} -> - GunOpts = build_gun_opts(ConnectTimeoutMs), + GunOpts = build_gun_opts(ConnectTimeoutMs, Protocols), case get_or_open_connection(Scheme, Host, Port, GunOpts) of {ok, ConnPid, _Protocol} -> MethodAtom = to_method_atom(Method), @@ -31,54 +31,54 @@ request_sync_impl(Method, Url, Headers, Body, TimeoutMs, ConnectTimeoutMs, AutoR {response, fin, Status, RespHeaders} -> NormHeaders = normalize_headers(RespHeaders), handle_sync_response(Status, NormHeaders, <<>>, AutoRedirect, Redirects, - Method, Url, Headers, Body, TimeoutMs, ConnectTimeoutMs, RespHeaders); + Method, Url, Headers, Body, TimeoutMs, ConnectTimeoutMs, RespHeaders, Protocols); {response, nofin, Status, RespHeaders} -> case gun:await_body(ConnPid, StreamRef, TimeoutMs) of {ok, RespBody} -> {DecompBody, CleanHeaders} = maybe_decompress_response(RespBody, RespHeaders), NormHeaders = normalize_headers(CleanHeaders), handle_sync_response(Status, NormHeaders, DecompBody, AutoRedirect, Redirects, - Method, Url, Headers, Body, TimeoutMs, ConnectTimeoutMs, RespHeaders); + Method, Url, Headers, Body, TimeoutMs, ConnectTimeoutMs, RespHeaders, Protocols); {ok, RespBody, _Trailers} -> {DecompBody, CleanHeaders} = maybe_decompress_response(RespBody, RespHeaders), NormHeaders = normalize_headers(CleanHeaders), handle_sync_response(Status, NormHeaders, DecompBody, AutoRedirect, Redirects, - Method, Url, Headers, Body, TimeoutMs, ConnectTimeoutMs, RespHeaders); + Method, Url, Headers, Body, TimeoutMs, ConnectTimeoutMs, RespHeaders, Protocols); {error, timeout} -> - {error, <<"timeout">>}; + {error, {timed_out, TimeoutMs}}; {error, Reason} -> - {error, format_error(Reason)} + {error, classify_error(Reason, TimeoutMs)} end; {error, timeout} -> - {error, <<"timeout">>}; - {error, {stream_error, Reason, _HumanReadable}} -> + {error, {timed_out, TimeoutMs}}; + {error, {stream_error, Reason, HumanReadable}} -> case RetriesLeft > 0 andalso is_stale_connection_error({stream_error, Reason}) of true -> gun:close(ConnPid), request_sync_impl(Method, Url, Headers, Body, TimeoutMs, - ConnectTimeoutMs, AutoRedirect, Redirects, RetriesLeft - 1); + ConnectTimeoutMs, AutoRedirect, Redirects, RetriesLeft - 1, Protocols); false -> - {error, format_error(Reason)} + {error, classify_error({stream_error, Reason, HumanReadable}, TimeoutMs)} end; {error, Reason} -> case RetriesLeft > 0 andalso is_stale_connection_error(Reason) of true -> gun:close(ConnPid), request_sync_impl(Method, Url, Headers, Body, TimeoutMs, - ConnectTimeoutMs, AutoRedirect, Redirects, RetriesLeft - 1); + ConnectTimeoutMs, AutoRedirect, Redirects, RetriesLeft - 1, Protocols); false -> - {error, format_error(Reason)} + {error, classify_error(Reason, TimeoutMs)} end end; {error, Reason} -> - {error, format_connection_error(Reason)} + {error, classify_connect_error(Reason)} end; {error, Reason} -> - {error, format_error(Reason)} + {error, classify_error(Reason, TimeoutMs)} end. handle_sync_response(Status, NormHeaders, DecompBody, AutoRedirect, Redirects, - Method, Url, OrigHeaders, OrigBody, TimeoutMs, ConnectTimeoutMs, RawRespHeaders) -> + Method, Url, OrigHeaders, OrigBody, TimeoutMs, ConnectTimeoutMs, RawRespHeaders, Protocols) -> case AutoRedirect andalso is_redirect(Status) of true -> case get_location(RawRespHeaders) of @@ -87,7 +87,7 @@ handle_sync_response(Status, NormHeaders, DecompBody, AutoRedirect, Redirects, RedirectMethod = redirect_method(Status, Method), RedirectBody = redirect_body(Status, OrigBody), request_sync_impl(RedirectMethod, ResolvedUrl, OrigHeaders, RedirectBody, - TimeoutMs, ConnectTimeoutMs, AutoRedirect, Redirects + 1, 1); + TimeoutMs, ConnectTimeoutMs, AutoRedirect, Redirects + 1, 1, Protocols); error -> {ok, {Status, NormHeaders, DecompBody}} end; @@ -99,15 +99,15 @@ handle_sync_response(Status, NormHeaders, DecompBody, AutoRedirect, Redirects, %% Pull-based streaming %% ============================================================================ -request_stream(Method, Url, Headers, Body, _Receiver, TimeoutMs, ConnectTimeoutMs, AutoRedirect) -> +request_stream(Method, Url, Headers, Body, _Receiver, TimeoutMs, ConnectTimeoutMs, AutoRedirect, Protocols) -> NHeaders = maybe_add_accept_encoding(to_gun_headers(Headers)), Owner = spawn(fun() -> - stream_owner_init(Method, Url, NHeaders, Body, TimeoutMs, ConnectTimeoutMs, AutoRedirect) + stream_owner_init(Method, Url, NHeaders, Body, TimeoutMs, ConnectTimeoutMs, AutoRedirect, Protocols) end), {ok, Owner}. -stream_owner_init(Method, Url, Headers, Body, TimeoutMs, ConnectTimeoutMs, AutoRedirect) -> - case start_gun_stream(Method, Url, Headers, Body, TimeoutMs, ConnectTimeoutMs, AutoRedirect, 0) of +stream_owner_init(Method, Url, Headers, Body, TimeoutMs, ConnectTimeoutMs, AutoRedirect, Protocols) -> + case start_gun_stream(Method, Url, Headers, Body, TimeoutMs, ConnectTimeoutMs, AutoRedirect, 0, Protocols) of {ok, ConnPid, StreamRef, _Status, RespHeaders} -> ZlibCtx = maybe_init_stream_zlib(RespHeaders), NormHeaders = normalize_headers(RespHeaders), @@ -118,10 +118,10 @@ stream_owner_init(Method, Url, Headers, Body, TimeoutMs, ConnectTimeoutMs, AutoR %% Follows redirects, then returns {ok, ConnPid, StreamRef, Status, Headers} for a %% streaming response (status 2xx), or {error, Reason} for non-2xx / failure. -start_gun_stream(Method, Url, Headers, Body, TimeoutMs, ConnectTimeoutMs, _AutoRedirect, Redirects) when Redirects >= ?MAX_REDIRECTS -> - start_gun_stream_final(Method, Url, Headers, Body, TimeoutMs, ConnectTimeoutMs); -start_gun_stream(Method, Url, Headers, Body, TimeoutMs, ConnectTimeoutMs, AutoRedirect, Redirects) -> - case start_gun_stream_final(Method, Url, Headers, Body, TimeoutMs, ConnectTimeoutMs) of +start_gun_stream(Method, Url, Headers, Body, TimeoutMs, ConnectTimeoutMs, _AutoRedirect, Redirects, Protocols) when Redirects >= ?MAX_REDIRECTS -> + start_gun_stream_final(Method, Url, Headers, Body, TimeoutMs, ConnectTimeoutMs, Protocols); +start_gun_stream(Method, Url, Headers, Body, TimeoutMs, ConnectTimeoutMs, AutoRedirect, Redirects, Protocols) -> + case start_gun_stream_final(Method, Url, Headers, Body, TimeoutMs, ConnectTimeoutMs, Protocols) of {ok, ConnPid, StreamRef, Status, RespHeaders} -> case AutoRedirect andalso is_redirect(Status) of true -> @@ -133,7 +133,7 @@ start_gun_stream(Method, Url, Headers, Body, TimeoutMs, ConnectTimeoutMs, AutoRe RedirectMethod = redirect_method(Status, Method), RedirectBody = redirect_body(Status, Body), start_gun_stream(RedirectMethod, ResolvedUrl, Headers, RedirectBody, - TimeoutMs, ConnectTimeoutMs, AutoRedirect, Redirects + 1); + TimeoutMs, ConnectTimeoutMs, AutoRedirect, Redirects + 1, Protocols); error -> {ok, ConnPid, StreamRef, Status, RespHeaders} end; @@ -143,24 +143,20 @@ start_gun_stream(Method, Url, Headers, Body, TimeoutMs, ConnectTimeoutMs, AutoRe {ok, ConnPid, StreamRef, Status, RespHeaders}; false -> FullBody = collect_body(ConnPid, StreamRef, TimeoutMs), - StatusBin = integer_to_binary(Status), - ReasonPhrase = status_reason(Status), - SafeBody = ensure_utf8_binary(FullBody), - ErrorMsg = <<"HTTP ", StatusBin/binary, " ", ReasonPhrase/binary, ": ", SafeBody/binary>>, - {error, ErrorMsg} + {error, {http_failure, Status, normalize_headers(RespHeaders), ensure_utf8_binary(FullBody)}} end end; {error, Reason} -> {error, Reason} end. -start_gun_stream_final(Method, Url, Headers, Body, TimeoutMs, ConnectTimeoutMs) -> - start_gun_stream_final(Method, Url, Headers, Body, TimeoutMs, ConnectTimeoutMs, 1). +start_gun_stream_final(Method, Url, Headers, Body, TimeoutMs, ConnectTimeoutMs, Protocols) -> + start_gun_stream_final(Method, Url, Headers, Body, TimeoutMs, ConnectTimeoutMs, 1, Protocols). -start_gun_stream_final(Method, Url, Headers, Body, TimeoutMs, ConnectTimeoutMs, RetriesLeft) -> +start_gun_stream_final(Method, Url, Headers, Body, TimeoutMs, ConnectTimeoutMs, RetriesLeft, Protocols) -> case parse_url(Url) of {ok, Scheme, Host, Port, PathQs} -> - GunOpts = build_gun_opts(ConnectTimeoutMs), + GunOpts = build_gun_opts(ConnectTimeoutMs, Protocols), case get_or_open_connection(Scheme, Host, Port, GunOpts) of {ok, ConnPid, _Protocol} -> MethodAtom = to_method_atom(Method), @@ -171,22 +167,22 @@ start_gun_stream_final(Method, Url, Headers, Body, TimeoutMs, ConnectTimeoutMs, {response, nofin, Status, RespHeaders} -> {ok, ConnPid, StreamRef, Status, RespHeaders}; {error, timeout} -> - {error, <<"timeout">>}; + {error, {timed_out, TimeoutMs}}; {error, Reason} -> case RetriesLeft > 0 andalso is_stale_connection_error(Reason) of true -> gun:close(ConnPid), start_gun_stream_final(Method, Url, Headers, Body, - TimeoutMs, ConnectTimeoutMs, RetriesLeft - 1); + TimeoutMs, ConnectTimeoutMs, RetriesLeft - 1, Protocols); false -> - {error, format_error(Reason)} + {error, classify_error(Reason, TimeoutMs)} end end; {error, Reason} -> - {error, format_connection_error(Reason)} + {error, classify_connect_error(Reason)} end; {error, Reason} -> - {error, format_error(Reason)} + {error, classify_error(Reason, TimeoutMs)} end. drain_stream(ConnPid, StreamRef) -> @@ -239,11 +235,11 @@ stream_owner_wait(ConnPid, StreamRef, Buffer, StartHeaders, StartWaiters, ZlibCt StartHeaders, StartWaiters, undefined, TimeoutMs); {gun_error, ConnPid, StreamRef, Reason} -> cleanup_zlib(ZlibCtx), - stream_owner_wait(ConnPid, StreamRef, Buffer ++ [{error, format_error(Reason)}], + stream_owner_wait(ConnPid, StreamRef, Buffer ++ [{error, classify_error(Reason, TimeoutMs)}], StartHeaders, StartWaiters, undefined, TimeoutMs); {gun_error, ConnPid, Reason} -> cleanup_zlib(ZlibCtx), - stream_owner_wait(ConnPid, StreamRef, Buffer ++ [{error, format_error(Reason)}], + stream_owner_wait(ConnPid, StreamRef, Buffer ++ [{error, classify_error(Reason, TimeoutMs)}], StartHeaders, StartWaiters, undefined, TimeoutMs); _Other -> stream_owner_wait(ConnPid, StreamRef, Buffer, StartHeaders, StartWaiters, ZlibCtx, TimeoutMs) @@ -279,14 +275,14 @@ handle_fetch_next(From, ConnPid, StreamRef, [], StartHeaders, StartWaiters, Zlib ok; {gun_error, ConnPid, StreamRef, Reason} -> cleanup_zlib(ZlibCtx), - From ! {stream_error, format_error(Reason)}, + From ! {stream_error, classify_error(Reason, TimeoutMs)}, ok; {gun_error, ConnPid, Reason} -> cleanup_zlib(ZlibCtx), - From ! {stream_error, format_error(Reason)}, + From ! {stream_error, classify_error(Reason, TimeoutMs)}, ok after TimeoutMs -> - From ! {stream_error, timeout}, + From ! {stream_error, {timed_out, TimeoutMs}}, stream_owner_wait(ConnPid, StreamRef, [], StartHeaders, StartWaiters, ZlibCtx, TimeoutMs) end; handle_fetch_next(From, ConnPid, StreamRef, [Item | Rest], StartHeaders, StartWaiters, ZlibCtx, TimeoutMs) -> @@ -317,11 +313,13 @@ fetch_next(OwnerPid, TimeoutMs) -> {stream_error, Reason} -> erlang:demonitor(MonitorRef, [flush]), {error, Reason}; + {'DOWN', MonitorRef, process, OwnerPid, {stream_start_failed, ErrorInfo}} -> + {error, ErrorInfo}; {'DOWN', MonitorRef, process, OwnerPid, Reason} -> - {error, format_exit_reason(Reason)} + {error, {process_down, ensure_utf8_binary(io_lib:format("~p", [Reason]))}} after TimeoutMs -> erlang:demonitor(MonitorRef, [flush]), - {error, timeout} + {error, {timed_out, TimeoutMs}} end. fetch_start_headers(OwnerPid, TimeoutMs) -> @@ -331,11 +329,13 @@ fetch_start_headers(OwnerPid, TimeoutMs) -> {stream_start_headers, Headers} -> erlang:demonitor(MonitorRef, [flush]), {ok, Headers}; + {'DOWN', MonitorRef, process, OwnerPid, {stream_start_failed, ErrorInfo}} -> + {error, ErrorInfo}; {'DOWN', MonitorRef, process, OwnerPid, Reason} -> - {error, format_exit_reason(Reason)} + {error, {process_down, ensure_utf8_binary(io_lib:format("~p", [Reason]))}} after TimeoutMs -> erlang:demonitor(MonitorRef, [flush]), - {error, timeout} + {error, {timed_out, TimeoutMs}} end. normalize_headers_default(undefined) -> []; @@ -346,19 +346,19 @@ normalize_headers_default(Headers) -> Headers. %% ============================================================================ request_stream_messages(Method, Url, Headers, Body, _ReceiverPid, TimeoutMs, - ConnectTimeoutMs, AutoRedirect) -> + ConnectTimeoutMs, AutoRedirect, Protocols) -> NHeaders = maybe_add_accept_encoding(to_gun_headers(Headers)), CallerPid = self(), TranslatorPid = spawn(fun() -> - translator_init(Method, Url, NHeaders, Body, CallerPid, TimeoutMs, ConnectTimeoutMs, AutoRedirect) + translator_init(Method, Url, NHeaders, Body, CallerPid, TimeoutMs, ConnectTimeoutMs, AutoRedirect, Protocols) end), %% Generate a unique string ID for this stream StringId = translator_ref_to_string(TranslatorPid), store_ref_mapping(StringId, TranslatorPid), {ok, StringId}. -translator_init(Method, Url, Headers, Body, CallerPid, TimeoutMs, ConnectTimeoutMs, AutoRedirect) -> - case start_gun_stream(Method, Url, Headers, Body, TimeoutMs, ConnectTimeoutMs, AutoRedirect, 0) of +translator_init(Method, Url, Headers, Body, CallerPid, TimeoutMs, ConnectTimeoutMs, AutoRedirect, Protocols) -> + case start_gun_stream(Method, Url, Headers, Body, TimeoutMs, ConnectTimeoutMs, AutoRedirect, 0, Protocols) of {ok, ConnPid, StreamRef, _Status, RespHeaders} -> StringId = get_my_string_id(), %% Store ConnPid and StreamRef for cancellation @@ -401,11 +401,11 @@ translator_loop(ConnPid, StreamRef, CallerPid, StringId, ZlibCtx, TimeoutMs) -> ok; {gun_error, ConnPid, StreamRef, Reason} -> cleanup_zlib(ZlibCtx), - CallerPid ! {http, {StringId, {error, format_error(Reason)}}}, + CallerPid ! {http, {StringId, {error, classify_error(Reason, TimeoutMs)}}}, ok; {gun_error, ConnPid, Reason} -> cleanup_zlib(ZlibCtx), - CallerPid ! {http, {StringId, {error, format_error(Reason)}}}, + CallerPid ! {http, {StringId, {error, classify_error(Reason, TimeoutMs)}}}, ok; cancel -> cleanup_zlib(ZlibCtx), @@ -415,7 +415,7 @@ translator_loop(ConnPid, StreamRef, CallerPid, StringId, ZlibCtx, TimeoutMs) -> translator_loop(ConnPid, StreamRef, CallerPid, StringId, ZlibCtx, TimeoutMs) after TimeoutMs -> cleanup_zlib(ZlibCtx), - CallerPid ! {http, {StringId, {error, <<"timeout">>}}}, + CallerPid ! {http, {StringId, {error, {timed_out, TimeoutMs}}}}, ok end. @@ -472,8 +472,8 @@ receive_stream_message(TimeoutMs) -> {chunk, StringId, Data}; {http, {StringId, stream_end, Headers}} -> {stream_end, StringId, Headers}; - {http, {StringId, {error, Reason}}} -> - {stream_error, StringId, ensure_binary(Reason)} + {http, {StringId, {error, ErrorInfo}}} -> + {stream_error, StringId, ErrorInfo} after TimeoutMs -> timeout end. @@ -487,16 +487,13 @@ decode_stream_message_for_selector({http, InnerMessage}) -> {StringId, stream_end, Headers} -> remove_ref_mapping(StringId), {stream_end, StringId, Headers}; - {StringId, {error, Reason}} -> + {StringId, {error, ErrorInfo}} -> remove_ref_mapping(StringId), - {stream_error, StringId, ensure_binary(Reason)}; + {stream_error, StringId, ErrorInfo}; _ -> error(badarg) end. -ensure_binary(Bin) when is_binary(Bin) -> Bin; -ensure_binary(Other) -> ensure_utf8_binary(io_lib:format("~p", [Other])). - %% ============================================================================ %% Header normalization %% ============================================================================ @@ -518,8 +515,16 @@ normalize_header_tuple(_) -> configure_transport(Config) -> %% Config is a Gleam opaque type = Erlang tuple {transport_config, F1, F2, ...} ets:insert(dream_http_client_transport_config, {config, Config}), + LogLevel = map_log_level(element(15, Config)), + logger:set_module_level([dream_http_conn_manager, dream_http_shim], LogLevel), nil. +map_log_level(log_debug) -> debug; +map_log_level(log_info) -> info; +map_log_level(log_warning) -> warning; +map_log_level(log_error) -> error; +map_log_level(log_none) -> none. + get_transport_config() -> case ets:lookup(dream_http_client_transport_config, config) of [{config, Config}] -> @@ -565,8 +570,12 @@ get_or_open_connection(Scheme, Host, Port, GunOpts) -> FullOpts = maps:merge(TransportConfig, GunOpts), dream_http_conn_manager:ensure_connection(Scheme, Host, Port, FullOpts). -build_gun_opts(ConnectTimeoutMs) -> - #{connect_timeout => ConnectTimeoutMs}. +build_gun_opts(ConnectTimeoutMs, Protocols) -> + Opts = #{connect_timeout => ConnectTimeoutMs}, + case Protocols of + default -> Opts; + _ -> Opts#{protocols => Protocols} + end. send_request(ConnPid, Method, PathQs, Headers, Body) when Body =:= <<>>; Body =:= undefined -> gun:Method(ConnPid, PathQs, Headers); @@ -764,8 +773,8 @@ maybe_decompress_response(Body, Headers) -> "identity" -> {Body, Headers}; "" -> {Body, Headers}; Other -> - io:format("WARNING: unrecognized Content-Encoding, passing through raw bytes: ~s~n", - [to_binary(Other)]), + logger:warning("unrecognized Content-Encoding, passing through raw bytes: ~s", + [to_binary(Other)]), {Body, Headers} end. @@ -775,7 +784,7 @@ try_decompress(DecompressFn, OrigBody, Headers) -> {Decompressed, remove_header("content-encoding", Headers)} catch _:_ -> - io:format("WARNING: decompression failed, passing through raw bytes~n"), + logger:warning("decompression failed, passing through raw bytes", []), {OrigBody, Headers} end. @@ -787,8 +796,8 @@ detect_stream_encoding(Headers) -> "" -> none; "identity" -> none; Other -> - io:format("WARNING: unrecognized Content-Encoding for stream, passing through raw bytes: ~s~n", - [to_binary(Other)]), + logger:warning("unrecognized Content-Encoding for stream, passing through raw bytes: ~s", + [to_binary(Other)]), none end. @@ -823,25 +832,42 @@ is_stale_connection_error(closed) -> true; is_stale_connection_error(_) -> false. %% ============================================================================ -%% Error formatting -%% ============================================================================ - -format_error(Reason) -> - ensure_utf8_binary(io_lib:format("~p", [Reason])). - -format_connection_error(econnrefused) -> <<"econnrefused">>; -format_connection_error(connect_timeout) -> <<"connect_timeout">>; -format_connection_error(timeout) -> <<"connect_timeout">>; -format_connection_error(nxdomain) -> <<"nxdomain">>; -format_connection_error(Reason) -> - ensure_utf8_binary(io_lib:format("~p", [Reason])). - -format_exit_reason({stream_start_failed, Error}) -> - ensure_binary(Error); -format_exit_reason(normal) -> - <<"Stream process exited normally">>; -format_exit_reason(Reason) -> - ensure_utf8_binary(io_lib:format("Stream process died: ~p", [Reason])). +%% Error classification (returns tagged tuples for Gleam decoders) +%% ============================================================================ + +%% 3-tuple GOAWAY clause MUST come before generic 3-tuple clause. +%% Gun can return {stream_error, {goaway, ...}, HumanReadable} as a 3-tuple. +%% Without this clause, the generic {stream_error, Code, HumanReadable} would +%% match with Code = {goaway, ...} (a tuple), and atom_to_binary would crash. +classify_error({stream_error, {goaway, LastStreamId, Code, DebugData}, _HumanReadable}, _TimeoutMs) -> + {goaway, atom_to_binary(Code, utf8), LastStreamId, ensure_utf8_binary(DebugData)}; +classify_error({stream_error, {goaway, LastStreamId, Code, DebugData}}, _TimeoutMs) -> + {goaway, atom_to_binary(Code, utf8), LastStreamId, ensure_utf8_binary(DebugData)}; +classify_error({stream_error, Code, HumanReadable}, _TimeoutMs) when is_atom(Code) -> + {stream_reset, atom_to_binary(Code, utf8), ensure_utf8_binary(HumanReadable)}; +classify_error({stream_error, Code}, _TimeoutMs) when is_atom(Code) -> + {stream_reset, atom_to_binary(Code, utf8), atom_to_binary(Code, utf8)}; +classify_error({connection_error, Code, HumanReadable}, _TimeoutMs) -> + {connection_error, atom_to_binary(Code, utf8), ensure_utf8_binary(HumanReadable)}; +classify_error(closed, _TimeoutMs) -> + {remote_closed}; +classify_error({closed, _}, _TimeoutMs) -> + {remote_closed}; +classify_error(timeout, TimeoutMs) -> + {timed_out, TimeoutMs}; +classify_error(Reason, _TimeoutMs) -> + {unexpected, ensure_utf8_binary(io_lib:format("~p", [Reason]))}. + +classify_connect_error(econnrefused) -> + {connect_failed, <<"Connection refused">>}; +classify_connect_error(connect_timeout) -> + {connect_failed, <<"Connection timed out">>}; +classify_connect_error(timeout) -> + {connect_failed, <<"Connection timed out">>}; +classify_connect_error(nxdomain) -> + {connect_failed, <<"DNS resolution failed (NXDOMAIN)">>}; +classify_connect_error(Reason) -> + {connect_failed, ensure_utf8_binary(io_lib:format("~p", [Reason]))}. %% ============================================================================ %% Binary/string conversion @@ -947,3 +973,4 @@ remove_ref_mapping(StringId) -> ok end. + diff --git a/modules/http_client/src/dream_http_client/internal.gleam b/modules/http_client/src/dream_http_client/internal.gleam index 1a6faf3..5b39350 100644 --- a/modules/http_client/src/dream_http_client/internal.gleam +++ b/modules/http_client/src/dream_http_client/internal.gleam @@ -8,7 +8,7 @@ //// This is an internal module. Use `dream_http_client/client`, //// `dream_http_client/recorder`, and `dream_http_client/matching` instead. -import gleam/bit_array +import gleam/dynamic import gleam/dynamic/decode as d import gleam/erlang/atom import gleam/erlang/process @@ -32,6 +32,7 @@ fn request_stream( timeout_ms: Int, connect_timeout_ms: Int, autoredirect: Bool, + protocols: atom.Atom, ) -> d.Dynamic @external(erlang, "dream_http_shim", "fetch_next") @@ -89,6 +90,7 @@ pub fn start_gun_stream( timeout_ms: Int, connect_timeout_ms: Int, autoredirect: Bool, + protocols_atom: atom.Atom, ) -> d.Dynamic { let port_string = case request.port { option.Some(port) -> ":" <> int.to_string(port) @@ -117,6 +119,7 @@ pub fn start_gun_stream( timeout_ms, connect_timeout_ms, autoredirect, + protocols_atom, ) } @@ -156,8 +159,9 @@ pub fn extract_owner_pid(request_result: d.Dynamic) -> d.Dynamic { /// Receive the next chunk from the stream /// /// Receives the next chunk of data from an active streaming HTTP request. -/// Returns `Ok(BitArray)` when a chunk is available, or `Error(String)` when -/// the stream has finished or an error occurred. +/// Returns `Ok(BitArray)` when a chunk is available, or `Error(d.Dynamic)` when +/// the stream has finished or an error occurred. The error dynamic value is a +/// structured tagged tuple from the Erlang shim's `classify_error`. /// /// ## Parameters /// @@ -168,11 +172,11 @@ pub fn extract_owner_pid(request_result: d.Dynamic) -> d.Dynamic { /// /// - `Ok(Some(BitArray))`: The next chunk of response data /// - `Ok(None)`: Stream finished normally (no more data) -/// - `Error(String)`: Error occurred with reason +/// - `Error(d.Dynamic)`: Structured error from the Erlang shim pub fn receive_next( owner: d.Dynamic, timeout_ms: Int, -) -> Result(option.Option(BitArray), String) { +) -> Result(option.Option(BitArray), d.Dynamic) { let resp = fetch_next(owner, timeout_ms) let tag = d.run(resp, d.at([0], d.dynamic)) @@ -186,21 +190,17 @@ pub fn receive_next( } "finished" -> Ok(option.None) "error" -> { - let reason = case d.run(resp, d.at([1], d.string)) { - Ok(s) -> s - Error(_) -> - case d.run(resp, d.at([1], d.bit_array)) { - Ok(bytes) -> - case bit_array.to_string(bytes) { - Ok(s) -> s - Error(_) -> string.inspect(resp) - } - Error(_) -> string.inspect(resp) - } - } - Error(reason) + let reason_dyn = + d.run(resp, d.at([1], d.dynamic)) |> result.unwrap(dynamic.nil()) + Error(reason_dyn) } - _ -> Error("Unexpected stream message tag: " <> tag) + _ -> + Error( + to_dynamic(#( + atom.create("unexpected"), + "Unexpected stream message tag: " <> tag, + )), + ) } } @@ -238,10 +238,9 @@ pub fn get_stream_start_headers( } "error" -> { - let reason = - d.run(resp, d.at([1], d.string)) - |> result.unwrap("Unknown stream_start header error") - Error(reason) + let reason_dyn = + d.run(resp, d.at([1], d.dynamic)) |> result.unwrap(dynamic.nil()) + Error(string.inspect(reason_dyn)) } _ -> Error("Unexpected fetch_start_headers response: " <> tag) @@ -252,6 +251,9 @@ fn convert_to_atom(dyn: d.Dynamic) -> Result(atom.Atom, e) { Ok(atom.cast_from_dynamic(dyn)) } +@external(erlang, "gleam_stdlib", "identity") +fn to_dynamic(value: a) -> d.Dynamic + // ============================================================================ // Message-Based Streaming FFI // ============================================================================ @@ -295,6 +297,7 @@ pub fn start_stream_messages( timeout_ms: Int, connect_timeout_ms: Int, autoredirect: Bool, + protocols: atom.Atom, ) -> d.Dynamic /// Cancel a streaming request diff --git a/modules/http_client/test/client_test.gleam b/modules/http_client/test/client_test.gleam index ca5be3c..aa8552b 100644 --- a/modules/http_client/test/client_test.gleam +++ b/modules/http_client/test/client_test.gleam @@ -1,4 +1,4 @@ -import dream_http_client/client.{Header} +import dream_http_client/client.{Header, Http1Only, Http2Only, Http2Preferred} import gleam/http import gleam/list import gleam/option @@ -188,3 +188,44 @@ pub fn add_header_adds_header_to_request_test() { [] -> should.fail() } } + +pub fn protocols_sets_request_protocols_test() { + // Arrange + let request = client.new() + + // Act + let updated = client.protocols(request, Http2Only) + + // Assert + client.get_protocols(updated) |> should.equal(option.Some(Http2Only)) +} + +pub fn protocols_defaults_to_none_test() { + // Arrange & Act + let request = client.new() + + // Assert + client.get_protocols(request) |> should.equal(option.None) +} + +pub fn protocols_http1_only_round_trip_test() { + // Arrange + let request = client.new() + + // Act + let updated = client.protocols(request, Http1Only) + + // Assert + client.get_protocols(updated) |> should.equal(option.Some(Http1Only)) +} + +pub fn protocols_http2_preferred_round_trip_test() { + // Arrange + let request = client.new() + + // Act + let updated = client.protocols(request, Http2Preferred) + + // Assert + client.get_protocols(updated) |> should.equal(option.Some(Http2Preferred)) +} diff --git a/modules/http_client/test/compression_test.gleam b/modules/http_client/test/compression_test.gleam index 8e71e2d..631ff3b 100644 --- a/modules/http_client/test/compression_test.gleam +++ b/modules/http_client/test/compression_test.gleam @@ -167,7 +167,9 @@ pub fn start_stream_passes_through_unknown_encoding_chunks_test() { mock_request("/stream/unknown-encoding") |> client.on_stream_chunk(fn(data) { process.send(chunks_subject, data) }) |> client.on_stream_end(fn(_headers) { process.send(ended_subject, True) }) - |> client.on_stream_error(fn(reason) { process.send(error_subject, reason) }) + |> client.on_stream_error(fn(failure) { + process.send(error_subject, failure) + }) let assert Ok(_handle) = client.start_stream(request) @@ -179,7 +181,7 @@ pub fn start_stream_passes_through_unknown_encoding_chunks_test() { Ok(False) -> should.fail() Error(Nil) -> { case process.receive(error_subject, 1000) { - Ok(_reason) -> Nil + Ok(_failure) -> Nil Error(Nil) -> { io.println("start_stream unknown-encoding: neither end nor error") should.fail() @@ -290,8 +292,10 @@ pub fn stream_yielder_works_without_encoding_test() { list.all(results, fn(r) { case r { Ok(_) -> True - Error(reason) -> { - io.println("Unexpected error: " <> reason) + Error(failure) -> { + io.println( + "Unexpected error: " <> client.stream_failure_to_string(failure), + ) False } } @@ -320,9 +324,12 @@ pub fn start_stream_auto_injects_accept_encoding_test() { mock_request("/echo-accept-encoding") |> client.on_stream_chunk(fn(data) { process.send(chunks_subject, data) }) |> client.on_stream_end(fn(_headers) { process.send(ended_subject, True) }) - |> client.on_stream_error(fn(reason) { + |> client.on_stream_error(fn(failure) { process.send(ended_subject, False) - io.println("Error in header injection test: " <> reason) + io.println( + "Error in header injection test: " + <> client.stream_failure_to_string(failure), + ) }) let assert Ok(_handle) = client.start_stream(request) @@ -376,9 +383,11 @@ pub fn start_stream_preserves_custom_accept_encoding_test() { |> client.headers([Header("Accept-Encoding", "zstd")]) |> client.on_stream_chunk(fn(data) { process.send(chunks_subject, data) }) |> client.on_stream_end(fn(_headers) { process.send(ended_subject, True) }) - |> client.on_stream_error(fn(reason) { + |> client.on_stream_error(fn(failure) { process.send(ended_subject, False) - io.println("Error in preserve test: " <> reason) + io.println( + "Error in preserve test: " <> client.stream_failure_to_string(failure), + ) }) let assert Ok(_handle) = client.start_stream(request) @@ -468,12 +477,14 @@ pub fn start_stream_gzip_cleans_up_zlib_on_error_test() { mock_request("/status/500") |> client.on_stream_chunk(fn(_data) { Nil }) |> client.on_stream_end(fn(_headers) { Nil }) - |> client.on_stream_error(fn(reason) { process.send(error_subject, reason) }) + |> client.on_stream_error(fn(failure) { + process.send(error_subject, failure) + }) let assert Ok(handle) = client.start_stream(request) case process.receive(error_subject, 5000) { - Ok(_reason) -> Nil + Ok(_failure) -> Nil Error(Nil) -> { io.println("Zlib error cleanup test: error never called") should.fail() diff --git a/modules/http_client/test/error_handling_test.gleam b/modules/http_client/test/error_handling_test.gleam index a30460c..26a75e4 100644 --- a/modules/http_client/test/error_handling_test.gleam +++ b/modules/http_client/test/error_handling_test.gleam @@ -47,10 +47,10 @@ pub fn send_404_status_test() { status |> should.equal(404) string.contains(body, "404") |> should.be_true() } - Error(client.RequestError(message: error_reason)) -> { + Error(client.RequestError(error: transport_error)) -> { io.println( "send_404_status_test encountered connection-level error: " - <> error_reason, + <> client.transport_error_to_string(transport_error), ) } Ok(_) -> { @@ -78,10 +78,10 @@ pub fn send_500_status_test() { status |> should.equal(500) string.contains(body, "500") |> should.be_true() } - Error(client.RequestError(message: error_reason)) -> { + Error(client.RequestError(error: transport_error)) -> { io.println( "send_500_status_test encountered connection-level error: " - <> error_reason, + <> client.transport_error_to_string(transport_error), ) } Ok(_) -> { @@ -109,10 +109,10 @@ pub fn send_400_status_test() { status |> should.equal(400) string.contains(body, "400") |> should.be_true() } - Error(client.RequestError(message: error_reason)) -> { + Error(client.RequestError(error: transport_error)) -> { io.println( "send_400_status_test encountered connection-level error: " - <> error_reason, + <> client.transport_error_to_string(transport_error), ) } Ok(_) -> { @@ -138,7 +138,8 @@ pub fn send_connection_failure_test() { // Assert - Should get a RequestError (transport failure) case result { - Error(client.RequestError(message: error_msg)) -> { + Error(client.RequestError(error: transport_error)) -> { + let error_msg = client.transport_error_to_string(transport_error) string.length(error_msg) |> should.not_equal(0) } Error(client.ResponseError(_)) -> { @@ -174,10 +175,10 @@ pub fn send_respects_explicit_request_content_type_test() { ) False |> should.be_true() } - Error(client.RequestError(message: error_reason)) -> { + Error(client.RequestError(error: transport_error)) -> { io.println( "send_respects_explicit_request_content_type_test failed: " - <> error_reason, + <> client.transport_error_to_string(transport_error), ) False |> should.be_true() } @@ -281,8 +282,11 @@ pub fn send_error_response_includes_headers_test() { }) has_content_type |> should.be_true() } - Error(client.RequestError(message: msg)) -> { - io.println("Connection error (mock server down?): " <> msg) + Error(client.RequestError(error: transport_error)) -> { + io.println( + "Connection error (mock server down?): " + <> client.transport_error_to_string(transport_error), + ) } Ok(_) -> { io.println("Expected ResponseError for 404, got Ok") @@ -357,6 +361,71 @@ pub fn send_status_503_returns_error_test() { // Error Message Quality Tests // ============================================================================ +/// Test: connection refused returns ConnectFailed with a non-empty reason +pub fn send_connection_refused_returns_connect_failed_test() { + let req = + client.new() + |> client.method(http.Get) + |> client.scheme(http.Http) + |> client.host("localhost") + |> client.port(19_999) + |> client.path("/nonexistent") + + let result = client.send(req) + + case result { + Error(client.RequestError(error: client.ConnectFailed(reason: reason))) -> { + { reason != "" } |> should.be_true() + } + Error(client.RequestError(error: other)) -> { + io.println( + "Expected ConnectFailed, got: " + <> client.transport_error_to_string(other), + ) + should.fail() + } + Error(client.ResponseError(_)) -> { + io.println("Expected RequestError, got ResponseError") + should.fail() + } + Ok(_) -> should.fail() + } +} + +/// Test: transport_error_to_string produces a readable message for ConnectFailed +pub fn transport_error_to_string_returns_readable_message_test() { + let msg = + client.transport_error_to_string(client.ConnectFailed( + reason: "Connection refused", + )) + { msg != "" } |> should.be_true() + string.contains(msg, "Connection refused") |> should.be_true() +} + +/// Test: stream_failure_to_string produces a readable message for HttpFailure +pub fn stream_failure_to_string_returns_readable_message_for_http_failure_test() { + let msg = + client.stream_failure_to_string( + client.HttpFailure(response: client.HttpResponse( + status: 404, + headers: [], + body: "not found", + )), + ) + { msg != "" } |> should.be_true() + string.contains(msg, "404") |> should.be_true() +} + +/// Test: stream_failure_to_string produces a readable message for TransportFailure +pub fn stream_failure_to_string_returns_readable_message_for_transport_failure_test() { + let msg = + client.stream_failure_to_string( + client.TransportFailure(error: client.TimedOut(timeout_ms: 5000)), + ) + { msg != "" } |> should.be_true() + string.contains(msg, "5000") |> should.be_true() +} + /// Test: Errors contain useful information pub fn error_messages_are_informative_test() { // Arrange - Connect to non-existent server @@ -373,7 +442,8 @@ pub fn error_messages_are_informative_test() { // Assert - Error message should have substance case result { - Error(client.RequestError(message: error_msg)) -> { + Error(client.RequestError(error: transport_error)) -> { + let error_msg = client.transport_error_to_string(transport_error) // Should be more than just "error" or empty string.length(error_msg) |> should.not_equal(0) // Should not be just "Nil" or similar diff --git a/modules/http_client/test/ets_table_ownership_test.gleam b/modules/http_client/test/ets_table_ownership_test.gleam index 4e282e3..5c400b6 100644 --- a/modules/http_client/test/ets_table_ownership_test.gleam +++ b/modules/http_client/test/ets_table_ownership_test.gleam @@ -86,8 +86,11 @@ pub fn stream_from_expired_caller_completes_test() { |> client.on_stream_end(fn(_headers) { process.send(end_subject, "completed") }) - |> client.on_stream_error(fn(reason) { - process.send(end_subject, "error:" <> reason) + |> client.on_stream_error(fn(failure) { + process.send( + end_subject, + "error:" <> client.stream_failure_to_string(failure), + ) }) let assert Ok(_handle) = client.start_stream(request) }) @@ -116,7 +119,7 @@ pub fn concurrent_streams_from_expired_callers_both_complete_test() { let request = mock_request("/stream/fast") |> client.on_stream_end(fn(_headers) { process.send(end_subject, 1) }) - |> client.on_stream_error(fn(_reason) { process.send(end_subject, -1) }) + |> client.on_stream_error(fn(_failure) { process.send(end_subject, -1) }) let assert Ok(_handle) = client.start_stream(request) }) @@ -127,7 +130,7 @@ pub fn concurrent_streams_from_expired_callers_both_complete_test() { let request = mock_request("/stream/slow") |> client.on_stream_end(fn(_headers) { process.send(end_subject, 2) }) - |> client.on_stream_error(fn(_reason) { process.send(end_subject, -2) }) + |> client.on_stream_error(fn(_failure) { process.send(end_subject, -2) }) let assert Ok(_handle) = client.start_stream(request) }) @@ -146,7 +149,7 @@ pub fn three_concurrent_streams_all_complete_test() { let request = mock_request("/stream/fast") |> client.on_stream_end(fn(_headers) { process.send(end_subject, i) }) - |> client.on_stream_error(fn(_reason) { process.send(end_subject, -i) }) + |> client.on_stream_error(fn(_failure) { process.send(end_subject, -i) }) let assert Ok(_handle) = client.start_stream(request) Nil }) @@ -168,7 +171,7 @@ pub fn sequential_streams_after_process_exit_test() { let request2 = mock_request("/stream/fast") |> client.on_stream_end(fn(_headers) { process.send(end_subject, True) }) - |> client.on_stream_error(fn(_reason) { process.send(end_subject, False) }) + |> client.on_stream_error(fn(_failure) { process.send(end_subject, False) }) let assert Ok(_handle2) = client.start_stream(request2) case process.receive(end_subject, 5000) { @@ -191,7 +194,7 @@ pub fn five_concurrent_streams_from_expired_callers_test() { let request = mock_request("/stream/fast") |> client.on_stream_end(fn(_headers) { process.send(end_subject, i) }) - |> client.on_stream_error(fn(_reason) { + |> client.on_stream_error(fn(_failure) { process.send(end_subject, -i) }) let assert Ok(_handle) = client.start_stream(request) diff --git a/modules/http_client/test/h2c_test.gleam b/modules/http_client/test/h2c_test.gleam new file mode 100644 index 0000000..f9aec2b --- /dev/null +++ b/modules/http_client/test/h2c_test.gleam @@ -0,0 +1,390 @@ +//// h2c integration tests — HTTP/2 over cleartext (prior knowledge mode) +//// +//// Verifies that dream_http_client with Http2Only over TCP successfully +//// negotiates HTTP/2 with a Mist-based server and correctly routes requests +//// to the right endpoints. Tests all three execution modes (send, +//// stream_yielder, start_stream) with path-specific content assertions. + +import dream_http_client/client.{Header, Http1Only, Http2Only, Http2Preferred} +import dream_http_client_test +import gleam/bit_array +import gleam/bytes_tree +import gleam/erlang/process +import gleam/http +import gleam/list +import gleam/option +import gleam/string +import gleam/yielder +import gleeunit/should + +fn h2c_request(path: String) -> client.ClientRequest { + client.new() + |> client.method(http.Get) + |> client.scheme(http.Http) + |> client.host("localhost") + |> client.port(dream_http_client_test.get_test_port()) + |> client.path(path) + |> client.protocols(Http2Only) +} + +fn http1_request(path: String) -> client.ClientRequest { + client.new() + |> client.method(http.Get) + |> client.scheme(http.Http) + |> client.host("localhost") + |> client.port(dream_http_client_test.get_test_port()) + |> client.path(path) +} + +// ============================================================================ +// A. send() over h2c — path-specific routing +// ============================================================================ + +/// h2c routes to /text and returns the correct body +pub fn h2c_send_text_returns_correct_body_test() { + let req = h2c_request("/text") + let assert Ok(resp) = client.send(req) + resp.status |> should.equal(200) + resp.body |> should.equal("Hello, World!") +} + +/// h2c routes to /json and returns JSON with correct content +pub fn h2c_send_json_returns_correct_body_test() { + let req = h2c_request("/json") + let assert Ok(resp) = client.send(req) + resp.status |> should.equal(200) + string.contains(resp.body, "Hello, World!") |> should.be_true() +} + +/// h2c routes to /status/500 and surfaces the error +pub fn h2c_send_error_status_test() { + let req = h2c_request("/status/500") + case client.send(req) { + Error(client.ResponseError(response: client.HttpResponse( + status: status, + body: _body, + .., + ))) -> { + status |> should.equal(500) + } + Ok(resp) -> { + let _ = resp + should.fail() + } + _other -> { + should.fail() + } + } +} + +/// h2c POST with body routes correctly +pub fn h2c_send_post_with_body_test() { + let req = + h2c_request("/post") + |> client.method(http.Post) + |> client.body("{\"key\":\"value\"}") + |> client.headers([Header("Content-Type", "application/json")]) + + case client.send(req) { + Ok(resp) -> { + resp.status |> should.equal(201) + string.contains(resp.body, "key") |> should.be_true() + } + Error(err) -> { + let _ = err + should.fail() + } + } +} + +/// h2c decompresses gzip responses correctly +pub fn h2c_send_decompresses_gzip_test() { + let req = h2c_request("/gzip") + let assert Ok(resp) = client.send(req) + resp.status |> should.equal(200) + resp.body |> should.equal("Hello, World!") +} + +/// h2c send() returns response headers +pub fn h2c_send_returns_headers_test() { + let req = h2c_request("/text") + let assert Ok(resp) = client.send(req) + let content_type = + list.find(resp.headers, fn(h) { + let Header(name, _) = h + string.lowercase(name) == "content-type" + }) + let assert Ok(Header(_, ct_value)) = content_type + string.contains(ct_value, "text/plain") |> should.be_true() +} + +// ============================================================================ +// B. stream_yielder() over h2c — path-specific routing +// ============================================================================ + +/// h2c stream_yielder to /stream/fast receives all 10 chunks +pub fn h2c_stream_yielder_receives_all_chunks_test() { + let req = h2c_request("/stream/fast") + let results = client.stream_yielder(req) |> yielder.to_list + + let ok_chunks = + list.filter_map(results, fn(r) { + case r { + Ok(bt) -> Ok(bytes_tree.to_bit_array(bt)) + Error(_) -> Error(Nil) + } + }) + + { ok_chunks != [] } |> should.be_true() + + let combined = combine_chunks(ok_chunks) + string.contains(combined, "Chunk 1") |> should.be_true() + string.contains(combined, "Chunk 10") |> should.be_true() +} + +/// h2c stream_yielder decompresses gzip chunks +pub fn h2c_stream_yielder_decompresses_gzip_test() { + let req = h2c_request("/stream/gzip") + let results = client.stream_yielder(req) |> yielder.to_list + + let ok_chunks = + list.filter_map(results, fn(r) { + case r { + Ok(bt) -> Ok(bytes_tree.to_bit_array(bt)) + Error(_) -> Error(Nil) + } + }) + + { ok_chunks != [] } |> should.be_true() + + let combined = combine_chunks(ok_chunks) + string.contains(combined, "Chunk 1") |> should.be_true() + string.contains(combined, "Chunk 5") |> should.be_true() +} + +// ============================================================================ +// C. start_stream() over h2c — path-specific routing +// ============================================================================ + +/// h2c start_stream to /stream/fast delivers correct chunks +pub fn h2c_start_stream_delivers_correct_chunks_test() { + let chunks_subject = process.new_subject() + let ended_subject = process.new_subject() + + let request = + h2c_request("/stream/fast") + |> client.on_stream_chunk(fn(data) { process.send(chunks_subject, data) }) + |> client.on_stream_end(fn(_headers) { process.send(ended_subject, True) }) + |> client.on_stream_error(fn(_failure) { + process.send(ended_subject, False) + }) + + let assert Ok(_handle) = client.start_stream(request) + + case process.receive(ended_subject, 10_000) { + Ok(ended_ok) -> ended_ok |> should.be_true() + Error(Nil) -> should.fail() + } + + let chunks = collect_chunks(chunks_subject, []) + { chunks != [] } |> should.be_true() + let combined = combine_chunks(chunks) + string.contains(combined, "Chunk 1") |> should.be_true() +} + +/// h2c start_stream fires on_stream_start with headers +pub fn h2c_start_stream_fires_on_stream_start_test() { + let headers_subject = process.new_subject() + let ended_subject = process.new_subject() + + let request = + h2c_request("/stream/fast") + |> client.on_stream_start(fn(headers) { + process.send(headers_subject, headers) + }) + |> client.on_stream_chunk(fn(_data) { Nil }) + |> client.on_stream_end(fn(_headers) { process.send(ended_subject, True) }) + |> client.on_stream_error(fn(_failure) { + process.send(ended_subject, False) + }) + + let assert Ok(_handle) = client.start_stream(request) + + case process.receive(headers_subject, 10_000) { + Ok(headers) -> { + { headers != [] } |> should.be_true() + } + Error(Nil) -> should.fail() + } + + case process.receive(ended_subject, 10_000) { + Ok(_) -> Nil + Error(Nil) -> should.fail() + } +} + +// ============================================================================ +// D. Connection pool isolation — h2c and http1 use separate connections +// ============================================================================ + +/// HTTP/1.1 requests still work after h2c requests +pub fn http1_still_works_after_h2c_test() { + let _h2c = client.send(h2c_request("/text")) + + let assert Ok(resp) = client.send(http1_request("/text")) + resp.status |> should.equal(200) + resp.body |> should.equal("Hello, World!") +} + +/// h2c returns correct content (not the index page) +pub fn h2c_returns_correct_content_not_index_page_test() { + let assert Ok(h2c_resp) = client.send(h2c_request("/text")) + h2c_resp.status |> should.equal(200) + h2c_resp.body |> should.equal("Hello, World!") + + // Must NOT be the index page (which is what broken h2c returns) + string.contains(h2c_resp.body, "Mock Server") |> should.be_false() +} + +// ============================================================================ +// C. Protocol builder API — verify protocols field round-trips +// ============================================================================ + +pub fn protocols_builder_sets_http2_only_test() { + let req = + client.new() + |> client.protocols(Http2Only) + client.get_protocols(req) + |> should.equal(option.Some(Http2Only)) +} + +pub fn protocols_builder_sets_http1_only_test() { + let req = + client.new() + |> client.protocols(Http1Only) + client.get_protocols(req) + |> should.equal(option.Some(Http1Only)) +} + +pub fn protocols_builder_sets_http2_preferred_test() { + let req = + client.new() + |> client.protocols(Http2Preferred) + client.get_protocols(req) + |> should.equal(option.Some(Http2Preferred)) +} + +pub fn protocols_defaults_to_none_test() { + let req = client.new() + client.get_protocols(req) + |> should.equal(option.None) +} + +// ============================================================================ +// D. Multiple concurrent h2c requests +// ============================================================================ + +pub fn concurrent_h2c_requests_all_succeed_test() { + let subject1 = process.new_subject() + let subject2 = process.new_subject() + let subject3 = process.new_subject() + + let _pid1 = + process.spawn_unlinked(fn() { + let result = client.send(h2c_request("/text")) + process.send(subject1, result) + }) + + let _pid2 = + process.spawn_unlinked(fn() { + let result = client.send(h2c_request("/text")) + process.send(subject2, result) + }) + + let _pid3 = + process.spawn_unlinked(fn() { + let result = client.send(h2c_request("/text")) + process.send(subject3, result) + }) + + let assert Ok(Ok(resp1)) = process.receive(subject1, 10_000) + let assert Ok(Ok(resp2)) = process.receive(subject2, 10_000) + let assert Ok(Ok(resp3)) = process.receive(subject3, 10_000) + + resp1.status |> should.equal(200) + resp2.status |> should.equal(200) + resp3.status |> should.equal(200) + + { string.length(resp1.body) > 0 } |> should.be_true() + { string.length(resp2.body) > 0 } |> should.be_true() + { string.length(resp3.body) > 0 } |> should.be_true() +} + +// ============================================================================ +// E. Http1Only explicitly forces HTTP/1.1 +// ============================================================================ + +pub fn http1_only_returns_correct_content_test() { + let req = + client.new() + |> client.method(http.Get) + |> client.scheme(http.Http) + |> client.host("localhost") + |> client.port(dream_http_client_test.get_test_port()) + |> client.path("/text") + |> client.protocols(Http1Only) + + let assert Ok(resp) = client.send(req) + resp.status |> should.equal(200) + resp.body |> should.equal("Hello, World!") +} + +pub fn http1_only_stream_yielder_returns_correct_content_test() { + let req = + client.new() + |> client.method(http.Get) + |> client.scheme(http.Http) + |> client.host("localhost") + |> client.port(dream_http_client_test.get_test_port()) + |> client.path("/stream/fast") + |> client.protocols(Http1Only) + + let results = client.stream_yielder(req) |> yielder.to_list + + let ok_chunks = + list.filter_map(results, fn(r) { + case r { + Ok(bt) -> Ok(bytes_tree.to_bit_array(bt)) + Error(_) -> Error(Nil) + } + }) + + { ok_chunks != [] } |> should.be_true() + + let combined = combine_chunks(ok_chunks) + string.contains(combined, "Chunk 1") |> should.be_true() + string.contains(combined, "Chunk 10") |> should.be_true() +} + +// ============================================================================ +// Helpers +// ============================================================================ + +fn collect_chunks( + subject: process.Subject(BitArray), + acc: List(BitArray), +) -> List(BitArray) { + case process.receive(subject, 100) { + Ok(item) -> collect_chunks(subject, [item, ..acc]) + Error(Nil) -> list.reverse(acc) + } +} + +fn combine_chunks(chunks: List(BitArray)) -> String { + let combined = + list.fold(chunks, <<>>, fn(acc, chunk) { bit_array.append(acc, chunk) }) + case bit_array.to_string(combined) { + Ok(s) -> s + Error(Nil) -> "" + } +} diff --git a/modules/http_client/test/recorder_client_test.gleam b/modules/http_client/test/recorder_client_test.gleam index 3cb3a15..beaf8a9 100644 --- a/modules/http_client/test/recorder_client_test.gleam +++ b/modules/http_client/test/recorder_client_test.gleam @@ -274,8 +274,12 @@ pub fn send_with_recorder_finding_streaming_response_returns_error_test() { // Assert - should be RequestError, not ResponseError case result { - Error(client.RequestError(message: msg)) -> - string.contains(msg, "streaming response") |> should.be_true() + Error(client.RequestError(error: transport_error)) -> + string.contains( + client.transport_error_to_string(transport_error), + "streaming response", + ) + |> should.be_true() Error(client.ResponseError(_)) -> { io.println("Expected RequestError, got ResponseError") should.fail() @@ -540,8 +544,12 @@ pub fn send_with_recorder_in_playback_mode_with_ambiguous_key_returns_error_test // Assert result |> should.be_error() case result { - Error(client.RequestError(message: reason)) -> - string.contains(reason, "Ambiguous recording match") |> should.be_true() + Error(client.RequestError(error: transport_error)) -> + string.contains( + client.transport_error_to_string(transport_error), + "Ambiguous recording match", + ) + |> should.be_true() Error(client.ResponseError(_)) -> should.fail() Ok(_) -> should.fail() } @@ -616,8 +624,11 @@ pub fn playback_of_error_recording_preserves_status_and_headers_test() { }) has_custom |> should.be_true() } - Error(client.RequestError(message: msg)) -> { - io.println("Expected ResponseError, got RequestError: " <> msg) + Error(client.RequestError(error: transport_error)) -> { + io.println( + "Expected ResponseError, got RequestError: " + <> client.transport_error_to_string(transport_error), + ) should.fail() } Ok(_) -> { @@ -1230,7 +1241,7 @@ fn extract_query_from_get_response(body: String) -> String { } fn stream_chunks_to_string( - chunks: List(Result(bytes_tree.BytesTree, String)), + chunks: List(Result(bytes_tree.BytesTree, client.StreamFailure)), ) -> String { chunks |> list.filter_map(fn(chunk) { chunk }) diff --git a/modules/http_client/test/redirect_test.gleam b/modules/http_client/test/redirect_test.gleam index 96ce1b9..c885ccd 100644 --- a/modules/http_client/test/redirect_test.gleam +++ b/modules/http_client/test/redirect_test.gleam @@ -125,7 +125,7 @@ pub fn start_stream_follows_301_redirect_test() { mock_request("/redirect/301") |> client.on_stream_chunk(fn(data) { process.send(chunks_subject, data) }) |> client.on_stream_end(fn(_headers) { process.send(ended_subject, True) }) - |> client.on_stream_error(fn(reason) { process.send(error_subject, reason) }) + |> client.on_stream_error(fn(_failure) { process.send(error_subject, Nil) }) let assert Ok(_handle) = client.start_stream(request) @@ -139,7 +139,7 @@ pub fn start_stream_follows_301_redirect_test() { Ok(False) -> should.fail() Error(Nil) -> { case process.receive(error_subject, 1000) { - Ok(_reason) -> should.fail() + Ok(_) -> should.fail() Error(Nil) -> should.fail() } } @@ -161,7 +161,7 @@ fn collect_chunks( } fn combine_stream_chunks( - results: List(Result(bytes_tree.BytesTree, String)), + results: List(Result(bytes_tree.BytesTree, client.StreamFailure)), ) -> String { let chunks = list.filter_map(results, fn(r) { diff --git a/modules/http_client/test/snippets/protocols_config.gleam b/modules/http_client/test/snippets/protocols_config.gleam new file mode 100644 index 0000000..31c9e50 --- /dev/null +++ b/modules/http_client/test/snippets/protocols_config.gleam @@ -0,0 +1,14 @@ +//// Demonstrates using HTTP/2 over cleartext (h2c) with protocol preference. + +import dream_http_client/client.{type HttpResponse, type SendError, Http2Only} +import gleam/http + +pub fn h2c_request() -> Result(HttpResponse, SendError) { + client.new() + |> client.scheme(http.Http) + |> client.host("internal-service.local") + |> client.port(8080) + |> client.path("/api/data") + |> client.protocols(Http2Only) + |> client.send() +} diff --git a/modules/http_client/test/snippets/recording_playback.gleam b/modules/http_client/test/snippets/recording_playback.gleam index 4c303b4..0a52d9d 100644 --- a/modules/http_client/test/snippets/recording_playback.gleam +++ b/modules/http_client/test/snippets/recording_playback.gleam @@ -45,7 +45,9 @@ pub fn test_with_playback() -> Result(HttpResponse, SendError) { |> directory(recordings_directory_path) |> mode("record") |> start() - |> result.map_error(fn(e) { client.RequestError(message: e) }), + |> result.map_error(fn(e) { + client.RequestError(error: client.Unexpected(raw: e)) + }), ) recorder.add_recording(rec, test_recording) @@ -57,7 +59,9 @@ pub fn test_with_playback() -> Result(HttpResponse, SendError) { |> directory(recordings_directory_path) |> mode("playback") |> start() - |> result.map_error(fn(e) { client.RequestError(message: e) }), + |> result.map_error(fn(e) { + client.RequestError(error: client.Unexpected(raw: e)) + }), ) // Make request - returns recorded response without network call diff --git a/modules/http_client/test/snippets/stream_messages_basic.gleam b/modules/http_client/test/snippets/stream_messages_basic.gleam index 5b05bcf..12d8431 100644 --- a/modules/http_client/test/snippets/stream_messages_basic.gleam +++ b/modules/http_client/test/snippets/stream_messages_basic.gleam @@ -5,7 +5,7 @@ import dream_http_client/client.{ await_stream, host, on_stream_chunk, on_stream_end, on_stream_error, - on_stream_start, path, port, scheme, start_stream, + on_stream_start, path, port, scheme, start_stream, stream_failure_to_string, } import gleam/bit_array import gleam/http @@ -27,8 +27,8 @@ pub fn stream_and_print() -> Result(Nil, String) { } }) |> on_stream_end(fn(_headers) { io.println("\nStream completed") }) - |> on_stream_error(fn(reason) { - io.println_error("Stream error: " <> reason) + |> on_stream_error(fn(failure) { + io.println_error("Stream error: " <> stream_failure_to_string(failure)) }) |> start_stream() diff --git a/modules/http_client/test/start_stream_test.gleam b/modules/http_client/test/start_stream_test.gleam index 7fca4af..0bd198e 100644 --- a/modules/http_client/test/start_stream_test.gleam +++ b/modules/http_client/test/start_stream_test.gleam @@ -97,7 +97,9 @@ pub fn start_stream_calls_on_error_for_network_failure_test() { |> client.host("localhost") |> client.port(19_999) |> client.path("/") - |> client.on_stream_error(fn(reason) { process.send(error_subject, reason) }) + |> client.on_stream_error(fn(failure) { + process.send(error_subject, client.stream_failure_to_string(failure)) + }) // Act let assert Ok(_handle) = client.start_stream(request) diff --git a/modules/http_client/test/stream_error_decode_test.gleam b/modules/http_client/test/stream_error_decode_test.gleam index 52fa562..126a8b7 100644 --- a/modules/http_client/test/stream_error_decode_test.gleam +++ b/modules/http_client/test/stream_error_decode_test.gleam @@ -48,7 +48,9 @@ pub fn start_stream_connection_refused_surfaces_error_test() { let request = dead_port_request() - |> client.on_stream_error(fn(reason) { process.send(error_subject, reason) }) + |> client.on_stream_error(fn(failure) { + process.send(error_subject, client.stream_failure_to_string(failure)) + }) let _result = client.start_stream(request) @@ -77,7 +79,8 @@ pub fn stream_yielder_connection_refused_surfaces_error_test() { let assert [first, ..] = results case first { - Error(reason) -> { + Error(failure) -> { + let reason = client.stream_failure_to_string(failure) { string.length(reason) > 0 } |> should.be_true() io.println( "stream_yielder connection refused error: " @@ -95,7 +98,8 @@ pub fn stream_yielder_connection_refused_surfaces_error_test() { pub fn send_connection_refused_surfaces_error_test() { let req = dead_port_request() case client.send(req) { - Error(client.RequestError(message: reason)) -> { + Error(client.RequestError(error: transport_error)) -> { + let reason = client.transport_error_to_string(transport_error) { string.length(reason) > 0 } |> should.be_true() io.println( "send() connection refused error: " <> string.slice(reason, 0, 80), @@ -124,7 +128,9 @@ pub fn start_stream_connection_drop_surfaces_error_test() { let request = mock_request("/stream/drop") |> client.on_stream_chunk(fn(data) { process.send(chunk_subject, data) }) - |> client.on_stream_error(fn(reason) { process.send(error_subject, reason) }) + |> client.on_stream_error(fn(failure) { + process.send(error_subject, client.stream_failure_to_string(failure)) + }) let assert Ok(_handle) = client.start_stream(request) @@ -171,7 +177,9 @@ pub fn start_stream_non_utf8_error_body_surfaces_error_test() { let request = mock_request("/non-utf8-error") - |> client.on_stream_error(fn(reason) { process.send(error_subject, reason) }) + |> client.on_stream_error(fn(failure) { + process.send(error_subject, client.stream_failure_to_string(failure)) + }) let assert Ok(_handle) = client.start_stream(request) @@ -201,7 +209,8 @@ pub fn stream_yielder_non_utf8_error_body_surfaces_error_test() { let assert [first, ..] = results case first { - Error(reason) -> { + Error(failure) -> { + let reason = client.stream_failure_to_string(failure) { string.length(reason) > 0 } |> should.be_true() string.contains(reason, "400") |> should.be_true() io.println( @@ -222,7 +231,8 @@ pub fn stream_yielder_non_utf8_error_body_surfaces_error_test() { pub fn send_non_utf8_error_body_surfaces_error_test() { let req = mock_request("/non-utf8-error") case client.send(req) { - Error(client.RequestError(message: msg)) -> { + Error(client.RequestError(error: transport_error)) -> { + let msg = client.transport_error_to_string(transport_error) { string.length(msg) > 0 } |> should.be_true() io.println("send() non-UTF-8 body error (expected): " <> msg) } @@ -248,7 +258,9 @@ pub fn connection_refused_error_is_not_unknown_test() { let request = dead_port_request() - |> client.on_stream_error(fn(reason) { process.send(error_subject, reason) }) + |> client.on_stream_error(fn(failure) { + process.send(error_subject, client.stream_failure_to_string(failure)) + }) let _result = client.start_stream(request) diff --git a/modules/http_client/test/stream_non_streaming_response_test.gleam b/modules/http_client/test/stream_non_streaming_response_test.gleam index a6403d4..acd3b30 100644 --- a/modules/http_client/test/stream_non_streaming_response_test.gleam +++ b/modules/http_client/test/stream_non_streaming_response_test.gleam @@ -38,7 +38,9 @@ pub fn start_stream_calls_on_error_for_401_non_streaming_response_test() { let request = mock_request("/status/401") - |> client.on_stream_error(fn(reason) { process.send(error_subject, reason) }) + |> client.on_stream_error(fn(failure) { + process.send(error_subject, client.stream_failure_to_string(failure)) + }) let assert Ok(_handle) = client.start_stream(request) @@ -59,7 +61,9 @@ pub fn start_stream_calls_on_error_for_500_non_streaming_response_test() { let request = mock_request("/status/500") - |> client.on_stream_error(fn(reason) { process.send(error_subject, reason) }) + |> client.on_stream_error(fn(failure) { + process.send(error_subject, client.stream_failure_to_string(failure)) + }) let assert Ok(_handle) = client.start_stream(request) @@ -80,7 +84,9 @@ pub fn start_stream_error_contains_status_code_test() { let request = mock_request("/status/403") - |> client.on_stream_error(fn(reason) { process.send(error_subject, reason) }) + |> client.on_stream_error(fn(failure) { + process.send(error_subject, client.stream_failure_to_string(failure)) + }) let assert Ok(_handle) = client.start_stream(request) @@ -102,7 +108,9 @@ pub fn start_stream_error_contains_response_body_test() { let request = mock_request("/status/429") - |> client.on_stream_error(fn(reason) { process.send(error_subject, reason) }) + |> client.on_stream_error(fn(failure) { + process.send(error_subject, client.stream_failure_to_string(failure)) + }) let assert Ok(_handle) = client.start_stream(request) @@ -124,7 +132,9 @@ pub fn start_stream_does_not_crash_process_on_non_streaming_response_test() { let request = mock_request("/status/401") - |> client.on_stream_error(fn(reason) { process.send(error_subject, reason) }) + |> client.on_stream_error(fn(failure) { + process.send(error_subject, client.stream_failure_to_string(failure)) + }) let assert Ok(handle) = client.start_stream(request) @@ -157,7 +167,9 @@ pub fn stream_yielder_returns_error_for_401_non_streaming_response_test() { let assert [first, ..] = results case first { - Error(reason) -> string.contains(reason, "401") |> should.be_true() + Error(failure) -> + string.contains(client.stream_failure_to_string(failure), "401") + |> should.be_true() Ok(_) -> { io.println("Expected Error, got Ok") should.fail() @@ -174,7 +186,9 @@ pub fn stream_yielder_returns_error_for_500_non_streaming_response_test() { let assert [first, ..] = results case first { - Error(reason) -> string.contains(reason, "500") |> should.be_true() + Error(failure) -> + string.contains(client.stream_failure_to_string(failure), "500") + |> should.be_true() Ok(_) -> { io.println("Expected Error, got Ok") should.fail() @@ -191,7 +205,8 @@ pub fn stream_yielder_error_contains_response_body_test() { let assert [first, ..] = results case first { - Error(reason) -> { + Error(failure) -> { + let reason = client.stream_failure_to_string(failure) string.contains(reason, "422") |> should.be_true() { string.length(reason) > 10 } |> should.be_true() } @@ -242,8 +257,11 @@ pub fn stream_yielder_normal_streaming_still_works_test() { list.all(results, fn(result) { case result { Ok(_) -> True - Error(reason) -> { - io.println("Unexpected error in normal stream: " <> reason) + Error(failure) -> { + io.println( + "Unexpected error in normal stream: " + <> client.stream_failure_to_string(failure), + ) False } } @@ -257,3 +275,57 @@ fn collect_from_subject(subject: process.Subject(a), acc: List(a)) -> List(a) { Error(Nil) -> list.reverse(acc) } } + +// ============================================================================ +// Structured error type verification +// ============================================================================ + +/// HttpFailure from a 401 must carry response headers (not empty) +pub fn start_stream_401_http_failure_carries_headers_test() { + let error_subject = process.new_subject() + + let request = + mock_request("/status/401") + |> client.on_stream_error(fn(failure) { + process.send(error_subject, failure) + }) + + let assert Ok(_handle) = client.start_stream(request) + + case process.receive(error_subject, 3000) { + Ok(client.HttpFailure(response: response)) -> { + { response.headers != [] } |> should.be_true() + } + Ok(client.TransportFailure(_)) -> { + io.println("Expected HttpFailure, got TransportFailure") + should.fail() + } + Error(Nil) -> { + io.println("on_stream_error was never called") + should.fail() + } + } +} + +/// HttpFailure from a 500 via stream_yielder must carry a non-empty body +pub fn stream_yielder_500_http_failure_carries_body_test() { + let req = mock_request("/status/500") + let results = client.stream_yielder(req) |> yielder.take(1) |> yielder.to_list + + { results != [] } |> should.be_true() + + let assert [first, ..] = results + case first { + Error(client.HttpFailure(response: response)) -> { + { response.body != "" } |> should.be_true() + } + Error(client.TransportFailure(_)) -> { + io.println("Expected HttpFailure, got TransportFailure") + should.fail() + } + Ok(_) -> { + io.println("Expected Error, got Ok") + should.fail() + } + } +} diff --git a/modules/http_client/test/stream_yielder_completion_test.gleam b/modules/http_client/test/stream_yielder_completion_test.gleam index e40f634..8565c0b 100644 --- a/modules/http_client/test/stream_yielder_completion_test.gleam +++ b/modules/http_client/test/stream_yielder_completion_test.gleam @@ -42,11 +42,10 @@ pub fn stream_completes_without_error_test() { list.all(results, fn(result) { case result { Ok(_) -> True - Error(error_reason) -> { - // Unexpected error in stream; this test expects only Ok results. + Error(failure) -> { io.println( "stream_completes_without_error_test saw unexpected error: " - <> error_reason, + <> client.stream_failure_to_string(failure), ) False } @@ -68,9 +67,10 @@ pub fn last_chunk_is_ok_not_error_test() { case list.last(results) { Ok(Ok(_chunk)) -> Nil // Correct! - Ok(Error(error_reason)) -> { + Ok(Error(failure)) -> { io.println( - "last_chunk_is_ok_not_error_test saw unexpected error: " <> error_reason, + "last_chunk_is_ok_not_error_test saw unexpected error: " + <> client.stream_failure_to_string(failure), ) should.fail() } @@ -95,10 +95,10 @@ pub fn to_list_works_correctly_test() { list.all(results, fn(result) { case result { Ok(_) -> True - Error(error_reason) -> { + Error(failure) -> { io.println( "to_list_works_correctly_test saw unexpected error: " - <> error_reason, + <> client.stream_failure_to_string(failure), ) False } diff --git a/modules/http_client/test/transport_config_test.gleam b/modules/http_client/test/transport_config_test.gleam index db7ab3e..c5ed428 100644 --- a/modules/http_client/test/transport_config_test.gleam +++ b/modules/http_client/test/transport_config_test.gleam @@ -1,4 +1,6 @@ -import dream_http_client/client +import dream_http_client/client.{ + LogDebug, LogError, LogInfo, LogNone, LogWarning, +} import gleeunit/should pub fn transport_config_has_correct_defaults_test() { @@ -17,6 +19,7 @@ pub fn transport_config_has_correct_defaults_test() { client.get_initial_connection_window_size(config) |> should.equal(65_535) client.get_initial_stream_window_size(config) |> should.equal(65_535) client.get_closing_timeout(config) |> should.equal(15_000) + client.get_log_level(config) |> should.equal(LogInfo) } pub fn max_connections_sets_value_test() { @@ -121,6 +124,35 @@ pub fn closing_timeout_sets_value_test() { client.get_closing_timeout(updated) |> should.equal(30_000) } +pub fn log_level_defaults_to_info_test() { + let config = client.transport_config() + client.get_log_level(config) |> should.equal(LogInfo) +} + +pub fn log_level_sets_debug_test() { + let config = client.transport_config() + let updated = client.log_level(config, LogDebug) + client.get_log_level(updated) |> should.equal(LogDebug) +} + +pub fn log_level_sets_warning_test() { + let config = client.transport_config() + let updated = client.log_level(config, LogWarning) + client.get_log_level(updated) |> should.equal(LogWarning) +} + +pub fn log_level_sets_error_test() { + let config = client.transport_config() + let updated = client.log_level(config, LogError) + client.get_log_level(updated) |> should.equal(LogError) +} + +pub fn log_level_sets_none_test() { + let config = client.transport_config() + let updated = client.log_level(config, LogNone) + client.get_log_level(updated) |> should.equal(LogNone) +} + pub fn transport_config_builder_chain_sets_all_values_test() { let config = client.transport_config() @@ -137,6 +169,7 @@ pub fn transport_config_builder_chain_sets_all_values_test() { |> client.initial_connection_window_size(131_070) |> client.initial_stream_window_size(131_070) |> client.closing_timeout(30_000) + |> client.log_level(LogWarning) client.get_max_connections(config) |> should.equal(200) client.get_idle_timeout(config) |> should.equal(120_000) @@ -151,6 +184,7 @@ pub fn transport_config_builder_chain_sets_all_values_test() { client.get_initial_connection_window_size(config) |> should.equal(131_070) client.get_initial_stream_window_size(config) |> should.equal(131_070) client.get_closing_timeout(config) |> should.equal(30_000) + client.get_log_level(config) |> should.equal(LogWarning) } pub fn configure_transport_applies_without_error_test() { diff --git a/modules/opensearch/src/dream_opensearch/client.gleam b/modules/opensearch/src/dream_opensearch/client.gleam index c106c01..6377566 100644 --- a/modules/opensearch/src/dream_opensearch/client.gleam +++ b/modules/opensearch/src/dream_opensearch/client.gleam @@ -138,7 +138,8 @@ pub fn send_request( body: body, .., ))) -> Error(body) - Error(http_client.RequestError(message: message)) -> Error(message) + Error(http_client.RequestError(error: transport_error)) -> + Error(http_client.transport_error_to_string(transport_error)) } } diff --git a/research/gun_research/gleam.toml b/research/gun_research/gleam.toml new file mode 100644 index 0000000..c16ee82 --- /dev/null +++ b/research/gun_research/gleam.toml @@ -0,0 +1,11 @@ +name = "gun_research" +version = "0.0.0" +description = "Research: gun HTTP/2 client for dream_http_client" + +[dependencies] +gleam_stdlib = ">= 0.60.0 and < 1.0.0" +gleam_erlang = ">= 1.1.0 and < 2.0.0" +gun = ">= 2.2.0 and < 3.0.0" + +[dev-dependencies] +gleeunit = ">= 1.0.0 and < 2.0.0" diff --git a/research/gun_research/manifest.toml b/research/gun_research/manifest.toml new file mode 100644 index 0000000..7fd075d --- /dev/null +++ b/research/gun_research/manifest.toml @@ -0,0 +1,16 @@ +# This file was generated by Gleam +# You typically do not need to edit this file + +packages = [ + { name = "cowlib", version = "2.16.0", build_tools = ["make", "rebar3"], requirements = [], otp_app = "cowlib", source = "hex", outer_checksum = "7F478D80D66B747344F0EA7708C187645CFCC08B11AA424632F78E25BF05DB51" }, + { name = "gleam_erlang", version = "1.3.0", build_tools = ["gleam"], requirements = ["gleam_stdlib"], otp_app = "gleam_erlang", source = "hex", outer_checksum = "1124AD3AA21143E5AF0FC5CF3D9529F6DB8CA03E43A55711B60B6B7B3874375C" }, + { name = "gleam_stdlib", version = "0.70.0", build_tools = ["gleam"], requirements = [], otp_app = "gleam_stdlib", source = "hex", outer_checksum = "86949BF5D1F0E4AC0AB5B06F235D8A5CC11A2DFC33BF22F752156ED61CA7D0FF" }, + { name = "gleeunit", version = "1.9.0", build_tools = ["gleam"], requirements = ["gleam_stdlib"], otp_app = "gleeunit", source = "hex", outer_checksum = "DA9553CE58B67924B3C631F96FE3370C49EB6D6DC6B384EC4862CC4AAA718F3C" }, + { name = "gun", version = "2.2.0", build_tools = ["make", "rebar3"], requirements = ["cowlib"], otp_app = "gun", source = "hex", outer_checksum = "76022700C64287FEB4DF93A1795CFF6741B83FB37415C40C34C38D2A4645261A" }, +] + +[requirements] +gleam_erlang = { version = ">= 1.1.0 and < 2.0.0" } +gleam_stdlib = { version = ">= 0.60.0 and < 1.0.0" } +gleeunit = { version = ">= 1.0.0 and < 2.0.0" } +gun = { version = ">= 2.2.0 and < 3.0.0" } diff --git a/research/gun_research/src/gun_bench.erl b/research/gun_research/src/gun_bench.erl new file mode 100644 index 0000000..87ce5fd --- /dev/null +++ b/research/gun_research/src/gun_bench.erl @@ -0,0 +1,545 @@ +-module(gun_bench). +-export([ + run_all/0, + test_http1_sync/0, + test_http2_negotiation/0, + test_http2_multiplexing/0, + test_http2_multiplexing_single_conn/0, + test_concurrent_scale/0, + test_streaming/0, + test_cancel_stream/0, + test_error_handling/0, + test_connection_reuse/0, + test_connect_timeout/0, + test_auto_decompression/0, + test_h2_cancel_stream/0 +]). + +-define(BASE_HOST, "localhost"). +-define(BASE_PORT, 3004). + +run_all() -> + io:format("Starting gun research...~n~n"), + io:format("========================================~n"), + io:format(" GUN RESEARCH RESULTS~n"), + io:format("========================================~n~n"), + + io:format("--- 1. HTTP/1.1 sync request (localhost) ---~n"), + try test_http1_sync(), + io:format(" RESULT: PASS~n~n") + catch C1:R1:S1 -> + io:format(" RESULT: FAIL (~p:~p)~n Stack: ~p~n~n", [C1, R1, hd(S1)]) + end, + + io:format("--- 2. HTTP/2 negotiation (real HTTPS server) ---~n"), + try test_http2_negotiation(), + io:format(" RESULT: PASS~n~n") + catch C2:R2:S2 -> + io:format(" RESULT: FAIL (~p:~p)~n Stack: ~p~n~n", [C2, R2, hd(S2)]) + end, + + io:format("--- 3. HTTP/2 multiplexing (real HTTPS, multiple streams) ---~n"), + try test_http2_multiplexing(), + io:format(" RESULT: PASS~n~n") + catch C3:R3:S3 -> + io:format(" RESULT: FAIL (~p:~p)~n Stack: ~p~n~n", [C3, R3, hd(S3)]) + end, + + io:format("--- 4. HTTP/2 multiplexing (single conn, many streams) ---~n"), + try test_http2_multiplexing_single_conn(), + io:format(" RESULT: PASS~n~n") + catch C4:R4:S4 -> + io:format(" RESULT: FAIL (~p:~p)~n Stack: ~p~n~n", [C4, R4, hd(S4)]) + end, + + io:format("--- 5. Concurrent scale (100, 500, 1000, 5000 against localhost) ---~n"), + try test_concurrent_scale(), + io:format(" RESULT: PASS~n~n") + catch C5:R5:S5 -> + io:format(" RESULT: FAIL (~p:~p)~n Stack: ~p~n~n", [C5, R5, hd(S5)]) + end, + + io:format("--- 6. Streaming response ---~n"), + try test_streaming(), + io:format(" RESULT: PASS~n~n") + catch C6:R6:S6 -> + io:format(" RESULT: FAIL (~p:~p)~n Stack: ~p~n~n", [C6, R6, hd(S6)]) + end, + + io:format("--- 7. Cancel stream ---~n"), + try test_cancel_stream(), + io:format(" RESULT: PASS~n~n") + catch C7:R7:S7 -> + io:format(" RESULT: FAIL (~p:~p)~n Stack: ~p~n~n", [C7, R7, hd(S7)]) + end, + + io:format("--- 8. Error handling ---~n"), + try test_error_handling(), + io:format(" RESULT: PASS~n~n") + catch C8:R8:S8 -> + io:format(" RESULT: FAIL (~p:~p)~n Stack: ~p~n~n", [C8, R8, hd(S8)]) + end, + + io:format("--- 9. Connection reuse (many requests, one conn) ---~n"), + try test_connection_reuse(), + io:format(" RESULT: PASS~n~n") + catch C9:R9:S9 -> + io:format(" RESULT: FAIL (~p:~p)~n Stack: ~p~n~n", [C9, R9, hd(S9)]) + end, + + io:format("--- 10. Connect timeout behavior ---~n"), + try test_connect_timeout(), + io:format(" RESULT: PASS~n~n") + catch C10:R10:S10 -> + io:format(" RESULT: FAIL (~p:~p)~n Stack: ~p~n~n", [C10, R10, hd(S10)]) + end, + + io:format("--- 11. Auto decompression ---~n"), + try test_auto_decompression(), + io:format(" RESULT: PASS~n~n") + catch C11:R11:S11 -> + io:format(" RESULT: FAIL (~p:~p)~n Stack: ~p~n~n", [C11, R11, hd(S11)]) + end, + + io:format("--- 12. HTTP/2 cancel stream (connection survives) ---~n"), + try test_h2_cancel_stream(), + io:format(" RESULT: PASS~n~n") + catch C12:R12:S12 -> + io:format(" RESULT: FAIL (~p:~p)~n Stack: ~p~n~n", [C12, R12, hd(S12)]) + end, + + io:format("========================================~n"), + io:format(" DONE~n"), + io:format("========================================~n"), + ok. + +%% Test 1: Basic HTTP/1.1 sync request against mock server +test_http1_sync() -> + {ok, ConnPid} = gun:open(?BASE_HOST, ?BASE_PORT, #{ + protocols => [http] + }), + {ok, Protocol} = gun:await_up(ConnPid), + io:format(" Protocol: ~p~n", [Protocol]), + StreamRef = gun:get(ConnPid, "/text"), + case gun:await(ConnPid, StreamRef, 5000) of + {response, fin, Status, Headers} -> + io:format(" Status: ~p (no body)~n", [Status]), + io:format(" Headers: ~p~n", [Headers]); + {response, nofin, Status, Headers} -> + io:format(" Status: ~p~n", [Status]), + io:format(" Headers count: ~p~n", [length(Headers)]), + {ok, Body} = gun:await_body(ConnPid, StreamRef, 5000), + io:format(" Body: ~s (~p bytes)~n", [Body, byte_size(Body)]) + end, + gun:close(ConnPid), + ok. + +%% Test 2: HTTP/2 ALPN negotiation with a real HTTPS server +test_http2_negotiation() -> + {ok, ConnPid} = gun:open("www.google.com", 443, #{ + transport => tls, + protocols => [http2], + tls_opts => [{verify, verify_none}] + }), + case gun:await_up(ConnPid, 10000) of + {ok, Protocol} -> + io:format(" Negotiated protocol: ~p~n", [Protocol]), + StreamRef = gun:get(ConnPid, "/"), + case gun:await(ConnPid, StreamRef, 10000) of + {response, nofin, Status, Headers} -> + io:format(" Status: ~p~n", [Status]), + HeaderNames = [N || {N, _} <- Headers], + AllLower = lists:all(fun(N) -> + N =:= string:lowercase(N) + end, HeaderNames), + io:format(" All headers lowercase: ~p~n", [AllLower]), + io:format(" Sample headers: ~p~n", [lists:sublist(Headers, 3)]), + gun:cancel(ConnPid, StreamRef); + {response, fin, Status, _Headers} -> + io:format(" Status: ~p (no body)~n", [Status]) + end; + {error, Reason} -> + io:format(" Connection failed: ~p~n", [Reason]) + end, + gun:close(ConnPid), + ok. + +%% Test 3: HTTP/2 multiplexing - concurrent requests on separate connections +test_http2_multiplexing() -> + {ok, ConnPid} = gun:open("www.google.com", 443, #{ + transport => tls, + protocols => [http2], + tls_opts => [{verify, verify_none}] + }), + {ok, http2} = gun:await_up(ConnPid, 10000), + io:format(" Connected via HTTP/2~n"), + + N = 10, + T1 = erlang:monotonic_time(millisecond), + StreamRefs = [gun:get(ConnPid, "/") || _ <- lists:seq(1, N)], + io:format(" Sent ~p concurrent requests on ONE connection~n", [N]), + + Results = lists:map(fun(Ref) -> + case gun:await(ConnPid, Ref, 10000) of + {response, nofin, Status, _H} -> + gun:cancel(ConnPid, Ref), + {ok, Status}; + {response, fin, Status, _H} -> + {ok, Status}; + {error, E} -> + {error, E} + end + end, StreamRefs), + T2 = erlang:monotonic_time(millisecond), + + Successes = length([ok || {ok, _} <- Results]), + io:format(" ~p/~p succeeded in ~pms~n", [Successes, N, T2 - T1]), + io:format(" All on a SINGLE TCP connection (HTTP/2 multiplexing)~n"), + gun:close(ConnPid), + ok. + +%% Test 4: Many HTTP/2 streams on a single connection +test_http2_multiplexing_single_conn() -> + {ok, ConnPid} = gun:open("www.google.com", 443, #{ + transport => tls, + protocols => [http2], + tls_opts => [{verify, verify_none}], + http2_opts => #{max_concurrent_streams => 1000} + }), + {ok, http2} = gun:await_up(ConnPid, 10000), + + N = 50, + T1 = erlang:monotonic_time(millisecond), + StreamRefs = [gun:get(ConnPid, "/") || _ <- lists:seq(1, N)], + io:format(" Sent ~p requests on single HTTP/2 connection~n", [N]), + + Results = lists:map(fun(Ref) -> + case gun:await(ConnPid, Ref, 15000) of + {response, nofin, Status, _H} -> + gun:cancel(ConnPid, Ref), + {ok, Status}; + {response, fin, Status, _H} -> + {ok, Status}; + {error, E} -> + {error, E} + end + end, StreamRefs), + T2 = erlang:monotonic_time(millisecond), + + Successes = length([ok || {ok, _} <- Results]), + Errors = [E || {error, E} <- Results], + io:format(" ~p/~p succeeded in ~pms~n", [Successes, N, T2 - T1]), + case Errors of + [] -> ok; + _ -> io:format(" Errors: ~p~n", [lists:sublist(Errors, 5)]) + end, + gun:close(ConnPid), + ok. + +%% Test 5: Concurrent scale test against localhost (HTTP/1.1) +%% Each request gets its own gun connection (no pool bottleneck) +test_concurrent_scale() -> + lists:foreach(fun(N) -> + Self = self(), + T1 = erlang:monotonic_time(millisecond), + lists:foreach(fun(I) -> + spawn(fun() -> + Result = try + {ok, ConnPid} = gun:open(?BASE_HOST, ?BASE_PORT, #{ + protocols => [http], + connect_timeout => 5000 + }), + {ok, _} = gun:await_up(ConnPid, 5000), + StreamRef = gun:get(ConnPid, "/text"), + Res = case gun:await(ConnPid, StreamRef, 10000) of + {response, nofin, 200, _H} -> + {ok, Body} = gun:await_body(ConnPid, StreamRef, 10000), + {ok, byte_size(Body)}; + {response, fin, 200, _H} -> + {ok, 0}; + Other -> + {error, Other} + end, + gun:close(ConnPid), + Res + catch + _:Err -> {error, Err} + end, + Self ! {gun_done, N, I, Result} + end) + end, lists:seq(1, N)), + Successes = lists:foldl(fun(I, Acc) -> + receive + {gun_done, N, I, {ok, _}} -> Acc + 1; + {gun_done, N, I, _} -> Acc + after 30000 -> Acc + end + end, 0, lists:seq(1, N)), + T2 = erlang:monotonic_time(millisecond), + io:format(" gun ~p concurrent: ~p/~p in ~pms~n", [N, Successes, N, T2 - T1]) + end, [100, 500, 1000, 5000]), + ok. + +%% Test 6: Streaming response +test_streaming() -> + {ok, ConnPid} = gun:open(?BASE_HOST, ?BASE_PORT, #{ + protocols => [http] + }), + {ok, _} = gun:await_up(ConnPid, 5000), + StreamRef = gun:get(ConnPid, "/stream/10"), + {response, nofin, Status, _Headers} = gun:await(ConnPid, StreamRef, 5000), + io:format(" Status: ~p~n", [Status]), + Chunks = collect_body_chunks(ConnPid, StreamRef, []), + io:format(" Chunks received: ~p~n", [length(Chunks)]), + TotalBytes = lists:sum([byte_size(C) || C <- Chunks]), + io:format(" Total bytes: ~p~n", [TotalBytes]), + gun:close(ConnPid), + ok. + +collect_body_chunks(ConnPid, StreamRef, Acc) -> + receive + {gun_data, ConnPid, StreamRef, nofin, Data} -> + collect_body_chunks(ConnPid, StreamRef, [Data | Acc]); + {gun_data, ConnPid, StreamRef, fin, Data} -> + lists:reverse([Data | Acc]) + after 5000 -> + lists:reverse(Acc) + end. + +%% Test 7: Cancel a stream mid-response +test_cancel_stream() -> + {ok, ConnPid} = gun:open(?BASE_HOST, ?BASE_PORT, #{ + protocols => [http] + }), + {ok, _} = gun:await_up(ConnPid, 5000), + StreamRef = gun:get(ConnPid, "/stream/slow"), + case gun:await(ConnPid, StreamRef, 5000) of + {response, nofin, Status, _H} -> + io:format(" Got response status: ~p~n", [Status]), + ok = gun:cancel(ConnPid, StreamRef), + io:format(" Cancelled stream~n"), + gun:flush(StreamRef), + io:format(" Flushed remaining messages~n"); + {response, fin, Status, _H} -> + io:format(" Got complete response: ~p~n", [Status]) + end, + %% Verify connection is still usable after cancel + StreamRef2 = gun:get(ConnPid, "/text"), + case gun:await(ConnPid, StreamRef2, 5000) of + {response, nofin, 200, _} -> + {ok, Body} = gun:await_body(ConnPid, StreamRef2, 5000), + io:format(" Connection still usable after cancel: ~s~n", [Body]); + {response, fin, 200, _} -> + io:format(" Connection still usable after cancel~n"); + {error, E} -> + io:format(" Connection broken after cancel: ~p~n", [E]) + end, + gun:close(ConnPid), + ok. + +%% Test 8: Error handling +test_error_handling() -> + %% Connection refused + T1 = erlang:monotonic_time(millisecond), + {ok, ConnPid} = gun:open("localhost", 19999, #{ + protocols => [http], + connect_timeout => 2000 + }), + case gun:await_up(ConnPid, 3000) of + {ok, _} -> + io:format(" Unexpectedly connected~n"); + {error, Reason} -> + T2 = erlang:monotonic_time(millisecond), + io:format(" Connection refused: ~p in ~pms~n", [Reason, T2 - T1]) + end, + gun:close(ConnPid), + + %% Unreachable host (connect timeout) + io:format(" Testing connect timeout to unreachable host...~n"), + T3 = erlang:monotonic_time(millisecond), + {ok, ConnPid2} = gun:open("192.0.2.1", 80, #{ + protocols => [http], + connect_timeout => 1000 + }), + case gun:await_up(ConnPid2, 3000) of + {ok, _} -> + io:format(" Unexpectedly connected~n"); + {error, Reason2} -> + T4 = erlang:monotonic_time(millisecond), + io:format(" Timeout error: ~p in ~pms~n", [Reason2, T4 - T3]) + end, + gun:close(ConnPid2), + ok. + +%% Test 10: Connect timeout - does gun's connect_timeout option work? +test_connect_timeout() -> + %% Gun retries by default (retry=5, retry_timeout=5000). + %% Disable retries to isolate connect_timeout behavior. + + %% Test 1: connect_timeout=500ms, no retries, unreachable host + io:format(" Testing with retry=0 to isolate connect_timeout...~n"), + T1 = erlang:monotonic_time(millisecond), + {ok, ConnPid1} = gun:open("192.0.2.1", 80, #{ + protocols => [http], + connect_timeout => 500, + retry => 0 + }), + Result1 = gun:await_up(ConnPid1, 10000), + T2 = erlang:monotonic_time(millisecond), + io:format(" connect_timeout=500ms, retry=0: ~p in ~pms~n", [Result1, T2 - T1]), + gun:close(ConnPid1), + + %% Test 2: connect_timeout=2000ms, no retries + T3 = erlang:monotonic_time(millisecond), + {ok, ConnPid2} = gun:open("192.0.2.1", 80, #{ + protocols => [http], + connect_timeout => 2000, + retry => 0 + }), + Result2 = gun:await_up(ConnPid2, 10000), + T4 = erlang:monotonic_time(millisecond), + io:format(" connect_timeout=2000ms, retry=0: ~p in ~pms~n", [Result2, T4 - T3]), + gun:close(ConnPid2), + + %% Test 3: connection refused (should be instant, regardless of timeout) + T5 = erlang:monotonic_time(millisecond), + {ok, ConnPid3} = gun:open("localhost", 19999, #{ + protocols => [http], + connect_timeout => 5000, + retry => 0 + }), + Result3 = gun:await_up(ConnPid3, 10000), + T6 = erlang:monotonic_time(millisecond), + io:format(" connect_timeout=5000ms, refused port, retry=0: ~p in ~pms~n", + [Result3, T6 - T5]), + gun:close(ConnPid3), + + %% Test 4: verify retries amplify the time (retry=2 with 500ms timeout) + T7 = erlang:monotonic_time(millisecond), + {ok, ConnPid4} = gun:open("192.0.2.1", 80, #{ + protocols => [http], + connect_timeout => 500, + retry => 2, + retry_timeout => 100 + }), + Result4 = gun:await_up(ConnPid4, 30000), + T8 = erlang:monotonic_time(millisecond), + io:format(" connect_timeout=500ms, retry=2, retry_timeout=100ms: ~p in ~pms~n", + [Result4, T8 - T7]), + gun:close(ConnPid4), + ok. + +%% Test 11: Auto decompression - does gun handle gzip/deflate? +test_auto_decompression() -> + %% Request gzipped content from mock server + {ok, ConnPid} = gun:open(?BASE_HOST, ?BASE_PORT, #{ + protocols => [http] + }), + {ok, _} = gun:await_up(ConnPid, 5000), + + %% Request with Accept-Encoding: gzip + StreamRef1 = gun:get(ConnPid, "/gzip", [ + {<<"accept-encoding">>, <<"gzip">>} + ]), + case gun:await(ConnPid, StreamRef1, 5000) of + {response, nofin, Status1, Headers1} -> + {ok, Body1} = gun:await_body(ConnPid, StreamRef1, 5000), + CE = proplists:get_value(<<"content-encoding">>, Headers1, <<"none">>), + io:format(" With accept-encoding:gzip~n"), + io:format(" Status: ~p~n", [Status1]), + io:format(" Content-Encoding: ~s~n", [CE]), + io:format(" Body size: ~p bytes~n", [byte_size(Body1)]), + %% Check if body is still gzipped or was auto-decompressed + IsGzipped = case Body1 of + <<16#1f, 16#8b, _/binary>> -> true; + _ -> false + end, + io:format(" Body still gzipped: ~p~n", [IsGzipped]), + case IsGzipped of + true -> + Decompressed = zlib:gunzip(Body1), + io:format(" Decompressed: ~s (~p bytes)~n", + [Decompressed, byte_size(Decompressed)]), + io:format(" GUN DOES NOT AUTO-DECOMPRESS~n"); + false -> + io:format(" Body: ~s~n", [Body1]), + io:format(" GUN AUTO-DECOMPRESSES~n") + end; + {response, fin, Status1, _} -> + io:format(" Status: ~p (no body)~n", [Status1]) + end, + + %% Also test without accept-encoding + StreamRef2 = gun:get(ConnPid, "/text"), + case gun:await(ConnPid, StreamRef2, 5000) of + {response, nofin, _Status2, _Headers2} -> + {ok, Body2} = gun:await_body(ConnPid, StreamRef2, 5000), + io:format(" Without accept-encoding:~n"), + io:format(" Body: ~s~n", [Body2]); + {response, fin, _Status2, _} -> + io:format(" No body without accept-encoding~n") + end, + + gun:close(ConnPid), + ok. + +%% Test 12: HTTP/2 cancel stream - connection should survive +test_h2_cancel_stream() -> + {ok, ConnPid} = gun:open("www.google.com", 443, #{ + transport => tls, + protocols => [http2], + tls_opts => [{verify, verify_none}] + }), + {ok, http2} = gun:await_up(ConnPid, 10000), + io:format(" Connected via HTTP/2~n"), + + %% Start a request and cancel it + StreamRef1 = gun:get(ConnPid, "/search?q=test"), + timer:sleep(100), + gun:cancel(ConnPid, StreamRef1), + gun:flush(StreamRef1), + io:format(" Cancelled first request~n"), + + %% Now try another request on the SAME connection + StreamRef2 = gun:get(ConnPid, "/"), + case gun:await(ConnPid, StreamRef2, 10000) of + {response, nofin, Status, _H} -> + gun:cancel(ConnPid, StreamRef2), + io:format(" Second request after cancel: status ~p~n", [Status]), + io:format(" HTTP/2 connection SURVIVES cancel (streams are independent)~n"); + {response, fin, Status, _H} -> + io:format(" Second request after cancel: status ~p~n", [Status]), + io:format(" HTTP/2 connection SURVIVES cancel~n"); + {error, E} -> + io:format(" Second request FAILED: ~p~n", [E]), + io:format(" HTTP/2 connection BROKEN after cancel~n") + end, + gun:close(ConnPid), + ok. + +%% Test 9: Connection reuse - many sequential requests on one connection +test_connection_reuse() -> + {ok, ConnPid} = gun:open(?BASE_HOST, ?BASE_PORT, #{ + protocols => [http] + }), + {ok, _} = gun:await_up(ConnPid, 5000), + + N = 100, + T1 = erlang:monotonic_time(millisecond), + Successes = lists:foldl(fun(_, Acc) -> + StreamRef = gun:get(ConnPid, "/text"), + case gun:await(ConnPid, StreamRef, 5000) of + {response, nofin, 200, _} -> + {ok, _} = gun:await_body(ConnPid, StreamRef, 5000), + Acc + 1; + {response, fin, 200, _} -> + Acc + 1; + _ -> + Acc + end + end, 0, lists:seq(1, N)), + T2 = erlang:monotonic_time(millisecond), + io:format(" ~p/~p sequential requests on ONE connection in ~pms~n", + [Successes, N, T2 - T1]), + gun:close(ConnPid), + ok. diff --git a/research/gun_research/src/gun_research.gleam b/research/gun_research/src/gun_research.gleam new file mode 100644 index 0000000..4ead3b1 --- /dev/null +++ b/research/gun_research/src/gun_research.gleam @@ -0,0 +1,3 @@ +pub fn main() { + Nil +} diff --git a/research/gun_research/test/gun_research_test.gleam b/research/gun_research/test/gun_research_test.gleam new file mode 100644 index 0000000..7c8adfd --- /dev/null +++ b/research/gun_research/test/gun_research_test.gleam @@ -0,0 +1,10 @@ +import gleam/io + +@external(erlang, "gun_bench", "run_all") +fn run_all() -> Nil + +pub fn main() { + io.println("Running gun research benchmarks...") + run_all() + io.println("Done.") +} diff --git a/research/hackney_research/.github/workflows/test.yml b/research/hackney_research/.github/workflows/test.yml new file mode 100644 index 0000000..9821961 --- /dev/null +++ b/research/hackney_research/.github/workflows/test.yml @@ -0,0 +1,23 @@ +name: test + +on: + push: + branches: + - master + - main + pull_request: + +jobs: + test: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + - uses: erlef/setup-beam@v1 + with: + otp-version: "28" + gleam-version: "1.14.0" + rebar3-version: "3" + # elixir-version: "1" + - run: gleam deps download + - run: gleam test + - run: gleam format --check src test diff --git a/research/hackney_research/.gitignore b/research/hackney_research/.gitignore new file mode 100644 index 0000000..599be4e --- /dev/null +++ b/research/hackney_research/.gitignore @@ -0,0 +1,4 @@ +*.beam +*.ez +/build +erl_crash.dump diff --git a/research/hackney_research/README.md b/research/hackney_research/README.md new file mode 100644 index 0000000..ab4e5f8 --- /dev/null +++ b/research/hackney_research/README.md @@ -0,0 +1,24 @@ +# hackney_research + +[![Package Version](https://img.shields.io/hexpm/v/hackney_research)](https://hex.pm/packages/hackney_research) +[![Hex Docs](https://img.shields.io/badge/hex-docs-ffaff3)](https://hexdocs.pm/hackney_research/) + +```sh +gleam add hackney_research@1 +``` +```gleam +import hackney_research + +pub fn main() -> Nil { + // TODO: An example of the project in use +} +``` + +Further documentation can be found at . + +## Development + +```sh +gleam run # Run the project +gleam test # Run the tests +``` diff --git a/research/hackney_research/gleam.toml b/research/hackney_research/gleam.toml new file mode 100644 index 0000000..ba92fb6 --- /dev/null +++ b/research/hackney_research/gleam.toml @@ -0,0 +1,11 @@ +name = "hackney_research" +version = "0.0.0" +description = "Research: hackney vs httpc for dream_http_client" + +[dependencies] +gleam_stdlib = ">= 0.60.0 and < 1.0.0" +gleam_erlang = ">= 1.1.0 and < 2.0.0" +hackney = ">= 3.0.0 and < 4.0.0" + +[dev-dependencies] +gleeunit = ">= 1.0.0 and < 2.0.0" diff --git a/research/hackney_research/manifest.toml b/research/hackney_research/manifest.toml new file mode 100644 index 0000000..9c2713f --- /dev/null +++ b/research/hackney_research/manifest.toml @@ -0,0 +1,21 @@ +# This file was generated by Gleam +# You typically do not need to edit this file + +packages = [ + { name = "certifi", version = "2.16.0", build_tools = ["rebar3"], requirements = [], otp_app = "certifi", source = "hex", outer_checksum = "8A64F6669D85E9CC0E5086FCF29A5B13DE57A13EFA23D3582874B9A19303F184" }, + { name = "gleam_erlang", version = "1.3.0", build_tools = ["gleam"], requirements = ["gleam_stdlib"], otp_app = "gleam_erlang", source = "hex", outer_checksum = "1124AD3AA21143E5AF0FC5CF3D9529F6DB8CA03E43A55711B60B6B7B3874375C" }, + { name = "gleam_stdlib", version = "0.70.0", build_tools = ["gleam"], requirements = [], otp_app = "gleam_stdlib", source = "hex", outer_checksum = "86949BF5D1F0E4AC0AB5B06F235D8A5CC11A2DFC33BF22F752156ED61CA7D0FF" }, + { name = "gleeunit", version = "1.9.0", build_tools = ["gleam"], requirements = ["gleam_stdlib"], otp_app = "gleeunit", source = "hex", outer_checksum = "DA9553CE58B67924B3C631F96FE3370C49EB6D6DC6B384EC4862CC4AAA718F3C" }, + { name = "hackney", version = "3.2.1", build_tools = ["rebar3"], requirements = ["certifi", "idna", "mimerl", "parse_trans", "quic", "ssl_verify_fun"], otp_app = "hackney", source = "hex", outer_checksum = "1D9260C31B7C910B63E6B3929D296F18BA3D8217EF01800989AFCFCC77776AFE" }, + { name = "idna", version = "7.1.0", build_tools = ["rebar3"], requirements = [], otp_app = "idna", source = "hex", outer_checksum = "6AE959A025BF36DF61A8CAB8508D9654891B5426A84C44D82DEAFFD6DDF8C71F" }, + { name = "mimerl", version = "1.4.0", build_tools = ["rebar3"], requirements = [], otp_app = "mimerl", source = "hex", outer_checksum = "13AF15F9F68C65884ECCA3A3891D50A7B57D82152792F3E19D88650AA126B144" }, + { name = "parse_trans", version = "3.4.1", build_tools = ["rebar3"], requirements = [], otp_app = "parse_trans", source = "hex", outer_checksum = "620A406CE75DADA827B82E453C19CF06776BE266F5A67CFF34E1EF2CBB60E49A" }, + { name = "quic", version = "0.10.2", build_tools = ["rebar3"], requirements = [], otp_app = "quic", source = "hex", outer_checksum = "7C196A66973C877A59768A5687F0A0610FF11817254D0A4E45CC4E3A16B1D00B" }, + { name = "ssl_verify_fun", version = "1.1.7", build_tools = ["mix", "rebar3", "make"], requirements = [], otp_app = "ssl_verify_fun", source = "hex", outer_checksum = "FE4C190E8F37401D30167C8C405EDA19469F34577987C76DDE613E838BBC67F8" }, +] + +[requirements] +gleam_erlang = { version = ">= 1.1.0 and < 2.0.0" } +gleam_stdlib = { version = ">= 0.60.0 and < 1.0.0" } +gleeunit = { version = ">= 1.0.0 and < 2.0.0" } +hackney = { version = ">= 3.0.0 and < 4.0.0" } diff --git a/research/hackney_research/src/hackney_bench.erl b/research/hackney_research/src/hackney_bench.erl new file mode 100644 index 0000000..16810b1 --- /dev/null +++ b/research/hackney_research/src/hackney_bench.erl @@ -0,0 +1,384 @@ +-module(hackney_bench). +-export([ + run_all/0, + test_sync_request/0, + test_pull_stream/0, + test_async_stream/0, + test_async_once_stream/0, + test_connection_pool/0, + test_redirect_control/0, + test_connect_timeout/0, + test_auto_decompression/0, + test_error_format/0, + test_cancel_async/0, + test_http2_negotiation/0, + test_http2_multiplexing/0, + test_concurrent_requests_hackney/0, + test_concurrent_requests_httpc/0 +]). + +%% Run all research tests against a target URL +%% Start the mock server first: cd modules/http_client && gleam test +%% Or use httpbin.org for external tests + +-define(BASE, "http://localhost:3004"). + +run_all() -> + {ok, _} = application:ensure_all_started(hackney), + Tests = [ + {"1. Sync request", fun test_sync_request/0}, + {"2. Pull-based streaming (stream_body)", fun test_pull_stream/0}, + {"3. Async streaming (async: true)", fun test_async_stream/0}, + {"4. Async-once streaming (async: once)", fun test_async_once_stream/0}, + {"5. Connection pooling", fun test_connection_pool/0}, + {"6. Redirect control (follow_redirect)", fun test_redirect_control/0}, + {"7. Connect timeout", fun test_connect_timeout/0}, + {"8. Auto decompression", fun test_auto_decompression/0}, + {"9. Error format", fun test_error_format/0}, + {"10. Cancel async request", fun test_cancel_async/0} + ], + io:format("~n========================================~n"), + io:format(" HACKNEY RESEARCH RESULTS~n"), + io:format("========================================~n~n"), + lists:foreach(fun({Name, Fun}) -> + io:format("--- ~s ---~n", [Name]), + try + Fun(), + io:format(" RESULT: PASS~n~n") + catch + Class:Reason:Stack -> + io:format(" RESULT: FAIL~n"), + io:format(" Error: ~p:~p~n", [Class, Reason]), + io:format(" Stack: ~p~n~n", [hd(Stack)]) + end + end, Tests), + io:format("~n--- 11. HTTP/2 negotiation ---~n"), + try test_http2_negotiation(), + io:format(" RESULT: PASS~n~n") + catch C11:R11:S11 -> + io:format(" RESULT: FAIL (~p:~p)~n Stack: ~p~n~n", [C11, R11, hd(S11)]) + end, + + io:format("--- 12. HTTP/2 multiplexing ---~n"), + try test_http2_multiplexing(), + io:format(" RESULT: PASS~n~n") + catch C12:R12:S12 -> + io:format(" RESULT: FAIL (~p:~p)~n Stack: ~p~n~n", [C12, R12, hd(S12)]) + end, + + io:format("--- 13. Concurrent requests (hackney, 100 parallel) ---~n"), + try test_concurrent_requests_hackney(), + io:format(" RESULT: PASS~n~n") + catch C13:R13:S13 -> + io:format(" RESULT: FAIL (~p:~p)~n Stack: ~p~n~n", [C13, R13, hd(S13)]) + end, + + io:format("--- 14. Concurrent requests (httpc, 100 parallel) ---~n"), + try test_concurrent_requests_httpc(), + io:format(" RESULT: PASS~n~n") + catch C14:R14:S14 -> + io:format(" RESULT: FAIL (~p:~p)~n Stack: ~p~n~n", [C14, R14, hd(S14)]) + end, + + io:format("========================================~n"), + io:format(" DONE~n"), + io:format("========================================~n"), + ok. + +%% Test 1: Basic sync request - does hackney return status, headers, body? +test_sync_request() -> + {ok, Status, Headers, Body} = hackney:request(get, <>, [], <<>>, []), + io:format(" Status: ~p~n", [Status]), + io:format(" Headers type: ~p (first: ~p)~n", [length(Headers), hd(Headers)]), + io:format(" Body type: ~p, size: ~p~n", [element(1, {Body}), byte_size(Body)]), + io:format(" Body: ~s~n", [Body]), + true = is_integer(Status), + true = Status =:= 200, + true = is_binary(Body), + ok. + +%% Test 2: Pull-based streaming via stream_body/1 +%% This is hackney's equivalent of our pull-based yielder model +test_pull_stream() -> + {ok, Status, Headers, Ref} = hackney:request(get, <>, [], <<>>, []), + io:format(" Status: ~p~n", [Status]), + io:format(" Headers count: ~p~n", [length(Headers)]), + io:format(" Ref type: ~p~n", [Ref]), + {Chunks, _FinalRef} = collect_stream_body(Ref, []), + io:format(" Chunks received: ~p~n", [length(Chunks)]), + io:format(" Total bytes: ~p~n", [lists:sum([byte_size(C) || C <- Chunks])]), + true = length(Chunks) > 0, + ok. + +collect_stream_body(Ref, Acc) -> + case hackney:stream_body(Ref) of + {ok, Data} -> + collect_stream_body(Ref, [Data | Acc]); + done -> + {lists:reverse(Acc), Ref}; + {error, Reason} -> + io:format(" Stream error: ~p~n", [Reason]), + {lists:reverse(Acc), Ref} + end. + +%% Test 3: Async streaming - messages pushed to process mailbox +%% This is hackney's equivalent of our message-based start_stream() model +test_async_stream() -> + {ok, Ref} = hackney:request(get, <>, [], <<>>, [{async, true}]), + io:format(" Ref: ~p~n", [Ref]), + {Status, Headers, Chunks} = collect_async_messages(Ref, undefined, [], []), + io:format(" Status: ~p~n", [Status]), + io:format(" Headers count: ~p~n", [length(Headers)]), + io:format(" Chunks received: ~p~n", [length(Chunks)]), + io:format(" Total bytes: ~p~n", [lists:sum([byte_size(C) || C <- Chunks])]), + true = Status =:= 200, + true = length(Chunks) > 0, + ok. + +collect_async_messages(Ref, Status, Headers, Chunks) -> + receive + {hackney_response, Ref, {status, S, _Reason}} -> + collect_async_messages(Ref, S, Headers, Chunks); + {hackney_response, Ref, {headers, H}} -> + collect_async_messages(Ref, Status, H, Chunks); + {hackney_response, Ref, done} -> + {Status, Headers, lists:reverse(Chunks)}; + {hackney_response, Ref, Bin} when is_binary(Bin) -> + collect_async_messages(Ref, Status, Headers, [Bin | Chunks]); + {hackney_response, Ref, {error, Reason}} -> + io:format(" Async error: ~p~n", [Reason]), + {Status, Headers, lists:reverse(Chunks)} + after 30000 -> + io:format(" TIMEOUT waiting for async message~n"), + {Status, Headers, lists:reverse(Chunks)} + end. + +%% Test 4: Async-once streaming - pull one chunk at a time via stream_next/1 +%% This gives backpressure control like our pull-based model +test_async_once_stream() -> + {ok, Ref} = hackney:request(get, <>, [], <<>>, [{async, once}]), + io:format(" Ref: ~p~n", [Ref]), + {Status, Headers, Chunks} = collect_async_once(Ref, undefined, [], []), + io:format(" Status: ~p~n", [Status]), + io:format(" Headers count: ~p~n", [length(Headers)]), + io:format(" Chunks received: ~p~n", [length(Chunks)]), + true = Status =:= 200, + true = length(Chunks) > 0, + ok. + +collect_async_once(Ref, Status, Headers, Chunks) -> + receive + {hackney_response, Ref, {status, S, _Reason}} -> + hackney:stream_next(Ref), + collect_async_once(Ref, S, Headers, Chunks); + {hackney_response, Ref, {headers, H}} -> + hackney:stream_next(Ref), + collect_async_once(Ref, Status, H, Chunks); + {hackney_response, Ref, done} -> + {Status, Headers, lists:reverse(Chunks)}; + {hackney_response, Ref, Bin} when is_binary(Bin) -> + hackney:stream_next(Ref), + collect_async_once(Ref, Status, Headers, [Bin | Chunks]); + {hackney_response, Ref, {error, Reason}} -> + io:format(" Async-once error: ~p~n", [Reason]), + {Status, Headers, lists:reverse(Chunks)} + after 30000 -> + io:format(" TIMEOUT~n"), + {Status, Headers, lists:reverse(Chunks)} + end. + +%% Test 5: Connection pooling - does hackney reuse connections? +test_connection_pool() -> + %% Make 5 requests to same host - check pool stats + lists:foreach(fun(I) -> + {ok, 200, _H, Body} = hackney:request(get, <>, [], <<>>, []), + io:format(" Request ~p: ~p bytes~n", [I, byte_size(Body)]) + end, lists:seq(1, 5)), + %% Check pool stats + Stats = hackney_pool:get_stats(default), + io:format(" Pool stats: ~p~n", [Stats]), + ok. + +%% Test 6: Redirect control +test_redirect_control() -> + %% hackney uses follow_redirect option (default: false) + %% This is different from httpc where autoredirect defaults to true + io:format(" hackney default: follow_redirect = false~n"), + io:format(" httpc default: autoredirect = true~n"), + io:format(" hackney also supports max_redirect option (default: 5)~n"), + io:format(" NOTE: No redirect endpoint on mock server to test against~n"), + ok. + +%% Test 7: Connect timeout behavior +test_connect_timeout() -> + %% hackney default connect_timeout is 8000ms + %% httpc default is infinity (we hardcoded 15000ms) + T1 = erlang:monotonic_time(millisecond), + Result = hackney:request(get, <<"http://localhost:1/test">>, [], <<>>, [ + {connect_timeout, 1000} + ]), + T2 = erlang:monotonic_time(millisecond), + Elapsed = T2 - T1, + io:format(" Result: ~p~n", [Result]), + io:format(" Elapsed: ~pms (with 1000ms connect_timeout)~n", [Elapsed]), + io:format(" hackney default connect_timeout: 8000ms~n"), + io:format(" httpc default connect_timeout: infinity~n"), + ok. + +%% Test 8: Auto decompression +test_auto_decompression() -> + %% Does hackney handle gzip automatically? + {ok, Status, Headers, Body} = hackney:request(get, <>, [], <<>>, []), + io:format(" Status: ~p~n", [Status]), + CE = proplists:get_value(<<"content-encoding">>, Headers, <<"none">>), + io:format(" Content-Encoding header: ~p~n", [CE]), + io:format(" Body size: ~p~n", [byte_size(Body)]), + io:format(" Body starts with: ~p~n", [binary:part(Body, 0, min(100, byte_size(Body)))]), + %% Check if body looks like JSON (decompressed) or binary (compressed) + IsJson = case Body of + <<"{", _/binary>> -> true; + _ -> false + end, + io:format(" Body looks like JSON (decompressed): ~p~n", [IsJson]), + ok. + +%% Test 9: Error format - what do hackney errors look like? +test_error_format() -> + %% Connection refused + {error, Reason1} = hackney:request(get, <<"http://localhost:1/test">>, [], <<>>, [ + {connect_timeout, 500} + ]), + io:format(" Connection refused error: ~p~n", [Reason1]), + io:format(" Error type: ~p~n", [element(1, {Reason1})]), + ok. + +%% Test 10: Cancel an async request +test_cancel_async() -> + {ok, Ref} = hackney:request(get, <>, [], <<>>, [{async, true}]), + io:format(" Started async request, ref: ~p~n", [Ref]), + timer:sleep(500), + %% hackney 3.x: try hackney:close/1 instead of cancel_request + Result = hackney:close(Ref), + io:format(" Close result: ~p~n", [Result]), + ok. + +%% Test 11: Does hackney negotiate HTTP/2 with a real HTTPS server? +test_http2_negotiation() -> + Urls = [ + <<"https://http2.pro/api/v1">>, + <<"https://www.google.com">> + ], + lists:foreach(fun(Url) -> + io:format(" Requesting ~s~n", [Url]), + case hackney:request(get, Url, [], <<>>, []) of + {ok, Status, Headers, Body} -> + io:format(" Status: ~p~n", [Status]), + io:format(" Body size: ~p~n", [byte_size(Body)]), + HeaderNames = [N || {N, _V} <- Headers], + AllLower = lists:all(fun(N) -> N =:= string:lowercase(N) end, HeaderNames), + io:format(" All headers lowercase (HTTP/2 indicator): ~p~n", [AllLower]), + io:format(" Sample headers: ~p~n", [lists:sublist(Headers, 3)]), + %% Check if body mentions h2 protocol + case binary:match(Body, <<"h2">>) of + nomatch -> io:format(" Body does not mention h2~n"); + _ -> io:format(" Body mentions h2 (server confirms HTTP/2)~n") + end; + {error, E} -> + io:format(" Error: ~p~n", [E]) + end, + io:format("~n") + end, Urls), + ok. + +%% Test 12: HTTP/2 multiplexing - multiple requests on same connection +test_http2_multiplexing() -> + Url = <<"https://nghttp2.org/httpbin/get">>, + %% Make 5 concurrent requests and see if they share connections + Self = self(), + T1 = erlang:monotonic_time(millisecond), + lists:foreach(fun(I) -> + spawn(fun() -> + Result = hackney:request(get, Url, [], <<>>, [ + {protocols, [http2, http1]} + ]), + Self ! {done, I, Result} + end) + end, lists:seq(1, 5)), + %% Collect results + Results = [receive {done, I, R} -> {I, R} after 15000 -> {timeout, timeout} end + || I <- lists:seq(1, 5)], + T2 = erlang:monotonic_time(millisecond), + lists:foreach(fun({I, R}) -> + case R of + {ok, S, _H, _B} -> io:format(" Request ~p: status ~p~n", [I, S]); + {error, E} -> io:format(" Request ~p: error ~p~n", [I, E]); + _ -> io:format(" Request ~p: ~p~n", [I, R]) + end + end, Results), + io:format(" Total time for 5 concurrent HTTPS requests: ~pms~n", [T2 - T1]), + ok. + +%% Test 13: Benchmark concurrent requests with hackney at multiple scales +test_concurrent_requests_hackney() -> + %% Use a dedicated pool with large max to avoid pool-size bottleneck + hackney_pool:start_pool(bench_pool, [{max_connections, 2000}, {timeout, 60000}]), + lists:foreach(fun(N) -> + Self = self(), + T1 = erlang:monotonic_time(millisecond), + lists:foreach(fun(I) -> + spawn(fun() -> + R = hackney:request(get, <>, [], <<>>, [{pool, bench_pool}]), + Self ! {hackney_done, N, I, R} + end) + end, lists:seq(1, N)), + Successes = lists:foldl(fun(I, Acc) -> + receive + {hackney_done, N, I, {ok, 200, _H, _B}} -> Acc + 1; + {hackney_done, N, I, Other} -> + case N =< 100 of + true -> io:format(" hackney fail ~p: ~p~n", [I, Other]); + false -> ok + end, + Acc + after 30000 -> Acc + end + end, 0, lists:seq(1, N)), + T2 = erlang:monotonic_time(millisecond), + io:format(" hackney ~p concurrent: ~p/~p in ~pms~n", [N, Successes, N, T2 - T1]) + end, [100, 500, 1000, 5000]), + PoolStats = hackney_pool:get_stats(bench_pool), + io:format(" Pool stats after: ~p~n", [PoolStats]), + hackney_pool:stop_pool(bench_pool), + ok. + +%% Test 14: Benchmark concurrent requests with httpc at multiple scales +test_concurrent_requests_httpc() -> + {ok, _} = application:ensure_all_started(inets), + {ok, _} = application:ensure_all_started(ssl), + %% Reset httpc to cold state by using a fresh profile + inets:stop(httpc, default), + inets:start(httpc, [{profile, default}]), + httpc:set_options([{max_sessions, 2000}, {max_pipeline_length, 0}, + {keep_alive_timeout, 60000}, {max_keep_alive_length, 1000}]), + lists:foreach(fun(N) -> + Self = self(), + T1 = erlang:monotonic_time(millisecond), + lists:foreach(fun(I) -> + spawn(fun() -> + R = httpc:request(get, {"http://localhost:3004/text", []}, + [{timeout, 30000}, {connect_timeout, 5000}], + [{sync, true}, {body_format, binary}]), + Self ! {httpc_done, N, I, R} + end) + end, lists:seq(1, N)), + Successes = lists:foldl(fun(I, Acc) -> + receive + {httpc_done, N, I, {ok, _}} -> Acc + 1; + {httpc_done, N, I, _} -> Acc + after 30000 -> Acc + end + end, 0, lists:seq(1, N)), + T2 = erlang:monotonic_time(millisecond), + io:format(" httpc ~p concurrent: ~p/~p in ~pms~n", [N, Successes, N, T2 - T1]) + end, [100, 500, 1000, 5000]), + ok. diff --git a/research/hackney_research/src/hackney_research.gleam b/research/hackney_research/src/hackney_research.gleam new file mode 100644 index 0000000..5dbde80 --- /dev/null +++ b/research/hackney_research/src/hackney_research.gleam @@ -0,0 +1,5 @@ +import gleam/io + +pub fn main() { + io.println("Run tests with: gleam test") +} diff --git a/research/hackney_research/test/hackney_research_test.gleam b/research/hackney_research/test/hackney_research_test.gleam new file mode 100644 index 0000000..78f8b12 --- /dev/null +++ b/research/hackney_research/test/hackney_research_test.gleam @@ -0,0 +1,10 @@ +import gleam/io + +pub fn main() { + io.println("Starting hackney research...") + run_all() + io.println("Done.") +} + +@external(erlang, "hackney_bench", "run_all") +fn run_all() -> Nil diff --git a/src/dream/servers/mist/request.gleam b/src/dream/servers/mist/request.gleam index bc4f948..fe63b4b 100644 --- a/src/dream/servers/mist/request.gleam +++ b/src/dream/servers/mist/request.gleam @@ -25,7 +25,7 @@ import gleam/list import gleam/option import gleam/result import gleam/string -import mist.{type Connection, type IpAddress, get_client_info} +import mist.{type Connection, type IpAddress, get_connection_info} /// Generate a simple request ID /// @@ -104,10 +104,10 @@ pub fn convert_metadata( |> option.from_result // Get client info - let client_info = get_client_info(mist_req.body) - let remote_address = case client_info { - Ok(info) -> { - let ip_address: IpAddress = info.ip_address + let connection_info = get_connection_info(mist_req.body) + let remote_address = case connection_info { + Ok(connection_info) -> { + let ip_address: IpAddress = connection_info.ip_address format_ip_address_value(ip_address) |> option.Some } Error(_) -> option.None diff --git a/src/dream/servers/mist/response.gleam b/src/dream/servers/mist/response.gleam index b89bea0..8144bbf 100644 --- a/src/dream/servers/mist/response.gleam +++ b/src/dream/servers/mist/response.gleam @@ -16,7 +16,7 @@ import gleam/list import gleam/option import gleam/string import gleam/yielder -import mist.{type ResponseData, Bytes as MistBytes, Chunked} +import mist.{type ResponseData, Bytes as MistBytes} /// Convert Dream response to Mist response format /// @@ -73,10 +73,8 @@ pub fn convert(dream_resp: Response) -> http_response.Response(ResponseData) { DreamBytes(bytes) -> MistBytes(bytes_tree.from_bit_array(bytes)) Stream(stream) -> { - let byte_stream = - stream - |> yielder.map(bytes_tree.from_bit_array) - Chunked(byte_stream) + let byte_stream = stream |> yielder.map(bytes_tree.from_bit_array) + mist.Streaming(byte_stream) } } diff --git a/src/dream/servers/mist/sse.gleam b/src/dream/servers/mist/sse.gleam index fce3f72..5b035d2 100644 --- a/src/dream/servers/mist/sse.gleam +++ b/src/dream/servers/mist/sse.gleam @@ -95,7 +95,7 @@ import gleam/erlang/process.{type Selector, type Subject} import gleam/http/request as http_request import gleam/http/response as http_response import gleam/list -import gleam/option.{type Option, None, Some} +import gleam/option.{type Option, Some} import gleam/otp/actor import gleam/string_tree import mist.{ @@ -184,13 +184,8 @@ pub fn upgrade_to_sse( internal.unsafe_coerce(raw_request) let wrapped_init = fn(subj: Subject(message)) { - let #(state, maybe_selector) = on_init(subj, dependencies) - let initialised = actor.initialised(state) - let initialised = case maybe_selector { - Some(sel) -> actor.selecting(initialised, sel) - None -> initialised - } - Ok(initialised) + let #(state, _maybe_selector) = on_init(subj, dependencies) + state } let wrapped_loop = fn( diff --git a/test/dream/servers/mist/h2c_test.gleam b/test/dream/servers/mist/h2c_test.gleam new file mode 100644 index 0000000..c0b85a2 --- /dev/null +++ b/test/dream/servers/mist/h2c_test.gleam @@ -0,0 +1,353 @@ +//// Tests for HTTP/2 over cleartext (h2c) through dream's full server stack. +//// +//// Starts a real dream server with routes, makes h2c requests via +//// dream_http_client with Http2Only, and asserts correct responses through +//// the complete pipeline: router dispatch, controller execution, response +//// conversion, and mist HTTP/2 frame handling. + +import dream/http/request.{type Request, Get, Post} +import dream/http/response.{type Response, json_response, text_response} +import dream/router.{route, router} +import dream/servers/mist/server +import dream_http_client/client.{Http2Only, ResponseError} +import dream_test/types.{ + type AssertionResult, AssertionFailed, AssertionFailure, AssertionOk, +} +import dream_test/unit.{type UnitTest, describe, it} +import gleam/erlang/process +import gleam/http +import gleam/list +import gleam/option.{None} +import gleam/string + +// ============================================================================ +// Tests +// ============================================================================ + +pub fn tests() -> UnitTest { + describe("h2c", [ + it("GET returns correct body over h2c", fn() { + use handle <- with_h2c_server(19_980) + + let result = + h2c_request("/h2c/hello", 19_980) + |> client.send() + + server.stop(handle) + + case result { + Ok(resp) -> { + case resp.status == 200 && resp.body == "hello from dream over h2c" { + True -> AssertionOk + False -> + assertion_failed( + "h2c_get", + "Expected status 200 and body 'hello from dream over h2c', got status " + <> string.inspect(resp.status) + <> " body '" + <> resp.body + <> "'", + ) + } + } + Error(err) -> + assertion_failed("h2c_get", "Request failed: " <> string.inspect(err)) + } + }), + it("path params work over h2c", fn() { + use handle <- with_h2c_server(19_981) + + let result = + h2c_request("/h2c/echo/test-value", 19_981) + |> client.send() + + server.stop(handle) + + case result { + Ok(resp) -> { + case resp.status == 200 && string.contains(resp.body, "test-value") { + True -> AssertionOk + False -> + assertion_failed( + "h2c_path_params", + "Expected status 200 with body containing 'test-value', got status " + <> string.inspect(resp.status) + <> " body '" + <> resp.body + <> "'", + ) + } + } + Error(err) -> + assertion_failed( + "h2c_path_params", + "Request failed: " <> string.inspect(err), + ) + } + }), + it("JSON response has correct content-type over h2c", fn() { + use handle <- with_h2c_server(19_982) + + let result = + h2c_request("/h2c/json", 19_982) + |> client.send() + + server.stop(handle) + + case result { + Ok(resp) -> { + let has_json_body = string.contains(resp.body, "\"greeting\"") + let has_json_ct = + list.any(resp.headers, fn(h) { + let client.Header(name, value) = h + string.lowercase(name) == "content-type" + && string.contains(value, "application/json") + }) + + case resp.status == 200 && has_json_body && has_json_ct { + True -> AssertionOk + False -> + assertion_failed( + "h2c_json", + "Expected status 200, JSON body with 'greeting', and application/json content-type. Got status " + <> string.inspect(resp.status) + <> " body '" + <> resp.body + <> "' headers " + <> string.inspect(resp.headers), + ) + } + } + Error(err) -> + assertion_failed( + "h2c_json", + "Request failed: " <> string.inspect(err), + ) + } + }), + it("404 for unknown route over h2c", fn() { + use handle <- with_h2c_server(19_983) + + let result = + h2c_request("/h2c/nonexistent", 19_983) + |> client.send() + + server.stop(handle) + + case result { + Error(ResponseError(response: client.HttpResponse(status: 404, ..))) -> + AssertionOk + Error(ResponseError(response: client.HttpResponse(status: status, ..))) -> + assertion_failed( + "h2c_404", + "Expected status 404, got " <> string.inspect(status), + ) + Ok(resp) -> + assertion_failed( + "h2c_404", + "Expected error response, got Ok with status " + <> string.inspect(resp.status), + ) + Error(err) -> + assertion_failed( + "h2c_404", + "Unexpected error: " <> string.inspect(err), + ) + } + }), + it("POST with body echoes back over h2c", fn() { + use handle <- with_h2c_server(19_984) + + let payload = "h2c-test-payload" + let result = + h2c_request("/h2c/echo-body", 19_984) + |> client.method(http.Post) + |> client.body(payload) + |> client.send() + + server.stop(handle) + + case result { + Ok(resp) -> { + case resp.status == 200 && string.contains(resp.body, payload) { + True -> AssertionOk + False -> + assertion_failed( + "h2c_post_body", + "Expected status 200 with body containing '" + <> payload + <> "', got status " + <> string.inspect(resp.status) + <> " body '" + <> resp.body + <> "'", + ) + } + } + Error(err) -> + assertion_failed( + "h2c_post_body", + "Request failed: " <> string.inspect(err), + ) + } + }), + it("concurrent requests succeed over h2c", fn() { + use handle <- with_h2c_server(19_985) + + let subject1 = process.new_subject() + let subject2 = process.new_subject() + let subject3 = process.new_subject() + + let _pid1 = + process.spawn_unlinked(fn() { + let result = + h2c_request("/h2c/hello", 19_985) + |> client.send() + process.send(subject1, result) + }) + + let _pid2 = + process.spawn_unlinked(fn() { + let result = + h2c_request("/h2c/hello", 19_985) + |> client.send() + process.send(subject2, result) + }) + + let _pid3 = + process.spawn_unlinked(fn() { + let result = + h2c_request("/h2c/hello", 19_985) + |> client.send() + process.send(subject3, result) + }) + + let r1 = process.receive(subject1, 10_000) + let r2 = process.receive(subject2, 10_000) + let r3 = process.receive(subject3, 10_000) + + server.stop(handle) + + case r1, r2, r3 { + Ok(Ok(resp1)), Ok(Ok(resp2)), Ok(Ok(resp3)) -> { + case + resp1.status == 200 + && resp2.status == 200 + && resp3.status == 200 + && resp1.body == "hello from dream over h2c" + && resp2.body == "hello from dream over h2c" + && resp3.body == "hello from dream over h2c" + { + True -> AssertionOk + False -> + assertion_failed( + "h2c_concurrent", + "Not all responses matched. Got: " + <> string.inspect(#(resp1.status, resp2.status, resp3.status)), + ) + } + } + _, _, _ -> + assertion_failed( + "h2c_concurrent", + "One or more concurrent requests failed: " + <> string.inspect(#(r1, r2, r3)), + ) + } + }), + ]) +} + +// ============================================================================ +// Helpers +// ============================================================================ + +fn h2c_request(path: String, port: Int) -> client.ClientRequest { + client.new() + |> client.method(http.Get) + |> client.scheme(http.Http) + |> client.host("localhost") + |> client.port(port) + |> client.path(path) + |> client.protocols(Http2Only) +} + +fn h2c_router() { + router() + |> route( + method: Get, + path: "/h2c/hello", + controller: hello_controller, + middleware: [], + ) + |> route( + method: Get, + path: "/h2c/echo/:value", + controller: echo_controller, + middleware: [], + ) + |> route( + method: Get, + path: "/h2c/json", + controller: json_controller, + middleware: [], + ) + |> route( + method: Post, + path: "/h2c/echo-body", + controller: echo_body_controller, + middleware: [], + ) +} + +fn hello_controller(_request: Request, _context, _services) -> Response { + text_response(200, "hello from dream over h2c") +} + +fn echo_controller(request: Request, _context, _services) -> Response { + case request.get_param(request, "value") { + Ok(param) -> text_response(200, param.value) + Error(msg) -> text_response(400, msg) + } +} + +fn json_controller(_request: Request, _context, _services) -> Response { + json_response(200, "{\"greeting\":\"hello from dream over h2c\"}") +} + +fn echo_body_controller(request: Request, _context, _services) -> Response { + text_response(200, request.body) +} + +fn with_h2c_server( + port: Int, + test_fn: fn(server.ServerHandle) -> AssertionResult, +) -> AssertionResult { + let result = + server.new() + |> server.router(h2c_router()) + |> server.listen_with_handle(port) + + case result { + Ok(handle) -> { + process.sleep(100) + test_fn(handle) + } + Error(start_error) -> + assertion_failed( + "with_h2c_server", + "Failed to start server on port " + <> string.inspect(port) + <> ": " + <> string.inspect(start_error), + ) + } +} + +fn assertion_failed(operator: String, message: String) -> AssertionResult { + AssertionFailed(AssertionFailure( + operator: operator, + message: message, + payload: None, + )) +} diff --git a/test/dream_test.gleam b/test/dream_test.gleam index 3eba57e..0bf429c 100644 --- a/test/dream_test.gleam +++ b/test/dream_test.gleam @@ -13,6 +13,7 @@ import dream/http/status_test import dream/http/validation_test import dream/router/parser_test import dream/router_test +import dream/servers/mist/h2c_test import dream/servers/mist/handler_test import dream/servers/mist/request_test as mist_request_test import dream/servers/mist/response_test as mist_response_test @@ -41,6 +42,7 @@ pub fn main() { unit.to_test_cases("dream/http/validation", validation_test.tests()), unit.to_test_cases("dream/router", router_test.tests()), unit.to_test_cases("dream/router/parser", parser_test.tests()), + unit.to_test_cases("dream/servers/mist/h2c", h2c_test.tests()), unit.to_test_cases("dream/servers/mist/handler", handler_test.tests()), unit.to_test_cases( "dream/servers/mist/request", diff --git a/test/fixtures/hooks.gleam b/test/fixtures/hooks.gleam index f8c715f..e9f21b5 100644 --- a/test/fixtures/hooks.gleam +++ b/test/fixtures/hooks.gleam @@ -45,7 +45,7 @@ pub fn start_server() -> AssertionResult { fn is_server_responding() -> Bool { let result = - client.new + client.new() |> client.scheme(http.Http) |> client.host("127.0.0.1") |> client.port(test_server_port) @@ -54,7 +54,7 @@ fn is_server_responding() -> Bool { case result { Ok(_response) -> True - // Connection refused, timeout, or HTTP error expected during startup polling + Error(client.ResponseError(_)) -> True Error(_error) -> False } } From 5768bb0e5d086fbd0d25d6f25ea7766b471560e5 Mon Sep 17 00:00:00 2001 From: Dara Rockwell Date: Fri, 27 Mar 2026 11:00:46 -0600 Subject: [PATCH 5/7] fix: resolve PUT/POST/PATCH hang with empty body in gun dispatch ## Why This Change Was Made - `send_request` in `dream_http_shim.erl` used dynamic dispatch (`gun:Method/4`) which is inconsistent across HTTP methods. For PUT/POST/PATCH, `gun:put/3` etc. call `headers()` with `nofin`, expecting a follow-up `gun:data/4` that never arrives. For GET, `gun:get/4` treats the 4th arg as `ReqOpts` (not `Body`), crashing with `{badmap, <<>>}`. Both sides wait forever on PUT/POST/PATCH. ## What Was Changed - Replaced `gun:Method/4` dynamic dispatch with `gun:request/5` which always sends `{request, ...}` with body and `fin` flag, regardless of HTTP method - Added `method_atom_to_binary/1` to convert method atoms to uppercase binaries - Added PATCH /patch endpoint to mock server (view, controller, router) - Added 5 regression tests for empty-body PUT, POST, PATCH, GET, DELETE - Updated CHANGELOG with Fixed entry ## Note to Future Engineer - gun's method-specific functions have wildly inconsistent arity semantics: `gun:get/4` takes ReqOpts, `gun:put/4` takes Body. `gun:put/3` sends nofin, `gun:get/3` sends fin. Just use `gun:request/5` and save yourself the therapy. --- modules/http_client/CHANGELOG.md | 7 +++ .../src/dream_http_client/dream_http_shim.erl | 19 +++++- .../http_client/test/empty_body_test.gleam | 60 +++++++++++++++++++ .../controllers/api_controller.gleam | 9 +++ .../src/dream_mock_server/router.gleam | 6 ++ .../dream_mock_server/views/api_view.gleam | 14 +++++ 6 files changed, 112 insertions(+), 3 deletions(-) create mode 100644 modules/http_client/test/empty_body_test.gleam diff --git a/modules/http_client/CHANGELOG.md b/modules/http_client/CHANGELOG.md index decf328..4ce0790 100644 --- a/modules/http_client/CHANGELOG.md +++ b/modules/http_client/CHANGELOG.md @@ -7,6 +7,12 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## 5.2.0 - 2026-03-25 +### Fixed + +- Fixed PUT, POST, and PATCH requests hanging when sent with an empty body. + The underlying gun dispatch now uses `gun:request/5` directly instead of + method-specific functions that behave inconsistently for empty bodies. + ### Changed - **HTTP backend replaced: `httpc` → `gun`.** The underlying HTTP client has @@ -71,6 +77,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 Create with `transport_config()`, configure with builders, apply with `configure_transport()`. Settings are global and affect all subsequent requests. Stored in ETS for concurrent read access. + - **Connection pool manager.** `dream_http_conn_manager` gen_server manages a per-host connection pool backed by an ETS `bag` table. Features round-robin selection, automatic dead-connection cleanup, idle connection reaping, and diff --git a/modules/http_client/src/dream_http_client/dream_http_shim.erl b/modules/http_client/src/dream_http_client/dream_http_shim.erl index 42b2d34..a6f9230 100644 --- a/modules/http_client/src/dream_http_client/dream_http_shim.erl +++ b/modules/http_client/src/dream_http_client/dream_http_shim.erl @@ -577,10 +577,13 @@ build_gun_opts(ConnectTimeoutMs, Protocols) -> _ -> Opts#{protocols => Protocols} end. -send_request(ConnPid, Method, PathQs, Headers, Body) when Body =:= <<>>; Body =:= undefined -> - gun:Method(ConnPid, PathQs, Headers); send_request(ConnPid, Method, PathQs, Headers, Body) -> - gun:Method(ConnPid, PathQs, Headers, Body). + MethodBin = method_atom_to_binary(Method), + ActualBody = case Body of + undefined -> <<>>; + _ -> Body + end, + gun:request(ConnPid, MethodBin, PathQs, Headers, ActualBody). %% ============================================================================ %% URL parsing @@ -722,6 +725,16 @@ to_method_atom(Method) when is_binary(Method) -> Other -> list_to_atom(Other) end. +method_atom_to_binary(get) -> <<"GET">>; +method_atom_to_binary(post) -> <<"POST">>; +method_atom_to_binary(put) -> <<"PUT">>; +method_atom_to_binary(delete) -> <<"DELETE">>; +method_atom_to_binary(patch) -> <<"PATCH">>; +method_atom_to_binary(head) -> <<"HEAD">>; +method_atom_to_binary(options) -> <<"OPTIONS">>; +method_atom_to_binary(Other) -> + list_to_binary(string:to_upper(atom_to_list(Other))). + %% ============================================================================ %% Header conversion %% ============================================================================ diff --git a/modules/http_client/test/empty_body_test.gleam b/modules/http_client/test/empty_body_test.gleam new file mode 100644 index 0000000..fb4799a --- /dev/null +++ b/modules/http_client/test/empty_body_test.gleam @@ -0,0 +1,60 @@ +//// Empty body regression tests +//// +//// Verifies that PUT, POST, PATCH, GET, and DELETE all complete successfully +//// when sent with no request body. Previously, PUT/POST/PATCH hung forever +//// due to gun's inconsistent method-specific dispatch for empty bodies. + +import dream_http_client/client +import dream_http_client_test +import gleam/http +import gleeunit/should + +fn mock_request(method: http.Method, path: String) -> client.ClientRequest { + client.new() + |> client.method(method) + |> client.scheme(http.Http) + |> client.host("localhost") + |> client.port(dream_http_client_test.get_test_port()) + |> client.path(path) + |> client.timeout(5000) +} + +pub fn send_put_empty_body_succeeds_test() { + let req = mock_request(http.Put, "/put") + + let assert Ok(resp) = client.send(req) + + resp.status |> should.equal(200) +} + +pub fn send_post_empty_body_succeeds_test() { + let req = mock_request(http.Post, "/post") + + let assert Ok(resp) = client.send(req) + + resp.status |> should.equal(201) +} + +pub fn send_patch_empty_body_succeeds_test() { + let req = mock_request(http.Patch, "/patch") + + let assert Ok(resp) = client.send(req) + + resp.status |> should.equal(200) +} + +pub fn send_get_empty_body_succeeds_test() { + let req = mock_request(http.Get, "/text") + + let assert Ok(resp) = client.send(req) + + resp.status |> should.equal(200) +} + +pub fn send_delete_empty_body_succeeds_test() { + let req = mock_request(http.Delete, "/delete") + + let assert Ok(resp) = client.send(req) + + resp.status |> should.equal(200) +} diff --git a/modules/mock_server/src/dream_mock_server/controllers/api_controller.gleam b/modules/mock_server/src/dream_mock_server/controllers/api_controller.gleam index 4e5cb76..9b3143f 100644 --- a/modules/mock_server/src/dream_mock_server/controllers/api_controller.gleam +++ b/modules/mock_server/src/dream_mock_server/controllers/api_controller.gleam @@ -46,6 +46,15 @@ pub fn put( json_response(status.ok, api_view.put_to_json(request.path, request.body)) } +/// PATCH /patch - Echoes request body as JSON +pub fn patch( + request: Request, + _context: EmptyContext, + _services: EmptyServices, +) -> Response { + json_response(status.ok, api_view.patch_to_json(request.path, request.body)) +} + /// DELETE /delete - Returns success response pub fn delete( request: Request, diff --git a/modules/mock_server/src/dream_mock_server/router.gleam b/modules/mock_server/src/dream_mock_server/router.gleam index d1f02d5..06a4e77 100644 --- a/modules/mock_server/src/dream_mock_server/router.gleam +++ b/modules/mock_server/src/dream_mock_server/router.gleam @@ -83,6 +83,12 @@ pub fn create_router() -> Router(EmptyContext, EmptyServices) { controller: api_controller.put, middleware: [], ) + |> route( + method: Patch, + path: "/patch", + controller: api_controller.patch, + middleware: [], + ) |> route( method: Delete, path: "/delete", diff --git a/modules/mock_server/src/dream_mock_server/views/api_view.gleam b/modules/mock_server/src/dream_mock_server/views/api_view.gleam index 35f8ac3..a3b43cc 100644 --- a/modules/mock_server/src/dream_mock_server/views/api_view.gleam +++ b/modules/mock_server/src/dream_mock_server/views/api_view.gleam @@ -23,6 +23,12 @@ pub fn put_to_json(path: String, body: String) -> String { |> json.to_string() } +/// Format PATCH request info as JSON string +pub fn patch_to_json(path: String, body: String) -> String { + patch_to_json_object(path, body) + |> json.to_string() +} + /// Format DELETE request info as JSON string pub fn delete_to_json(path: String) -> String { delete_to_json_object(path) @@ -85,6 +91,14 @@ fn put_to_json_object(path: String, body: String) -> json.Json { ]) } +fn patch_to_json_object(path: String, body: String) -> json.Json { + json.object([ + #("method", json.string("PATCH")), + #("url", json.string(path)), + #("data", json.string(body)), + ]) +} + fn delete_to_json_object(path: String) -> json.Json { json.object([ #("method", json.string("DELETE")), From 395187ef1016857057be40c0032dcde427688430 Mon Sep 17 00:00:00 2001 From: Dara Rockwell Date: Fri, 27 Mar 2026 11:03:19 -0600 Subject: [PATCH 6/7] fix: point mist dependency back to git ref from local path ## Why This Change Was Made - During debugging of the HTTP/2 stream actor lifecycle bug, mist was pointed at a local path (../mist) for rapid iteration. Now that the fix is committed and pushed to TrustBound/mist fix/http2-support, the dependency is restored to the git reference. ## What Was Changed - gleam.toml: mist dependency from `path = "../mist"` back to `git = "https://github.com/TrustBound/mist.git", ref = "fix/http2-support"` - manifest.toml: updated automatically by gleam build ## Note to Future Engineer - If you need to iterate on mist locally again, swap the dep back to path. Just don't commit it. We've been through this before. Twice. --- manifest.toml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/manifest.toml b/manifest.toml index 864b897..9ad4007 100644 --- a/manifest.toml +++ b/manifest.toml @@ -33,7 +33,7 @@ packages = [ { name = "logging", version = "1.3.0", build_tools = ["gleam"], requirements = ["gleam_stdlib"], otp_app = "logging", source = "hex", outer_checksum = "1098FBF10B54B44C2C7FDF0B01C1253CAFACDACABEFB4B0D027803246753E06D" }, { name = "marceau", version = "1.3.0", build_tools = ["gleam"], requirements = [], otp_app = "marceau", source = "hex", outer_checksum = "2D1C27504BEF45005F5DFB18591F8610FB4BFA91744878210BDC464412EC44E9" }, { name = "meck", version = "1.0.0", build_tools = ["rebar3"], requirements = [], otp_app = "meck", source = "hex", outer_checksum = "680A9BCFE52764350BEB9FB0335FB75FEE8E7329821416CEE0A19FEC35433882" }, - { name = "mist", version = "6.0.2", build_tools = ["gleam"], requirements = ["exception", "gleam_erlang", "gleam_http", "gleam_otp", "gleam_stdlib", "gleam_yielder", "glisten", "gramps", "hpack_erl", "logging"], source = "git", repo = "https://github.com/TrustBound/mist.git", commit = "ae358876d2d6763e8febb977221797fc42bffcc0" }, + { name = "mist", version = "6.0.2", build_tools = ["gleam"], requirements = ["exception", "gleam_erlang", "gleam_http", "gleam_otp", "gleam_stdlib", "gleam_yielder", "glisten", "gramps", "hpack_erl", "logging"], source = "git", repo = "https://github.com/TrustBound/mist.git", commit = "2ef108a83ad9a53ca909cace4c3d1d1580201739" }, { name = "mockth", version = "2.0.0", build_tools = ["gleam"], requirements = ["gleam_erlang", "gleam_stdlib", "gleeunit", "meck"], source = "git", repo = "https://github.com/bondiano/mockth.git", commit = "bacecbc7cd7ffac806d84154b07360c627d235ec" }, { name = "mug", version = "3.1.0", build_tools = ["gleam"], requirements = ["gleam_erlang", "gleam_stdlib"], otp_app = "mug", source = "hex", outer_checksum = "C01279D98E40371DA23461774B63F0E3581B8F1396049D881B0C7EB32799D93F" }, { name = "non_empty_list", version = "2.3.0", build_tools = ["gleam"], requirements = ["gleam_stdlib"], otp_app = "non_empty_list", source = "hex", outer_checksum = "06F9D3AC751CF7853AD5D24B8139CEB30E42D8799DE8D49F966A6197DF0B01CC" }, From c3ae1d341b0782aa89279151cf9afd6b7731b46e Mon Sep 17 00:00:00 2001 From: Dara Rockwell Date: Sat, 28 Mar 2026 02:04:12 -0600 Subject: [PATCH 7/7] refactor: adapt mist streaming adapter to actor-based chunked API MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## Why This Change Was Made - The updated mist library removed the `mist.Streaming(Yielder(BytesTree))` response variant from `ResponseData`, replacing it with an actor-based `mist.chunked()` API. This broke Dream's build at `src/dream/servers/mist/response.gleam:77` because `mist.Streaming` no longer exists. ## What Was Changed - Extracted header-building logic in `response.gleam` into a shared `build_response_headers` helper and made `set_all_headers`/`add_header` generic over body type - Added `convert_stream` public function that bridges Dream's pull-based `Yielder(BitArray)` into mist's push-based actor pattern via self-messaging (`DrainNext` messages drive iteration with back-pressure from `mist.send_chunk`) - Added `convert_dream_response` dispatcher in `handler.gleam` that routes `Stream` bodies to `convert_stream` and everything else to `convert` - Replaced all 3 `mist_response.convert()` call sites in handler with the new dispatcher - Updated CHANGELOG with the change - manifest.toml updated to latest mist commit and dep bumps ## Note to Future Engineer - The `init` closure in `convert_stream` captures the yielder — this is required by mist's `chunked()` API signature and falls under the third-party closure exception. The `loop` function is extracted as a named top-level function to minimize closure usage. - If you call `convert()` directly with a `Stream` body it will panic. That's intentional — the handler MUST route Stream bodies through `convert_stream` which needs the mist request for the connection. Congratulations on finding this comment instead of the panic message. --- CHANGELOG.md | 7 ++ manifest.toml | 6 +- src/dream/servers/mist/handler.gleam | 19 +++- src/dream/servers/mist/response.gleam | 129 +++++++++++++++++++------- 4 files changed, 123 insertions(+), 38 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 4ab7bf5..9928702 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,13 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] +### Changed + +- Updated mist streaming adapter to use actor-based `mist.chunked()` API + instead of the removed `mist.Streaming` response variant. Dream's public + streaming API (`stream_response`, `sse_response`, `ResponseBody.Stream`) + is unchanged. + ## [2.4.1] - 2026-03-11 ### Fixed diff --git a/manifest.toml b/manifest.toml index 9ad4007..5c7f2c3 100644 --- a/manifest.toml +++ b/manifest.toml @@ -21,7 +21,7 @@ packages = [ { name = "gleam_otp", version = "1.2.0", build_tools = ["gleam"], requirements = ["gleam_erlang", "gleam_stdlib"], otp_app = "gleam_otp", source = "hex", outer_checksum = "BA6A294E295E428EC1562DC1C11EA7530DCB981E8359134BEABC8493B7B2258E" }, { name = "gleam_regexp", version = "1.1.1", build_tools = ["gleam"], requirements = ["gleam_stdlib"], otp_app = "gleam_regexp", source = "hex", outer_checksum = "9C215C6CA84A5B35BB934A9B61A9A306EC743153BE2B0425A0D032E477B062A9" }, { name = "gleam_stdlib", version = "0.70.0", build_tools = ["gleam"], requirements = [], otp_app = "gleam_stdlib", source = "hex", outer_checksum = "86949BF5D1F0E4AC0AB5B06F235D8A5CC11A2DFC33BF22F752156ED61CA7D0FF" }, - { name = "gleam_time", version = "1.7.0", build_tools = ["gleam"], requirements = ["gleam_stdlib"], otp_app = "gleam_time", source = "hex", outer_checksum = "56DB0EF9433826D3B99DB0B4AF7A2BFED13D09755EC64B1DAAB46F804A9AD47D" }, + { name = "gleam_time", version = "1.8.0", build_tools = ["gleam"], requirements = ["gleam_stdlib"], otp_app = "gleam_time", source = "hex", outer_checksum = "533D8723774D61AD4998324F5DD1DABDCDBFABAFB9E87CB5D03C6955448FC97D" }, { name = "gleam_yielder", version = "1.1.0", build_tools = ["gleam"], requirements = ["gleam_stdlib"], otp_app = "gleam_yielder", source = "hex", outer_checksum = "8E4E4ECFA7982859F430C57F549200C7749823C106759F4A19A78AEA6687717A" }, { name = "gleeunit", version = "1.9.0", build_tools = ["gleam"], requirements = ["gleam_stdlib"], otp_app = "gleeunit", source = "hex", outer_checksum = "DA9553CE58B67924B3C631F96FE3370C49EB6D6DC6B384EC4862CC4AAA718F3C" }, { name = "glexer", version = "2.3.1", build_tools = ["gleam"], requirements = ["gleam_stdlib", "splitter"], otp_app = "glexer", source = "hex", outer_checksum = "41D8D2E855AEA87ADC94B7AF26A5FEA3C90268D4CF2CCBBD64FD6863714EE085" }, @@ -33,7 +33,7 @@ packages = [ { name = "logging", version = "1.3.0", build_tools = ["gleam"], requirements = ["gleam_stdlib"], otp_app = "logging", source = "hex", outer_checksum = "1098FBF10B54B44C2C7FDF0B01C1253CAFACDACABEFB4B0D027803246753E06D" }, { name = "marceau", version = "1.3.0", build_tools = ["gleam"], requirements = [], otp_app = "marceau", source = "hex", outer_checksum = "2D1C27504BEF45005F5DFB18591F8610FB4BFA91744878210BDC464412EC44E9" }, { name = "meck", version = "1.0.0", build_tools = ["rebar3"], requirements = [], otp_app = "meck", source = "hex", outer_checksum = "680A9BCFE52764350BEB9FB0335FB75FEE8E7329821416CEE0A19FEC35433882" }, - { name = "mist", version = "6.0.2", build_tools = ["gleam"], requirements = ["exception", "gleam_erlang", "gleam_http", "gleam_otp", "gleam_stdlib", "gleam_yielder", "glisten", "gramps", "hpack_erl", "logging"], source = "git", repo = "https://github.com/TrustBound/mist.git", commit = "2ef108a83ad9a53ca909cace4c3d1d1580201739" }, + { name = "mist", version = "6.0.2", build_tools = ["gleam"], requirements = ["exception", "gleam_erlang", "gleam_http", "gleam_otp", "gleam_stdlib", "glisten", "gramps", "hpack_erl", "logging"], source = "git", repo = "https://github.com/TrustBound/mist.git", commit = "4335c5602f72f9c0366dd256cd1ada8ed7ce2c27" }, { name = "mockth", version = "2.0.0", build_tools = ["gleam"], requirements = ["gleam_erlang", "gleam_stdlib", "gleeunit", "meck"], source = "git", repo = "https://github.com/bondiano/mockth.git", commit = "bacecbc7cd7ffac806d84154b07360c627d235ec" }, { name = "mug", version = "3.1.0", build_tools = ["gleam"], requirements = ["gleam_erlang", "gleam_stdlib"], otp_app = "mug", source = "hex", outer_checksum = "C01279D98E40371DA23461774B63F0E3581B8F1396049D881B0C7EB32799D93F" }, { name = "non_empty_list", version = "2.3.0", build_tools = ["gleam"], requirements = ["gleam_stdlib"], otp_app = "non_empty_list", source = "hex", outer_checksum = "06F9D3AC751CF7853AD5D24B8139CEB30E42D8799DE8D49F966A6197DF0B01CC" }, @@ -45,7 +45,7 @@ packages = [ { name = "splitter", version = "1.2.0", build_tools = ["gleam"], requirements = ["gleam_stdlib"], otp_app = "splitter", source = "hex", outer_checksum = "3DFD6B6C49E61EDAF6F7B27A42054A17CFF6CA2135FF553D0CB61C234D281DD0" }, { name = "squirrel", version = "4.6.0", build_tools = ["gleam"], requirements = ["argv", "envoy", "eval", "filepath", "glam", "gleam_community_ansi", "gleam_crypto", "gleam_json", "gleam_regexp", "gleam_stdlib", "gleam_time", "glexer", "justin", "mug", "non_empty_list", "pog", "simplifile", "term_size", "tom", "tote", "youid"], otp_app = "squirrel", source = "hex", outer_checksum = "0ED10A868BDD1A5D4B68D99CD1C72DC3F23C6E36E16D33454C5F0C31BAC9CB1E" }, { name = "term_size", version = "1.0.1", build_tools = ["gleam"], requirements = ["gleam_stdlib"], otp_app = "term_size", source = "hex", outer_checksum = "D00BD2BC8FB3EBB7E6AE076F3F1FF2AC9D5ED1805F004D0896C784D06C6645F1" }, - { name = "tom", version = "2.0.1", build_tools = ["gleam"], requirements = ["gleam_stdlib", "gleam_time"], otp_app = "tom", source = "hex", outer_checksum = "90791DA4AACE637E30081FE77049B8DB850FBC8CACC31515376BCC4E59BE1DD2" }, + { name = "tom", version = "2.0.2", build_tools = ["gleam"], requirements = ["gleam_stdlib", "gleam_time"], otp_app = "tom", source = "hex", outer_checksum = "234A842F3D087D35737483F5DFB6DE9839E3366EF0CAF8726D2D094210227670" }, { name = "tote", version = "1.0.2", build_tools = ["gleam"], requirements = ["gleam_stdlib"], otp_app = "tote", source = "hex", outer_checksum = "A249892E26A53C668897F8D47845B0007EEE07707A1A03437487F0CD5A452CA5" }, { name = "youid", version = "1.5.1", build_tools = ["gleam"], requirements = ["gleam_crypto", "gleam_stdlib", "gleam_time"], otp_app = "youid", source = "hex", outer_checksum = "580E909FD704DB16416D5CB080618EDC2DA0F1BE4D21B490C0683335E3FFC5AF" }, ] diff --git a/src/dream/servers/mist/handler.gleam b/src/dream/servers/mist/handler.gleam index e2e7106..6a150a8 100644 --- a/src/dream/servers/mist/handler.gleam +++ b/src/dream/servers/mist/handler.gleam @@ -1,7 +1,7 @@ import dream/dream import dream/http/header.{Header} import dream/http/request.{type Request, Request} -import dream/http/response.{type Response, Response, Text} +import dream/http/response.{type Response, Response, Stream, Text} import dream/router.{type Route, type Router, find_route} import dream/servers/mist/internal import dream/servers/mist/request as mist_request @@ -114,7 +114,7 @@ fn create_request_handler( cookies: [], content_type: option.Some("text/plain; charset=utf-8"), ) - mist_response.convert(dream_response) + convert_dream_response(mist_request, dream_response) } } @@ -167,9 +167,9 @@ fn handle_routed_request( option.Some(perform_upgrade) -> case dream_response.status { 200 -> perform_upgrade(extract_dream_headers(dream_response)) - _ -> mist_response.convert(dream_response) + _ -> convert_dream_response(mist_request, dream_response) } - option.None -> mist_response.convert(dream_response) + option.None -> convert_dream_response(mist_request, dream_response) } } Error(response) -> response @@ -215,6 +215,17 @@ fn prepare_buffered_request( } } +fn convert_dream_response( + mist_request: http_request.Request(Connection), + dream_response: Response, +) -> http_response.Response(ResponseData) { + case dream_response.body { + Stream(stream) -> + mist_response.convert_stream(mist_request, dream_response, stream) + _ -> mist_response.convert(dream_response) + } +} + fn extract_dream_headers(response: Response) -> List(#(String, String)) { list.map(response.headers, fn(h) { let Header(name, value) = h diff --git a/src/dream/servers/mist/response.gleam b/src/dream/servers/mist/response.gleam index 8144bbf..543aabf 100644 --- a/src/dream/servers/mist/response.gleam +++ b/src/dream/servers/mist/response.gleam @@ -10,13 +10,15 @@ import dream/http/cookie.{type Cookie, Cookie, Lax, None, Strict} import dream/http/header.{type Header, header_name, header_value} import dream/http/response.{type Response, Bytes as DreamBytes, Stream, Text} import gleam/bytes_tree +import gleam/erlang/process +import gleam/http/request as http_request import gleam/http/response as http_response import gleam/int import gleam/list import gleam/option import gleam/string import gleam/yielder -import mist.{type ResponseData, Bytes as MistBytes} +import mist.{type Connection, type ResponseData, Bytes as MistBytes} /// Convert Dream response to Mist response format /// @@ -50,39 +52,56 @@ import mist.{type ResponseData, Bytes as MistBytes} /// // Mist server sends mist_resp to client /// ``` pub fn convert(dream_resp: Response) -> http_response.Response(ResponseData) { - // Status is now a plain Int - let status_code = dream_resp.status + let headers = build_response_headers(dream_resp) - // Convert headers - let headers = list.map(dream_resp.headers, convert_header_to_tuple) - - // Add cookie headers - let headers_with_cookies = - list.fold(dream_resp.cookies, headers, add_cookie_header) - - // Add content-type header if present - let headers_with_content_type = case dream_resp.content_type { - option.Some(ct) -> list.key_set(headers_with_cookies, "content-type", ct) - option.None -> headers_with_cookies - } - - // Convert body based on ResponseBody variant let response_data = case dream_resp.body { Text(text) -> MistBytes(bytes_tree.from_string(text)) - DreamBytes(bytes) -> MistBytes(bytes_tree.from_bit_array(bytes)) - - Stream(stream) -> { - let byte_stream = stream |> yielder.map(bytes_tree.from_bit_array) - mist.Streaming(byte_stream) - } + Stream(_stream) -> + panic as "Stream bodies must use convert_stream with mist request" } - let resp_with_body = - http_response.new(status_code) - |> http_response.set_body(response_data) + http_response.new(dream_resp.status) + |> http_response.set_body(response_data) + |> set_all_headers(headers) +} - set_all_headers(headers_with_content_type, resp_with_body) +/// Convert a Dream streaming response to Mist chunked response +/// +/// Uses mist's actor-based chunked API to drain a Dream yielder, +/// sending each element as an HTTP chunk. Requires the original mist +/// request for connection access. +/// +/// ## Example +/// +/// ```gleam +/// // Internal use - called by the handler when body is Stream +/// let mist_resp = convert_stream(mist_request, dream_resp, stream) +/// ``` +pub fn convert_stream( + mist_request: http_request.Request(Connection), + dream_response: Response, + stream: yielder.Yielder(BitArray), +) -> http_response.Response(ResponseData) { + let headers = build_response_headers(dream_response) + + let base_response = + http_response.Response( + status: dream_response.status, + headers: [], + body: Nil, + ) + |> set_all_headers(headers) + + mist.chunked( + request: mist_request, + response: base_response, + init: fn(subject) { + process.send(subject, DrainNext) + DrainState(remaining: stream, subject: subject) + }, + loop: drain_yielder_loop, + ) } fn convert_header_to_tuple(header: Header) -> #(String, String) { @@ -97,10 +116,20 @@ fn add_cookie_header( [#("set-cookie", cookie_header), ..acc] } +fn build_response_headers(dream_resp: Response) -> List(#(String, String)) { + let headers = list.map(dream_resp.headers, convert_header_to_tuple) + let headers_with_cookies = + list.fold(dream_resp.cookies, headers, add_cookie_header) + case dream_resp.content_type { + option.Some(ct) -> list.key_set(headers_with_cookies, "content-type", ct) + option.None -> headers_with_cookies + } +} + fn add_header( - acc: http_response.Response(ResponseData), + acc: http_response.Response(body), header: #(String, String), -) -> http_response.Response(ResponseData) { +) -> http_response.Response(body) { case header.0 { "set-cookie" -> http_response.prepend_header(acc, header.0, header.1) _ -> http_response.set_header(acc, header.0, header.1) @@ -108,12 +137,50 @@ fn add_header( } fn set_all_headers( + resp: http_response.Response(body), headers: List(#(String, String)), - resp: http_response.Response(ResponseData), -) -> http_response.Response(ResponseData) { +) -> http_response.Response(body) { list.fold(headers, resp, add_header) } +type DrainMessage { + DrainNext +} + +type DrainState { + DrainState( + remaining: yielder.Yielder(BitArray), + subject: process.Subject(DrainMessage), + ) +} + +fn drain_yielder_loop( + state: DrainState, + _message: DrainMessage, + connection: Connection, +) -> mist.ChunkNext(DrainState) { + case yielder.step(state.remaining) { + yielder.Next(chunk, rest) -> + send_chunk_and_continue(state, chunk, rest, connection) + yielder.Done -> mist.ChunkStop + } +} + +fn send_chunk_and_continue( + state: DrainState, + chunk: BitArray, + rest: yielder.Yielder(BitArray), + connection: Connection, +) -> mist.ChunkNext(DrainState) { + case mist.send_chunk(connection, chunk) { + Ok(Nil) -> { + process.send(state.subject, DrainNext) + mist.ChunkContinue(DrainState(remaining: rest, subject: state.subject)) + } + Error(Nil) -> mist.ChunkStop + } +} + /// Format a cookie for the Set-Cookie header fn format_cookie_header(cookie: Cookie) -> String { let Cookie(