HTTP Fetch

View Source

Elixir CI Elixir CI Hex.pm Hexdocs.pm Hex.pm Hex.pm

A modern HTTP client library for Elixir that provides a fetch API similar to web browsers, built on Erlang's built-in socket modules.

For development, prepare the umbrella before running a scoped app test: MIX_ENV=test mix deps.get && MIX_ENV=test mix compile --warnings-as-errors followed by MIX_ENV=test mix test apps/http_fetch/test. Running the test from the root keeps runtime applications of in_umbrella dependencies, including ex_ssl, on the code path without adding duplicate child dependencies.

Features

  • Browser-like API: Familiar fetch interface with promises and async/await patterns
  • Full HTTP support: GET, POST, PUT, DELETE, PATCH, HEAD methods
  • Internal HTTP/1.1 transport: Uses :gen_tcp for HTTP, selectable TLS for HTTPS, and Unix domain sockets
  • Unix Domain Sockets: HTTP over Unix sockets for Docker daemon, systemd, and other local services
  • Form data support: HTTP.FormData for multipart/form-data and file uploads
  • Streaming request bodies: Fetch-style duplex: "half" uploads over HTTP/1.1 and HTTP/2
  • Type-safe configuration: HTTP.FetchOptions for structured request configuration
  • Promise-based: Async operations with chaining support
  • Request cancellation: AbortController support for cancelling requests
  • Automatic JSON parsing: Built-in JSON response handling
  • Selectable TLS: OTP :ssl by default, with opt-in :ex_ssl for verified TLS 1.3
  • HTTP/2 wire profiles: Versioned native and synthetic profiles can control ordered SETTINGS and header serialization; use http2_profile only with an explicit HTTP/2 or h2c request.

Explicit HTTP/2 and h2c prior knowledge use supervised pooled connections with multiplexing, flow-controlled binary/streaming uploads and bounded download buffers. The default HTTP version remains HTTP/1. Profiles control ordered SETTINGS, header serialization and supported priority behavior on cold and warm connections. Available IDs are native_v1, synthetic_test_v1, and synthetic_test_v2; revision 2 explicitly disables server push. Synthetic profiles do not claim browser fingerprint equivalence. Use HTTP.HTTP2.ProfileCapture.build_manifest/2 for capture provenance.

See the Fetch validation record and stream-client validation record for executed gates, candidate provenance, resource budgets and phase status. These records distinguish local acceptance, remote CI, publication and rollout.

Browser Fetch API Compatibility

This library implements the Browser Fetch API standard for Elixir with ~85% compatibility. All critical Response properties and methods from the JavaScript Fetch API are supported.

Response Properties

response = HTTP.fetch("https://api.example.com/data") |> HTTP.Promise.await()

# Standard Browser Fetch API properties
response.status        # 200
response.status_text   # "OK"
response.ok            # true (for 200-299 status codes)
response.headers       # HTTP.Headers struct
response.body          # Response body binary, or stream PID for streamed responses
response.body_used     # false (tracks consumption, but doesn't prevent reads in Elixir)
response.redirected    # false (true if response was redirected)
response.type          # :basic
response.url           # URI struct

Response Methods

# Read as JSON
{:ok, data} = HTTP.Response.json(response)

# Read as text
text = HTTP.Response.text(response)

# Read as binary (ArrayBuffer equivalent)
binary = HTTP.Response.arrayBuffer(response)

# Read as Blob with metadata
blob = HTTP.Response.blob(response)
IO.puts "Type: #{blob.type}, Size: #{blob.size} bytes"

# Clone for multiple reads
clone = HTTP.Response.clone(response)
json = HTTP.Response.json(response)
text = HTTP.Response.text(clone)  # Read clone independently

Elixir-Specific Differences

Immutability: Unlike JavaScript, Elixir responses are immutable. The body_used field exists for API compatibility but doesn't prevent multiple reads of the same response value. Use clone/1 for clarity when reading multiple times.

Synchronous Returns: Methods like json() and text() return values directly instead of Promises, following Elixir conventions.

