httprr

package
v0.1.14-0...-c2f9ad7 Latest Latest
Warning

This package is not in the latest version of its module.

Go to latest
Published: Jun 28, 2025 License: MIT Imports: 21 Imported by: 0

README

httprr: HTTP Record and Replay for Testing

The httprr package provides deterministic HTTP record and replay functionality for testing. It allows tests to record real HTTP interactions during development and replay them during CI/testing, ensuring consistent and fast test execution.

Quick Start

func TestMyAPI(t *testing.T) {
    // Skip test gracefully if no credentials and no recording exists
    httprr.SkipIfNoCredentialsOrRecording(t, "API_KEY")
    
    // Create recorder/replayer
    rr, err := httprr.OpenForTest(t, http.DefaultTransport)
    if err != nil {
        t.Fatal(err)
    }
    defer rr.Close()
    
    // Use rr.Client() for all HTTP calls
    client := rr.Client()
    resp, err := client.Get("https://api.example.com/data")
    // ... test continues
}

Core Concepts

Recording vs Replay Modes
  • Recording Mode (-httprecord=.): Makes real HTTP requests and saves them to .httprr files
  • Replay Mode (default): Reads saved .httprr files and replays the responses
Command-Line Flags
  • -httprecord=<regexp>: Re-record traces for files matching the regexp pattern (use "." to match all)
  • -httprecord-delay=<ms>: Add delay in milliseconds between HTTP requests during recording (helps avoid rate limits)
File Management
  • Recording: Always creates uncompressed .httprr files for easier debugging
  • Replay: Automatically handles both .httprr and .httprr.gz files
  • Conflict Resolution: Chooses the newer file if both compressed and uncompressed exist
  • Auto-cleanup: Recording mode removes conflicting files automatically

API Reference

Core Functions
OpenForTest(t *testing.T, rt http.RoundTripper) (*RecordReplay, error)

The primary API for most test cases. Creates a recorder/replayer for the given test.

  • Recording mode: Creates testdata/TestName.httprr
  • Replay mode: Loads existing recording
  • File naming: Derived automatically from t.Name()
  • Directory: Always uses testdata/ subdirectory
SkipIfNoCredentialsOrRecording(t *testing.T, envVars ...string)

Gracefully skips tests when they cannot run (no API keys) and have no recorded data.

// Skip if OPENAI_API_KEY not set AND no recording exists
httprr.SkipIfNoCredentialsOrRecording(t, "OPENAI_API_KEY")

// Skip if neither API_KEY nor BACKUP_KEY is set AND no recording exists  
httprr.SkipIfNoCredentialsOrRecording(t, "API_KEY", "BACKUP_KEY")
Open(file string, rt http.RoundTripper) (*RecordReplay, error)

Low-level API for custom file management. Most tests should use OpenForTest instead.

RecordReplay Methods
Client() *http.Client

Returns an HTTP client that routes through the recorder/replayer.

ScrubReq(scrubs ...func(*http.Request) error)

Adds request scrubbing functions to sanitize sensitive data before recording.

rr.ScrubReq(func(req *http.Request) error {
    req.Header.Set("Authorization", "Bearer test-api-key")
    return nil
})
ScrubResp(scrubs ...func(*bytes.Buffer) error)

Adds response scrubbing functions to sanitize sensitive data in responses.

Recording() bool

Reports whether the recorder is in recording mode.

Close() error

Closes the recorder/replayer. Use with defer for automatic cleanup.

Usage Patterns

