dream_http_client/recorder

Recorder process and state management

Manages HTTP request/response recordings using a process to store state. Supports recording, playback, and passthrough modes.

Types

Opaque recorder handle for managing HTTP request/response recordings

A Recorder is a handle to an OTP actor process that manages recording state. Multiple HTTP requests can share the same recorder by passing the same handle. The recorder handles saving recordings to disk, loading them for playback, and matching requests to recorded responses.

Thread Safety

Recorders are safe to use concurrently - multiple requests can use the same recorder handle simultaneously. The internal actor processes messages sequentially.

Lifecycle

  1. Build with recorder.new() |> ... |> start()
  2. Attach to requests with client.recorder(rec)
  3. Optionally cleanup with recorder.stop(rec) (recordings already saved)

Examples

import dream_http_client/recorder.{directory, mode, start}

// Record mode
let assert Ok(rec) =
  recorder.new()
  |> directory("mocks")
  |> mode("record")
  |> start()

// Use recorder with requests
client.new()
  |> client.host("api.example.com")
  |> client.recorder(rec)
  |> client.send()

// Cleanup (optional - recordings already saved)
recorder.stop(rec)
pub opaque type Recorder

Builder for configuring and starting a recorder.

Recorder configuration uses a builder so options can evolve without adding a combinatorial set of start_with_* functions.

import dream_http_client/recorder.{directory, mode}

let builder =
  recorder.new()
  |> directory("mocks/api")
  |> mode("playback")
pub type RecorderBuilder {
  RecorderBuilder(
    mode: String,
    directory: option.Option(String),
    key: fn(recording.RecordedRequest) -> String,
    request_transformer: fn(recording.RecordedRequest) -> recording.RecordedRequest,
    response_transformer: fn(
      recording.RecordedRequest,
      recording.RecordedResponse,
    ) -> recording.RecordedResponse,
  )
}

Constructors

A request transformer applied before keying and persistence.

A transformer is a pure function RecordedRequest -> RecordedRequest that runs in two places:

  • Before computing the match key (so matching uses the transformed request)
  • Before persisting recordings to disk (so secrets can be removed)

This is how you implement “scrub secrets and still match”: normalize the request (remove auth headers, drop query params, normalize paths, etc.), then choose a key that matches on the remaining stable fields.

Example (drop Authorization header)

import dream_http_client/recorder.{directory, mode, request_transformer}
import dream_http_client/recording
import gleam/list

fn drop_auth_header(req: recording.RecordedRequest) -> recording.RecordedRequest {
  let headers =
    req.headers
    |> list.filter(fn(h) { h.0 != "Authorization" })
  recording.RecordedRequest(..req, headers: headers)
}

let builder =
  recorder.new()
  |> mode("record")
  |> directory("mocks")
  |> request_transformer(drop_auth_header)
pub type RequestTransformer =
  fn(recording.RecordedRequest) -> recording.RecordedRequest

A response transformer applied before persistence in Record mode.

This hook is for scrubbing secrets out of recorded responses (set-cookie, authorization echoes, PII, etc.) before they are written to disk.

Order of operations in Record mode:

  1. The request transformer runs (request normalization + request scrubbing)
  2. The key is computed from the transformed request
  3. The response transformer runs (response scrubbing)
  4. The scrubbed recording is stored in memory and persisted to disk

Note: this transformer runs only in Record mode. Playback returns whatever is in the saved fixtures.

pub type ResponseTransformer =
  fn(recording.RecordedRequest, recording.RecordedResponse) -> recording.RecordedResponse

Values

pub fn add_recording(
  recorder: Recorder,
  rec: recording.Recording,
) -> Nil

Add a recording to the recorder

Manually adds a recording to the recorder’s in-memory state and saves it to disk immediately if in Record mode. This function is typically called automatically by the HTTP client when a request completes, but can be used directly for testing or manual recording creation.

Parameters

  • recorder: The recorder to add the recording to
  • rec: The recording (request/response pair) to add