Stream Handling: Large responses expose an Elixir stream process in response.body instead of a JavaScript ReadableStream. The legacy response.stream field is kept as an alias for streamed responses.

Quick Start

# Simple GET request
response =
  HTTP.fetch("https://jsonplaceholder.typicode.com/posts/1")
  |> HTTP.Promise.await()

# Use Browser-like API
IO.puts("Status: #{response.status} #{response.status_text}")
IO.puts("Success: #{response.ok}")
text = HTTP.Response.text(response)
{:ok, json} = HTTP.Response.json(response)

# Read response body as raw binary
response =
  HTTP.fetch("https://jsonplaceholder.typicode.com/posts/1")
  |> HTTP.Promise.await()

# For buffered responses, response.body contains the raw binary data.
# For streamed responses, use HTTP.Response.read_all/1 or write_to/2.
binary_data = HTTP.Response.read_all(response)

# POST request with JSON
response =
  HTTP.fetch("https://jsonplaceholder.typicode.com/posts", [
    method: "POST",
    headers: %{"Content-Type" => "application/json"},
    body: JSON.encode\!(%{title: "Hello", body: "World"})
  ])
  |> HTTP.Promise.await()

# Unix Domain Socket request (Docker daemon example)
response =
  HTTP.fetch("http://localhost/version",
    unix_socket: "/var/run/docker.sock")
  |> HTTP.Promise.await()

# Parse Docker version info
{:ok, docker_info} = HTTP.Response.json(response)
IO.puts("Docker Version: #{docker_info["Version"]}")

TLS Backend Selection

HTTPS fetch (HTTP/1.1 and HTTP/2), secure WebSocket, and HTTPS EventSource share one TLS default. OTP :ssl remains the default when no configuration is set:

# config/config.exs or config/runtime.exs
config :http_core, tls_backend: :ex_ssl

A flat per-call option overrides that default:

HTTP.fetch("https://example.com", tls_backend: :ssl)

HTTP.fetch("https://example.com",
  tls_backend: :ex_ssl,
  ssl: [cacertfile: "/path/to/ca.pem"]
)

HTTP.WebSocket.new("wss://example.com/socket", [], tls_backend: :ex_ssl)
HTTP.EventSource.new("https://example.com/events", tls_backend: :ex_ssl)

tls_backend accepts :ssl, :ex_ssl, "ssl", or "ex_ssl". Maps also accept "tls_backend" and "tlsBackend" keys. Omitted or nil values inherit the shared configuration. The backend is captured when the request/client is created and retained through redirects and EventSource reconnects; runtime configuration changes affect new operations. Invalid selections fail explicitly.

http_core declares ex_ssl ~> 0.7.2 as a transitive runtime dependency. Consumers do not need to add it separately. ssl: [...] supplies TLS settings to the selected backend. The ex_ssl backend uses its own SSL protocol engine and requires peer verification. TLS 1.3 is the default; verified TLS 1.2 is explicitly selectable. It uses system CA certificates unless cacerts or cacertfile is supplied. DNS names and IP addresses are verified against the peer certificate. verify: :verify_none and unsupported TLS or TCP options return errors; connections never fall back to another backend automatically.

A complete HTTP/2 response remains deliverable if the peer closes before the client can write its remaining WINDOW_UPDATE or acknowledgement frames. This also covers responses buffered across multiple TLS records by ex_ssl after a normal peer shutdown: the client drains the receive side before deciding whether the response completed. Only :closed on optional control writes qualifies. A complete early response (such as 413) stops the remaining upload, including request DATA queued by WINDOW_UPDATE in the same batch. It also survives a subsequent RST_STREAM(NO_ERROR), as required by RFC 9113 §8.1. Completion requires END_STREAM and the complete HEADERS/CONTINUATION field block; an unfinished upload neither proves nor prevents response completion. HTTP/2 also validates Content-Length against unpadded DATA bytes before completion, rejects body overruns immediately, and reports mismatches as :content_length_mismatch. Valid HEAD/304 representation lengths do not require a body. Malformed/conflicting lengths, values longer than 20 decimal digits, and values outside the unsigned 64-bit bound return :invalid_content_length. Inbound frames are limited to the advertised 16,384-byte payload size and compressed header blocks to 65,536 bytes, including CONTINUATION fragments. Content-Length is forbidden on informational/204 responses and in trailers; DATA or HEADERS after END_STREAM is rejected rather than completed again. Truncation, required writes before completion, abnormal closure, cancellation and timeout remain errors. The original deadline and streaming backpressure are preserved.