Basic API Testing
func TestOpenAIChat(t *testing.T) {
    httprr.SkipIfNoCredentialsOrRecording(t, "OPENAI_API_KEY")
    
    rr, err := httprr.OpenForTest(t, http.DefaultTransport)
    if err != nil {
        t.Fatal(err)
    }
    defer rr.Close()
    
    // Scrub sensitive data
    rr.ScrubReq(func(req *http.Request) error {
        req.Header.Set("Authorization", "Bearer test-api-key")
        return nil
    })
    
    // Create client with recording support
    llm, err := openai.New(openai.WithHTTPClient(rr.Client()))
    require.NoError(t, err)
    
    // Test continues with recorded/replayed HTTP calls
    response, err := llm.GenerateContent(ctx, messages)
    require.NoError(t, err)
}
Helper Functions for Multiple Tests
func createTestClient(t *testing.T) *MyAPIClient {
    t.Helper()
    httprr.SkipIfNoCredentialsOrRecording(t, "MY_API_KEY")
    
    rr, err := httprr.OpenForTest(t, http.DefaultTransport)
    if err != nil {
        t.Fatal(err)
    }
    t.Cleanup(func() { rr.Close() })
    
    return NewMyAPIClient(WithHTTPClient(rr.Client()))
}

func TestFeatureA(t *testing.T) {
    client := createTestClient(t)
    // ... test continues
}

func TestFeatureB(t *testing.T) {
    client := createTestClient(t)
    // ... test continues
}
Multiple API Endpoints
func TestMultiAPIIntegration(t *testing.T) {
    httprr.SkipIfNoCredentialsOrRecording(t, "OPENAI_API_KEY", "SERPAPI_KEY")
    
    rr, err := httprr.OpenForTest(t, http.DefaultTransport)
    if err != nil {
        t.Fatal(err)
    }
    defer rr.Close()
    
    // Both clients will use the same recording
    openaiClient := openai.New(openai.WithHTTPClient(rr.Client()))
    searchClient := serpapi.New(serpapi.WithHTTPClient(rr.Client()))
    
    // All HTTP calls are recorded/replayed together
}

Command Line Usage

Recording New Interactions
# Record all tests
go test ./... -httprecord=.

# Record specific test
go test ./pkg -httprecord=. -run TestSpecificFunction

# Record with pattern matching
go test ./... -httprecord="TestOpenAI.*"
Running with Recorded Data
# Normal test run (uses recorded data)
go test ./...

# Skip tests that need credentials
OPENAI_API_KEY="" go test ./...  # Tests will skip gracefully

File Management

File Structure
testdata/
├── TestBasicFunction.httprr           # Uncompressed recording
├── TestWithSubtest-subcase.httprr     # Subtest recording  
├── TestOldFunction.httprr.gz          # Compressed recording
└── TestComplexAPI-setup.httprr        # Multi-part test
File Naming Rules
  • Test name: TestMyFunction → File: TestMyFunction.httprr
  • With subtests: TestMyFunction/subcase → File: TestMyFunction-subcase.httprr
  • Special chars: Replaced with hyphens for filesystem compatibility
Compression Management
# Compress all recordings (for repository storage)
go run ./internal/devtools/rrtool pack -r

# Check compression status
go run ./internal/devtools/rrtool check

# Decompress for debugging
go run ./internal/devtools/rrtool unpack -r
Recording with Rate Limit Protection

When recording tests that make many API calls, use the delay flag to avoid hitting rate limits:

# Record with 1 second delay between requests
go test -httprecord=. -httprecord-delay=1000 ./...

# Record specific test with 500ms delay
go test -httprecord=. -httprecord-delay=500 -run TestMyAPI ./mypackage

Best Practices

1. Always Use Graceful Skipping
// ✅ Good: Test skips gracefully when it can't run
httprr.SkipIfNoCredentialsOrRecording(t, "API_KEY")

// ❌ Bad: Test fails when API key missing
rr, err := httprr.OpenForTest(t, http.DefaultTransport)
2. Scrub Sensitive Data
// ✅ Good: Replace real API keys with test values
rr.ScrubReq(func(req *http.Request) error {
    req.Header.Set("Authorization", "Bearer test-api-key")
    return nil
})

// ❌ Bad: Real API keys recorded in files
// (No scrubbing - keys end up in repository)
3. Use Helper Functions
// ✅ Good: Reusable test setup
func createTestLLM(t *testing.T) *openai.LLM {
    t.Helper()
    httprr.SkipIfNoCredentialsOrRecording(t, "OPENAI_API_KEY")
    // ... setup code
}