Behavior by Mode

  • Record mode: Adds to in-memory state and saves to disk immediately
  • Playback mode: No-op (recordings loaded from disk at startup)
  • Passthrough mode: No-op (no recording functionality)

Examples

import dream_http_client/recorder.{directory, mode, start}

let assert Ok(rec) =
  recorder.new()
  |> directory("mocks")
  |> mode("record")
  |> start()

// Manually add a recording
let manual_recording = recording.Recording(
  request: create_test_request(),
  response: create_test_response(),
)
recorder.add_recording(rec, manual_recording)

Notes

  • Recordings are saved immediately in Record mode (no need to call stop())
  • If save fails, error is logged but recording remains in memory
  • If multiple recordings share the same match key, they are all stored (playback lookup will error if that makes the key ambiguous)
pub fn directory(
  builder: RecorderBuilder,
  directory: String,
) -> RecorderBuilder

Set the recording directory used by "record" and "playback" modes.

The directory is required for "record" and "playback" (validated in start()), and ignored for "passthrough".

pub fn find_recording(
  recorder: Recorder,
  request: recording.RecordedRequest,
) -> Result(option.Option(recording.Recording), String)

Find a matching recording for a request

Searches for a recording that matches the given request based on the recorder’s configured match key and request transformer. This is used internally by the HTTP client during playback, but can be called directly to check if a recording exists.

Parameters

  • recorder: The recorder to search in
  • request: The request to find a matching recording for

Returns

  • Ok(Some(Recording)): Matching recording found (unambiguous)
  • Ok(None): No matching recording found (or not in Playback mode)
  • Error(String): Playback lookup was ambiguous (multiple recordings share the same key)

Examples

import dream_http_client/recorder.{directory, mode, start}
import gleam/http
import gleam/option

let assert Ok(rec) =
  recorder.new()
  |> directory("mocks")
  |> mode("playback")
  |> start()

let request = recording.RecordedRequest(
  method: http.Get,
  scheme: http.Https,
  host: "api.example.com",
  port: option.None,
  path: "/users",
  query: option.None,
  headers: [],
  body: "",
)

case recorder.find_recording(rec, request) {
  Ok(option.Some(_recording)) -> io.println("Found recording")
  Ok(option.None) -> io.println("No recording found")
  Error(reason) -> io.println_error("Ambiguous match: " <> reason)
}

Notes

  • Only works in Playback mode (returns None in other modes)
  • Matching uses the recorder’s configured key + request transformer
  • Returns None if recorder doesn’t respond (safe default)
  • Timeout is 1 second - recorder should respond quickly
pub fn get_recordings(
  recorder: Recorder,
) -> List(recording.Recording)

Get all recordings from the recorder

Retrieves all recordings currently stored in the recorder’s in-memory state. In Playback mode, this returns all recordings loaded from disk at startup. In Record mode, this returns all recordings captured so far (including unsaved ones).

Parameters

  • recorder: The recorder to get recordings from

Returns

  • List(Recording): All recordings in the recorder (empty list if none or error)

Examples

import dream_http_client/recorder.{directory, mode, start}

let assert Ok(rec) =
  recorder.new()
  |> directory("mocks")
  |> mode("record")
  |> start()

// Make some requests...

let recordings = recorder.get_recordings(rec)
io.println("Captured " <> int.to_string(list.length(recordings)) <> " recordings")

Notes

  • Returns empty list if recorder doesn’t respond (safe default)
  • In Record mode, includes recordings that may not yet be saved to disk
  • Timeout is 1 second - recorder should respond quickly
pub fn is_record_mode(recorder: Recorder) -> Bool

Check if recorder is in Record mode

Determines whether the recorder is configured to capture and save real HTTP requests/responses. Useful for conditional logic that only applies during recording.

Parameters

  • recorder: The recorder to check

Returns

  • True: Recorder is in Record mode
  • False: Recorder is in Playback or Passthrough mode

Examples

import dream_http_client/recorder.{directory, mode, start}

let assert Ok(rec) =
  recorder.new()
  |> directory("mocks")
  |> mode("record")
  |> start()