For :ex_ssl, socket_opts accepts send_timeout, send_timeout_close: true, nodelay, keepalive, sndbuf, recbuf, and local ip/port. The adapter forwards only this allowlist and ex_ssl validates values. IPv6 literals infer the family; an IPv6 local ip tuple selects IPv6 DNS resolution. Both option containers must be keyword lists. Socket options override matching entries in ssl. Custom ClientHello profiles can be passed through ssl: [ex_ssl: [profile: profile]]; any ALPN list added by HTTP/2 selection must match the profile's ALPN list exactly. Configured ex_ssl client credentials stay within the initial request origin during automatic redirects. A scheme, hostname or effective-port change returns {:error, :client_identity_cross_origin_redirect}. To authorize another origin, use redirect: :manual and explicitly make a new request with that identity. The OTP backend retains its existing redirect behavior.

ex_ssl 0.5.0 supports verified TLS 1.2 for HTTP/1.1, HTTP/2, WSS and EventSource. Select it with ssl: [versions: [:"tlsv1.2"]]; a mixed TLS 1.3/TLS 1.2 offer selects the peer's supported version. The independent OpenSSL package gate includes 262,144-byte HTTP/2 responses with observed connection and stream WINDOW_UPDATE frames. The OTP default is unchanged.

TLS 1.3 session resumption is explicit: ssl: [versions: [:"tlsv1.3"], session_tickets: :auto]. Tickets are disabled by default. Auto mode currently rejects client identities and mixed/TLS 1.2 version offers; early data and PSK-only exchange are unsupported. When a server declines a ticket, a full handshake continues on the same connection without replaying request bytes. The published feature gate checks fresh HTTP/1.1, HTTP/2, WSS and EventSource connections against an independent OpenSSL peer; the peer must report a full handshake followed by a resumed handshake. HTTP version selection (http_version: :http2) and TLS version selection (ssl: [versions: [:"tlsv1.3"]]) are independent. Fetch supports streaming request bodies over HTTP/1.1 and HTTP/2; HTTP/2 uses the bounded upload bridge and preserves early-response upload cleanup. This is a bounded subset, not full OTP :ssl parity.

See the ex_ssl compatibility contract. The consumer contract inventory maps the implemented subset and intentional restrictions to its tests.

Plain HTTP, WS, and Unix sockets retain their existing transports. HTTP/3 and WebTransport use QUIC's separate TLS implementation and ignore the shared setting. An explicit non-nil tls_backend on either QUIC API returns {:error, :tls_backend_not_supported_for_quic} (through the promise for fetch).

Form Data With File Upload

file_stream = File.stream!("document.pdf")
form = HTTP.FormData.new()
       |> HTTP.FormData.append_field("name", "John Doe")
       |> HTTP.FormData.append_file("document", "document.pdf", file_stream)

response =
  HTTP.fetch("https://api.example.com/upload", [
    method: "POST",
    body: form
  ])
  |> HTTP.Promise.await()

Streaming Request Body

{:ok, stream} = HTTP.Stream.from_enumerable(["chunk one", "chunk two"])

response =
  HTTP.fetch("https://api.example.com/upload", [
    method: "POST",
    body: stream,
    duplex: "half",
    content_type: "text/plain"
  ])
  |> HTTP.Promise.await()

WebSocket Client

The umbrella also includes HTTP.WebSocket, a browser-like WebSocket client. It returns a socket immediately, then delivers open, message, error, and close events to the owner process.

socket = HTTP.WebSocket.new("wss://example.com/socket", ["chat.v1"])