// ❌ Bad: Duplicate setup in every test
func TestA(t *testing.T) {
    httprr.SkipIfNoCredentialsOrRecording(t, "OPENAI_API_KEY")
    rr, err := httprr.OpenForTest(t, http.DefaultTransport)
    // ... repeated setup
}
4. Handle Cleanup Properly
// ✅ Good: Automatic cleanup
defer rr.Close()

// or
t.Cleanup(func() { rr.Close() })

// ❌ Bad: Manual cleanup (can be forgotten)
// (No defer or cleanup)

Troubleshooting

Common Issues
"cached HTTP response not found"

Problem: Test is trying to make an HTTP request not in the recording.

Solutions:

# Re-record the test
go test ./pkg -httprecord=. -run TestName

# Check if you have required environment variables
export OPENAI_API_KEY="your-key-here"
go test ./pkg -httprecord=. -run TestName
"gzip: invalid header"

Problem: .httprr.gz file is corrupted or not actually compressed.

Solutions:

# Check and fix compression
go run ./internal/devtools/rrtool check
go run ./internal/devtools/rrtool pack -r

# Or remove the corrupted file and re-record
rm testdata/TestName.httprr.gz
go test ./pkg -httprecord=. -run TestName
Test skipped unexpectedly

Problem: Test is skipping when you expect it to run.

Debug steps:

# Check if environment variables are set
echo $OPENAI_API_KEY

# Check if recording exists
ls testdata/TestName.httprr*

# Run with verbose output
go test ./pkg -run TestName -v
File Conflicts

The system automatically handles conflicts, but you can resolve manually:

# Check which file is newer
ls -la testdata/TestName.httprr*

# Remove older file (system will warn and use newer)
rm testdata/TestName.httprr.gz  # if .httprr is newer

# Or compress the newer one
gzip testdata/TestName.httprr

Migration Guide

From OpenForTestWithSkip (Old API)
// ❌ Old API (removed)
rr := httprr.OpenForTestWithSkip(t, http.DefaultTransport, "API_KEY")
defer rr.Close()

// ✅ New API
httprr.SkipIfNoCredentialsOrRecording(t, "API_KEY")

rr, err := httprr.OpenForTest(t, http.DefaultTransport)
if err != nil {
    t.Fatal(err)
}
defer rr.Close()
Benefits of New API
  1. Consistent Error Handling: All httprr operations return errors
  2. Clear Separation: Skip logic separate from file operations
  3. Single Responsibility: Each function has one clear purpose
  4. Better Documentation: Self-documenting function names

Advanced Usage

Custom File Locations
// For custom file management (rarely needed)
rr, err := httprr.Open("custom/path/recording.httprr", http.DefaultTransport)
if err != nil {
    t.Fatal(err)
}
defer rr.Close()
Conditional Recording
func TestWithConditionalRecording(t *testing.T) {
    // Only record if we have credentials
    if os.Getenv("API_KEY") != "" {
        // Will record new interactions
        rr, err := httprr.OpenForTest(t, http.DefaultTransport)
        // ...
    } else {
        // Will only replay existing recordings
        httprr.SkipIfNoCredentialsOrRecording(t, "API_KEY")
        rr, err := httprr.OpenForTest(t, http.DefaultTransport)
        // ...
    }
}
Complex Scrubbing
rr.ScrubReq(func(req *http.Request) error {
    // Remove API keys
    req.Header.Set("Authorization", "Bearer test-key")
    
    // Scrub request body
    if req.Body != nil {
        body := req.Body.(*httprr.Body)
        bodyStr := string(body.Data)
        bodyStr = strings.ReplaceAll(bodyStr, "real-secret", "test-secret")
        body.Data = []byte(bodyStr)
    }
    
    return nil
})