if recorder.is_record_mode(rec) {
  io.println("Recording mode active")
}

Notes

  • Returns False if the recorder process doesn’t respond (safe default)
  • Timeout is 1 second - recorder should respond quickly
pub fn key(
  builder: RecorderBuilder,
  key: fn(recording.RecordedRequest) -> String,
) -> RecorderBuilder

Set the match key function.

The key function determines how requests are grouped for playback lookup. Keys should be stable and should generally not include secrets.

If two different recordings produce the same key, playback lookup becomes ambiguous and find_recording() will return Error(...).

pub fn mode(
  builder: RecorderBuilder,
  mode: String,
) -> RecorderBuilder

Set the recorder mode string.

Valid modes (validated in start()):

  • "record"
  • "playback"
  • "passthrough"

Mode strings are case-sensitive.

pub fn new() -> RecorderBuilder

Create a new recorder builder with safe defaults.

Defaults:

  • mode: "passthrough"
  • directory: unset
  • key: matching.request_key(method: True, url: True, headers: False, body: False)
  • request_transformer: identity
  • response_transformer: identity
pub fn request_transformer(
  builder: RecorderBuilder,
  request_transformer: fn(recording.RecordedRequest) -> recording.RecordedRequest,
) -> RecorderBuilder

Set the request transformer.

The transformer is applied before keying and before persistence, so it’s the right place to normalize requests and scrub secrets.

pub fn response_transformer(
  builder: RecorderBuilder,
  response_transformer: fn(
    recording.RecordedRequest,
    recording.RecordedResponse,
  ) -> recording.RecordedResponse,
) -> RecorderBuilder

Set the response transformer.

This transformer is applied only in Record mode and runs before persistence so recorded fixtures can be safely committed/shared.

pub fn start(
  builder: RecorderBuilder,
) -> Result(Recorder, String)

Start a new recorder from a builder.

Creates an OTP actor process to manage recorder state. The recorder handles saving recordings to disk (Record mode), loading them for playback (Playback mode), or passing requests through unchanged (Passthrough mode).

Parameters

  • builder: Recorder configuration builder

Returns

  • Ok(Recorder): Successfully started recorder
  • Error(String): Error message if startup fails (e.g., cannot load recordings in Playback mode)

Examples

import dream_http_client/recorder.{directory, mode, start}

let assert Ok(rec) =
  recorder.new()
  |> directory("mocks/api")
  |> mode("playback")
  |> start()

Notes

  • In Playback mode, recordings are loaded from disk at startup
  • In Record mode, recordings are saved immediately when captured (no need to call stop())
  • Multiple requests can share the same recorder handle safely
  • The recorder process runs until stop() is called or the VM shuts down
pub fn stop(recorder: Recorder) -> Result(Nil, String)

Stop the recorder and cleanup

Stops the recorder’s OTP actor process and releases resources. In Record mode, recordings are already saved to disk immediately when captured, so this function only performs cleanup. Calling stop() is optional but recommended for proper resource management.

Parameters

  • recorder: The recorder to stop

Returns

  • Ok(Nil): Successfully stopped the recorder
  • Error(String): Error message if the recorder doesn’t respond within 5 seconds

Examples

import dream_http_client/recorder.{directory, mode, start}

let assert Ok(rec) =
  recorder.new()
  |> directory("mocks")
  |> mode("record")
  |> start()

// Use recorder...

// Cleanup (optional - recordings already saved)
case recorder.stop(rec) {
  Ok(_) -> io.println("Recorder stopped")
  Error(reason) -> io.println_error("Failed to stop: " <> reason)
}

Notes

  • Recordings are saved immediately when captured - stop() is not required for persistence
  • This function is optional but recommended for proper resource cleanup
  • Timeout is 5 seconds - recorder should stop quickly
  • After calling stop(), the recorder handle is no longer valid
pub fn transform_response(
  recorder: Recorder,
  request: recording.RecordedRequest,
  response: recording.RecordedResponse,
) -> recording.RecordedResponse

Apply the recorder’s response transformer to a response.

Search Document