receive do
  {HTTP.WebSocket, ^socket, %HTTP.WebSocket.Event.Open{}} ->
    :ok = HTTP.WebSocket.send(socket, "hello")

  {HTTP.WebSocket, ^socket, %HTTP.WebSocket.Event.Message{data: data}} ->
    IO.inspect(data, label: "message")

  {HTTP.WebSocket, ^socket, %HTTP.WebSocket.Event.Close{code: code, reason: reason}} ->
    IO.inspect({code, reason}, label: "closed")
end

Browser-compatible accessors are exposed with Elixir naming:

HTTP.WebSocket.ready_state(socket)
HTTP.WebSocket.buffered_amount(socket)
HTTP.WebSocket.protocol(socket)
HTTP.WebSocket.extensions(socket)
HTTP.WebSocket.binary_type(socket)
HTTP.WebSocket.url(socket)
HTTP.WebSocket.http_version(socket) # :http1, :http2, or nil while opening
HTTP.WebSocket.status(socket)

Plain Elixir binaries are sent as text frames. Use HTTP.WebSocket.array_buffer/1 or HTTP.Blob for binary frames:

:ok = HTTP.WebSocket.send(socket, "text")
:ok = HTTP.WebSocket.send(socket, HTTP.WebSocket.array_buffer(<<0, 1, 2>>))
:ok = HTTP.WebSocket.send(socket, HTTP.Blob.new(<<0, 1, 2>>))
:ok = HTTP.WebSocket.close(socket, 1000, "done")

HTTP/1 remains the default. Select http_version: :http2 for TLS h2 with RFC 8441 peer permission, or :h2c for cleartext prior knowledge. :auto on WSS permits one separate HTTP/1 connection before establishment when ALPN or peer capability is unavailable. Authentication, certificate, malformed-handshake and established session failures do not trigger fallback or message replay. Cleartext :auto uses HTTP/1. Explicit profiles require their H2 wire identity; contradictory ALPN and H2 Unix-socket options are rejected before networking. With WSS :auto, a custom http2_scope or http2_reuse: false requires an explicit H2 profile; otherwise construction returns {:error, :http2_options_require_http2}.

socket = HTTP.WebSocket.new("wss://example.com/socket", [],
  http_version: :http2, delivery: :ack, tls_backend: :ssl)

receive do
  {HTTP.WebSocket, ^socket, %HTTP.WebSocket.Event.Message{data: data}, ref} ->
    consume(data)
    :ok = HTTP.WebSocket.acknowledge(socket, ref)
end

ACK delivery charges queued and in-flight messages against max_queue_bytes and max_queue_events (default 64). Legacy delivery keeps its original envelope and uses a finite internal queue with terminal slow-owner overload; it cannot bound unrelated messages in the owner's mailbox. Frames and assembled messages default to 16 MiB, with at most 16,384 fragment parts. Outbound admission allows 64 pending application frames and 16 control frames. buffered_amount/1 counts unsent application payload bytes, excluding masked frame headers; admission can return :send_queue_full under pressure. Closing finishes the current frame, discards queued application frames, and prioritizes the Close frame. A missing peer Close is abnormal, including HTTP/2 END_STREAM without a WebSocket Close.

opening_timeout defaults to the legacy timeout; idle_timeout defaults to :infinity and pauses during local ACK pressure; close_timeout defaults to 5,000 ms. An H2 client closes only its logical stream. Compatible TLS, profile, scope and connection options allow Fetch, SSE and WS to share the same runtime owner. See validation for acceptance commands and measured resource limits.

Elixir differences from the browser API: invalid constructor input returns {:error, reason} instead of raising a DOM exception, and events are process messages instead of EventTarget callbacks.

EventSource Client

The umbrella includes HTTP.EventSource, a browser-like Server-Sent Events client. It returns an event source immediately, then delivers open, message, custom message-type, and error events to the owner process.

source = HTTP.EventSource.new("https://example.com/events")

receive do
  {HTTP.EventSource, ^source, %HTTP.EventSource.Event.Open{}} ->
    IO.puts("connected")

  {HTTP.EventSource, ^source, %HTTP.EventSource.Event.Message{data: data}} ->
    IO.inspect(data, label: "event")

  {HTTP.EventSource, ^source, %HTTP.EventSource.Event.Error{reason: reason}} ->
    IO.inspect(reason, label: "stream error")