rr.ScrubResp(func(buf *bytes.Buffer) error {
    // Remove sensitive data from responses
    content := buf.String()
    content = strings.ReplaceAll(content, "sensitive-data", "redacted")
    buf.Reset()
    buf.WriteString(content)
    return nil
})

Contributing

When adding new tests that use external APIs:

  1. Always use SkipIfNoCredentialsOrRecording for graceful degradation
  2. Include appropriate scrubbing to avoid committing secrets
  3. Record with real credentials initially, then scrub the results
  4. Compress recordings before committing to save repository space
  5. Document required environment variables in test comments

For questions or issues with the httprr system, see the main project documentation or open an issue.

Documentation

Overview

Package httprr implements HTTP record and replay, mainly for use in tests.

Open creates a new RecordReplay. Whether it is recording or replaying is controlled by the -httprecord flag, which is defined by this package only in test programs (built by “go test”). See the Open documentation for more details.

Note: This package has been adapted for use in the LangChainGo library with convienence functions for creating RecordReplay instances that are suitable for testing.

Index

Constants

This section is empty.

Variables

This section is empty.

Functions

func CleanFileName

func CleanFileName(testName string) string

CleanFileName converts a test name to a clean filename suitable for recordings. It replaces path separators and other non-path-friendly characters with hyphens. For example:

  • "TestMyFunction/subtest" becomes "TestMyFunction-subtest"
  • "Test API/Complex_Case" becomes "Test-API-Complex_Case"

func EmbeddingJSONFormatter

func EmbeddingJSONFormatter() func(*bytes.Buffer) error

EmbeddingJSONFormatter returns a response scrubber that formats JSON responses with special handling for number arrays (displays them on single lines). This is particularly useful for embedding API responses which often contain large arrays of floating-point numbers.

Usage in tests:

rr.ScrubResp(httprr.EmbeddingJSONFormatter())

func Recording

func Recording(file string) (bool, error)

Recording reports whether the "-httprecord" flag is set for the given file. It returns an error if the flag is set to an invalid value.

func SkipIfNoCredentialsAndRecordingMissing

func SkipIfNoCredentialsAndRecordingMissing(t *testing.T, envVars ...string)

SkipIfNoCredentialsAndRecordingMissing skips the test if required environment variables are not set and no httprr recording exists. This allows tests to gracefully skip when they cannot run.

Example usage:

func TestMyAPI(t *testing.T) {
    httprr.SkipIfNoCredentialsAndRecordingMissing(t, "API_KEY", "API_URL")

    rr, err := httprr.OpenForTest(t, http.DefaultTransport)
    if err != nil {
        t.Fatal(err)
    }
    defer rr.Close()
    // use rr.Client() for HTTP requests...
}

Types

type Body

type Body struct {
	Data       []byte
	ReadOffset int
}

A Body is an io.ReadCloser used as an HTTP request body. In a Scrubber, if req.Body != nil, then req.Body is guaranteed to have type *Body, making it easy to access the body to change it.

func (*Body) Close

func (b *Body) Close() error

Close is a no-op, implementing io.Closer.

func (*Body) Read

func (b *Body) Read(p []byte) (int, error)

Read reads from the body, implementing io.Reader.

type RecordReplay

type RecordReplay struct {
	// contains filtered or unexported fields
}

A RecordReplay is an http.RoundTripper that can operate in two modes: record and replay.

In record mode, the RecordReplay invokes another RoundTripper and logs the (request, response) pairs to a file.

In replay mode, the RecordReplay responds to requests by finding an identical request in the log and sending the logged response.

func Open

func Open(file string, rt http.RoundTripper) (*RecordReplay, error)

Open opens a new record/replay log in the named file and returns a RecordReplay backed by that file.

By default Open expects the file to exist and contain a previously-recorded log of (request, response) pairs, which RecordReplay.RoundTrip consults to prepare its responses.

If the command-line flag -httprecord is set to a non-empty regular expression that matches file, then Open creates the file as a new log. In that mode, RecordReplay.RoundTrip makes actual HTTP requests using rt but then logs the requests and responses to the file for replaying in a future run.