end

Browser-compatible accessors are exposed with Elixir naming:

HTTP.EventSource.ready_state(source)
HTTP.EventSource.with_credentials(source)
HTTP.EventSource.url(source)
HTTP.EventSource.http_version(source) # :http1, :http2, or nil while opening
HTTP.EventSource.status(source)
HTTP.EventSource.close(source)

The client reconnects after dropped streams, honors retry: fields, and sends Last-Event-ID after receiving event IDs. Elixir differences from the browser API: invalid constructor input returns {:error, reason}, and events are process messages instead of EventTarget callbacks.

HTTP/1 remains the default. Select http_version: :http2 for required TLS h2, :h2c for cleartext prior knowledge, or :auto for TLS ALPN negotiation. HTTP/2 uses the shared runtime and can share an eligible connection with Fetch. An explicit http2_profile requires h2; http2_scope and http2_reuse retain the same isolation rules. With TLS :auto, a custom scope or http2_reuse: false requires an explicit H2 profile; otherwise construction returns {:error, :http2_options_require_http2}. OTP :ssl remains the default TLS backend.

For bounded consumer delivery, use opaque acknowledgements:

source = HTTP.EventSource.new("https://example.com/events",
  http_version: :http2, delivery: :ack)

receive do
  {HTTP.EventSource, ^source, %HTTP.EventSource.Event.Message{data: data}, ref} ->
    IO.inspect(data)
    :ok = HTTP.EventSource.acknowledge(source, ref)
end

The default parser caps a line at 64 KiB, an assembled event at 1 MiB, and its parts at 16,384. The acknowledged FIFO includes its in-flight message and defaults to 2 MiB/64 events; its byte limit must fit max_event_size + 7. Raw input reserves the advertised receive window in addition to a 1 MiB admitted-byte budget and has a 128-chunk limit. Legacy delivery keeps its original envelope and permanently stops on conservative owner-mailbox overload. Invalid responses/UTF-8/oversized events are fatal; 204 permanently stops; ordinary EOF/reset may reconnect. Accepted acknowledged deliveries drain before terminal errors. The cursor advances on parsing rather than application acknowledgement, so reconnects can replay.

[:http_runtime, :stream, :open | :queue | :reconnect | :error | :close | :fallback] events add bounded client/version/outcome labels and numeric queue/raw counters. Established idle timeout defaults to infinity and pauses for local acknowledged backpressure. See stream-client validation for independent peers, resource budgets and current phase status.

API Reference

HTTP.fetch/2

Performs an HTTP request and returns a Promise.

promise = HTTP.fetch(url, [
  method: "GET",
  headers: %{"Accept" => "application/json"},
  body: "request body",
  content_type: "application/json",
  redirect: :manual,
  timeout: 10_000,
  signal: abort_controller,
  unix_socket: "/var/run/docker.sock"  # Optional: use Unix Domain Socket
])

Set duplex: "half" only when body is an HTTP.Stream PID.

Supports both string URLs and URI structs:

# String URL
promise = HTTP.fetch("https://api.example.com/data")

# URI struct
uri = URI.parse("https://api.example.com/data")
promise = HTTP.fetch(uri)

HTTP.Promise

Asynchronous promise wrapper for HTTP requests.

response = HTTP.Promise.await(promise)

# Promise chaining
HTTP.fetch("https://api.example.com/data")
|> HTTP.Promise.then(fn response -> HTTP.Response.json(response) end)
|> HTTP.Promise.await()

HTTP.Response

Represents an HTTP response.

text = HTTP.Response.text(response)
{:ok, json} = HTTP.Response.json(response)

# Access raw response body as binary
response =
  HTTP.fetch("https://api.example.com/large-file")
  |> HTTP.Promise.await()

# For buffered responses, response.body contains raw bytes; streamed responses
# expose a stream PID and can still be read through the helper.
binary_data = HTTP.Response.read_all(response)

# Write response to file (supports both streaming and non-streaming)
:ok = HTTP.Response.write_to(response, "/tmp/downloaded-file.txt")