func OpenForEmbeddingTest

func OpenForEmbeddingTest(t *testing.T, rt http.RoundTripper) *RecordReplay

OpenForTest creates a RecordReplay for the given test using a filename derived from the test name. The recording will be stored in a "testdata" subdirectory with a ".httprr" extension.

The transport parameter is optional. If not provided (nil), it defaults to httputil.DefaultTransport.

Example usage:

func TestMyAPI(t *testing.T) {
    rr := httprr.OpenForTest(t, nil) // Uses httputil.DefaultTransport
    defer rr.Close()

    client := rr.Client()
    // use client for HTTP requests...
}

// Or with a custom transport:
func TestMyAPIWithCustomTransport(t *testing.T) {
    customTransport := &http.Transport{MaxIdleConns: 10}
    rr := httprr.OpenForTest(t, customTransport)
    defer rr.Close()

    client := rr.Client()
    // use client for HTTP requests...
}

This will create/use a file at "testdata/TestMyAPI.httprr". OpenForEmbeddingTest creates a RecordReplay instance optimized for embedding tests. It automatically applies embedding JSON formatting to reduce file sizes.

func OpenForTest

func OpenForTest(t *testing.T, rt http.RoundTripper) *RecordReplay

func (*RecordReplay) Client

func (rr *RecordReplay) Client() *http.Client

Client returns an http.Client using rr as its transport. It is a shorthand for:

return &http.Client{Transport: rr}

For more complicated uses, use rr or the RecordReplay.RoundTrip method directly.

func (*RecordReplay) Close

func (rr *RecordReplay) Close() error

Close closes the RecordReplay. It is a no-op in replay mode.

func (*RecordReplay) Recording

func (rr *RecordReplay) Recording() bool

Recording reports whether the RecordReplay is in recording mode.

func (*RecordReplay) Replaying

func (rr *RecordReplay) Replaying() bool

Replaying reports whether the RecordReplay is in replaying mode.

func (*RecordReplay) RoundTrip

func (rr *RecordReplay) RoundTrip(req *http.Request) (*http.Response, error)

RoundTrip implements http.RoundTripper.

If rr has been opened in record mode, RoundTrip passes the requests on to the http.RoundTripper specified in the call to Open and then logs the (request, response) pair to the underlying file.

If rr has been opened in replay mode, RoundTrip looks up the request in the log and then responds with the previously logged response. If the log does not contain req, RoundTrip returns an error.

func (*RecordReplay) ScrubReq

func (rr *RecordReplay) ScrubReq(scrubs ...func(req *http.Request) error)

ScrubReq adds new request scrubbing functions to rr.

Before using a request as a lookup key or saving it in the record/replay log, the RecordReplay calls each scrub function, in the order they were registered, to canonicalize non-deterministic parts of the request and remove secrets. Scrubbing only applies to a copy of the request used in the record/replay log; the unmodified original request is sent to the actual server in recording mode. A scrub function can assume that if req.Body is not nil, then it has type *Body.

Calling ScrubReq adds to the list of registered request scrubbing functions; it does not replace those registered by earlier calls.

func (*RecordReplay) ScrubResp

func (rr *RecordReplay) ScrubResp(scrubs ...func(*bytes.Buffer) error)

ScrubResp adds new response scrubbing functions to rr.

Before using a response as a lookup key or saving it in the record/replay log, the RecordReplay calls each scrub function on a byte representation of the response, in the order they were registered, to canonicalize non-deterministic parts of the response and remove secrets.

Calling ScrubResp adds to the list of registered response scrubbing functions; it does not replace those registered by earlier calls.

Clients should be careful when loading the bytes into *http.Response using http.ReadResponse. This function can set http.Response.Close to true even when the original response had it false. See code in go/src/net/http.Response.Write and go/src/net/http.Write for more info.

Jump to

Keyboard shortcuts

? : This menu
/ : Search site
f or F : Jump to
y or Y : Canonical URL