# Write large file downloads directly to disk
response =
  HTTP.fetch("https://example.com/large-file.zip")
  |> HTTP.Promise.await()

:ok = HTTP.Response.write_to(response, "/tmp/large-file.zip")

HTTP.Headers

Handle HTTP headers with utilities for parsing, normalizing, and manipulating headers.

# Create headers
headers = HTTP.Headers.new([{"Content-Type", "application/json"}])

# Get header value
type = HTTP.Headers.get(headers, "content-type")

# Set header
headers = HTTP.Headers.set(headers, "Authorization", "Bearer token")

# Set header only if not already present
headers = HTTP.Headers.set_default(headers, "User-Agent", "CustomAgent/1.0")

# Access default user agent string
default_ua = HTTP.Headers.user_agent()

# Parse Content-Type
{media_type, params} = HTTP.Headers.parse_content_type("application/json; charset=utf-8")

HTTP.Telemetry

Comprehensive telemetry and metrics for HTTP requests and responses.

# All HTTP.fetch operations automatically emit telemetry events
# No configuration required - just attach handlers

:telemetry.attach_many(
  "my_handler",
  [
    [:http_fetch, :request, :start],
    [:http_fetch, :request, :stop],
    [:http_fetch, :request, :exception]
  ],
  fn event_name, measurements, metadata, _config ->
    case event_name do
      [:http_fetch, :request, :start] ->
        IO.puts("Starting request to #{metadata.url}")
      [:http_fetch, :request, :stop] ->
        IO.puts("Request completed: #{measurements.status} in #{measurements.duration}μs")
      [:http_fetch, :request, :exception] ->
        IO.puts("Request failed: #{inspect(metadata.error)}")
    end
  end,
  nil
)

# Manual telemetry events (for custom implementations)
HTTP.Telemetry.request_start("GET", URI.parse("https://example.com"), %HTTP.Headers{})
HTTP.Telemetry.request_stop(200, URI.parse("https://example.com"), 1024, 1500)
HTTP.Telemetry.request_exception(URI.parse("https://example.com"), :timeout, 5000)

HTTP.Request

Request configuration struct.

request = %HTTP.Request{
  method: :post,
  url: URI.parse("https://api.example.com/data"),
  headers: HTTP.Headers.new([{"Authorization", "Bearer token"}]),
  body: "data",
  transport_options: [timeout: 10_000, connect_timeout: 5_000, redirect: :manual]
}

Transport Options:

  • transport_options: Socket transport options such as timeout, connect_timeout, tls_backend, ssl, socket_opts, and redirect

redirect defaults to :follow with the socket transport. Pass redirect: :manual to HTTP.fetch/2 or transport_options: [redirect: :manual] on %HTTP.Request{} to return redirect responses. Pass redirect: :error to fail when a redirect response is received.

HTTP.FormData

Handle form data and file uploads.

# Regular form data
form = HTTP.FormData.new()
       |> HTTP.FormData.append_field("name", "John")
       |> HTTP.FormData.append_field("email", "john@example.com")

# File upload
file_stream = File.stream!("document.pdf")
form = HTTP.FormData.new()
       |> HTTP.FormData.append_field("name", "John")
       |> HTTP.FormData.append_file("document", "document.pdf", file_stream, "application/pdf")

# Use in request
HTTP.fetch("https://api.example.com/upload", method: "POST", body: form)

HTTP.AbortController

Request cancellation.

controller = HTTP.AbortController.new()
HTTP.AbortController.abort(controller)

Error Handling

The library handles:

  • Network errors and timeouts
  • HTTP error status codes
  • JSON parsing errors
  • Invalid URLs
  • Cancelled requests

Development

This project uses several code quality tools to maintain high standards:

Code Quality Tools

Credo - Static code analysis to enforce Elixir style guidelines and identify code smells:

# Run standard checks
mix credo

# Run with strict mode (includes readability checks)
mix credo --strict

# Explain a specific issue
mix credo explain <issue_category>

Dialyzer - Static type analysis to catch type errors and inconsistencies:

# Run type checking
mix dialyzer

# Generate/rebuild PLT (first time setup, takes 2-3 minutes)
mix dialyzer --plt

ExDoc - Generate comprehensive documentation:

# Generate HTML documentation
mix docs

# View generated docs
open doc/index.html

Running Tests

Run these commands from the umbrella root, including when testing one app. The root dependency graph includes the runtime dependencies of every umbrella app; invoking Mix inside a child app does not traverse its in_umbrella dependencies in the same way.

# Prepare dependencies, including on a cold checkout
MIX_ENV=test mix deps.get
MIX_ENV=test mix compile --warnings-as-errors

# Run all unit tests
mix test

# Run one app (replace the app name as needed)
mix test apps/http_fetch/test

# Run specific test file
mix test apps/http_fetch/test/http/response_test.exs

# Run with coverage
mix test --cover

Running E2E Tests

The e2e suite exercises real HTTP behavior against a vendored Go test server. It requires Go 1.22+ to build the server.

# 1. Build the test server
(cd apps/http_fetch/priv/test_server && go build -o ../test_server/server .)

# 2. Start it in the background; capture the printed port
./apps/http_fetch/priv/test_server/server > .e2e_port &
PORT=$(grep -oE '[0-9]+' .e2e_port | head -n1)
export E2E_BASE_URL="http://127.0.0.1:$PORT"

# 3. Run the e2e suite
MIX_ENV=test mix test.e2e

In CI, the e2e.yml workflow handles all of this automatically. mix test.e2e keeps execution at the umbrella root. To run one suite, use MIX_ENV=test mix test apps/http_web_socket/e2e (or another app's e2e directory) after the same preparation as the unit tests.

Testing Packaged Consumers

bash scripts/external_consumer_smoke.sh

This builds all seven current Hex packages (http_core, http_runtime, elixir_quic_http3, http_fetch, http_web_socket, http_event_source, and http_web_transport) and installs their unpacked contents into a temporary project outside the umbrella, with independent dependencies and build output and no repository lockfile. Local paths resolve the unpublished internal packages; ex_ssl is resolved only through http_core. The smoke checks runtime application startup, verified local TLS 1.3 requests with both TCP TLS backends, and the separate WebTransport QUIC boundary.

Code Formatting

# Format all code
mix format

# Check formatting without changes
mix format --check-formatted

Requirements

  • Elixir 1.18+ (for built-in JSON module support)
  • Erlang OTP with :ssl and :public_key applications

License

MIT License

The full published feature gate is a separate validation mode:

# From the umbrella root; requires Git, Python 3 with OpenSSL TLS 1.3/ALPN,
# the openssl command, GNU timeout, and the normal Mix toolchain.
EX_SSL_RESULTS_DIR=/tmp/http-fetch-published bash scripts/ex_ssl_published_feature_gate.sh

# Explicit unreleased candidate validation, never a substitute for the Hex gate:
EX_SSL_DEP_MODE=source EX_SSL_SOURCE_DIR=/absolute/path/to/ex_ssl \
  EX_SSL_RESULTS_DIR=/tmp/http-fetch-source bash scripts/ex_ssl_source_smoke.sh

The published gate builds all seven package artifacts, uses the checked-in lock and an explicit test-only ex_ssl dependency, and verifies the resolved Hex package and loaded module provenance. It is distinct from the cold transitive smoke above. EX_SSL_DEP_MODE=published bash scripts/ex_ssl_source_smoke.sh is the compatibility entry point; no source runtime checkout is needed. Fixtures come from the immutable release commit recorded in scripts/ex_ssl_fixture_manifest.env, separately from the Hex runtime.

Maintainers should require Published ex_ssl feature gate (Elixir 1.18 / OTP 28). The separate scheduled/manual compatibility workflow covers Elixir 1.19/OTP 28 and 1.20/OTP 29; it validates this consumer, not the library's own runtime matrix. See consumer validation evidence for exact commands, results, provenance, and limits. Independent human security review is incomplete; green tests do not establish broad production readiness or improved performance. No connection pooling, automatic WebSocket reconnect, TLS 1.2/mTLS resumption, persistent tickets, or 0-RTT support is implied.