diff --git a/docs/docs/concepts/architecture.md b/docs/docs/concepts/architecture.md index 6ec1ce10..f6d531ff 100644 --- a/docs/docs/concepts/architecture.md +++ b/docs/docs/concepts/architecture.md @@ -460,6 +460,26 @@ func TestOpenAIWithRecording(t *testing.T) { require.NoError(t, err) require.NotEmpty(t, response.Choices[0].Content) } + +// Testing with identical requests (e.g., cache testing) +func TestCachingWithIdenticalRequests(t *testing.T) { + recorder := httprr.New("testdata/cache_test") + defer recorder.Stop() + + llm, err := openai.New( + openai.WithHTTPClient(&http.Client{Transport: recorder}), + ) + require.NoError(t, err) + + // Make identical requests - each will be recorded with its unique response + for i := 0; i < 3; i++ { + response, err := llm.GenerateContent(context.Background(), []llms.MessageContent{ + llms.TextParts(llms.ChatMessageTypeHuman, "Same question"), + }) + require.NoError(t, err) + // Each replay will return responses in the same order they were recorded + } +} ``` #### Recording guidelines @@ -468,6 +488,7 @@ func TestOpenAIWithRecording(t *testing.T) { 2. **Sensitive Data**: httprr automatically redacts common sensitive headers 3. **Deterministic Tests**: Recordings ensure consistent test results across environments 4. **Version Control**: Commit recording files for team consistency +5. **Identical Requests**: When recording multiple identical requests (e.g., for cache testing), httprr preserves each response in order and replays them sequentially using a circular buffer #### Contributing with httprr diff --git a/internal/httprr/README.md b/internal/httprr/README.md index 18f638ec..9da62db6 100644 --- a/internal/httprr/README.md +++ b/internal/httprr/README.md @@ -30,6 +30,16 @@ func TestMyAPI(t *testing.T) { - **Recording Mode** (`-httprecord=.`): Makes real HTTP requests and saves them to `.httprr` files - **Replay Mode** (default): Reads saved `.httprr` files and replays the responses +### Circular Buffer for Identical Requests + +When multiple identical requests are recorded (e.g., for cache testing), httprr stores all responses in order and replays them sequentially using a circular buffer. This allows testing scenarios where: + +- The same request produces different responses (e.g., cache miss → cache hit) +- Multiple identical requests need to be tested in sequence +- Response ordering matters for test accuracy + +**Example**: Testing Google Gemini's implicit caching where 3 identical requests produce different `CachedTokens` values (0, then non-zero, then non-zero). + ### Command-Line Flags - `-httprecord=`: Re-record traces for files matching the regexp pattern (use "." to match all) @@ -172,6 +182,41 @@ func TestMultiAPIIntegration(t *testing.T) { } ``` +### Testing Identical Requests (Circular Buffer) + +When testing scenarios that require multiple identical requests with different responses (e.g., cache behavior): + +```go +func TestImplicitCaching(t *testing.T) { + httprr.SkipIfNoCredentialsOrRecording(t, "API_KEY") + + rr, err := httprr.OpenForTest(t, http.DefaultTransport) + if err != nil { + t.Fatal(err) + } + defer rr.Close() + + client := NewAPIClient(WithHTTPClient(rr.Client())) + + // Make identical requests - each gets recorded with its actual response + // Request 1: Cache miss (no cached tokens) + r1, _ := client.Query("same query") + assert.Equal(t, 0, r1.CachedTokens) + + // Request 2: Cache hit (has cached tokens) + r2, _ := client.Query("same query") + assert.Greater(t, r2.CachedTokens, 0) + + // Request 3: Still cached + r3, _ := client.Query("same query") + assert.Greater(t, r3.CachedTokens, 0) + + // Replay mode: Responses are returned in order from circular buffer + // Re-running the test will get: r1 (no cache), r2 (cached), r3 (cached) + // A 4th identical request would cycle back to r1's response +} +``` + ## Command Line Usage ### Recording New Interactions @@ -388,6 +433,19 @@ defer rr.Close() ## Advanced Usage +### Circular Buffer Internals + +When httprr encounters multiple identical requests during recording, it stores all responses in order. During replay: + +1. **First request**: Returns the first recorded response +2. **Second request**: Returns the second recorded response +3. **Subsequent requests**: Continue cycling through responses in order +4. **After last response**: Cycles back to the first response (circular buffer) + +This behavior is automatic and transparent - no special configuration needed. + +**File Format**: Identical requests share the same request key but maintain separate response entries in the `.httprr` file, preserving recording order. + ### Custom File Locations ```go diff --git a/internal/httprr/circular_buffer_test.go b/internal/httprr/circular_buffer_test.go new file mode 100644 index 00000000..45614009 --- /dev/null +++ b/internal/httprr/circular_buffer_test.go @@ -0,0 +1,249 @@ +package httprr + +import ( + "io" + "net/http" + "net/http/httptest" + "strings" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +// TestCircularBuffer tests that identical requests get different responses +// in the order they were recorded, cycling back to the beginning +func TestCircularBuffer(t *testing.T) { + t.Parallel() + dir := t.TempDir() + file := dir + "/circular.httprr" + + // Create a test server that returns different responses for the same request + responseCounter := 0 + responses := []string{ + "Response 1", + "Response 2", + "Response 3", + } + handler := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.WriteHeader(http.StatusOK) + w.Write([]byte(responses[responseCounter])) + responseCounter++ + }) + srv := httptest.NewServer(handler) + defer srv.Close() + + // Phase 1: Record mode - make 3 identical requests + t.Run("record", func(t *testing.T) { + rr, err := create(file, http.DefaultTransport) + require.NoError(t, err) + defer rr.Close() + + // Make 3 identical requests + for i := 0; i < 3; i++ { + req, err := http.NewRequest("GET", srv.URL+"/test", nil) + require.NoError(t, err) + + resp, err := rr.Client().Do(req) + require.NoError(t, err) + + body, err := io.ReadAll(resp.Body) + resp.Body.Close() + require.NoError(t, err) + + expected := responses[i] + assert.Equal(t, expected, string(body), "Recording request %d should get response %d", i+1, i+1) + } + }) + + // Phase 2: Replay mode - verify responses are returned in order + t.Run("replay-first-cycle", func(t *testing.T) { + rr, err := open(file, http.DefaultTransport) + require.NoError(t, err) + + // Request the same endpoint 3 times, should get responses in order + for i := 0; i < 3; i++ { + req, err := http.NewRequest("GET", srv.URL+"/test", nil) + require.NoError(t, err) + + resp, err := rr.Client().Do(req) + require.NoError(t, err) + + body, err := io.ReadAll(resp.Body) + resp.Body.Close() + require.NoError(t, err) + + expected := responses[i] + assert.Equal(t, expected, string(body), "First cycle request %d should get response %d", i+1, i+1) + } + }) + + // Phase 3: Verify circular behavior - should cycle back to first response + t.Run("replay-second-cycle", func(t *testing.T) { + rr, err := open(file, http.DefaultTransport) + require.NoError(t, err) + + // Make 6 more requests to test the circular buffer (2 full cycles) + for cycle := 0; cycle < 2; cycle++ { + for i := 0; i < 3; i++ { + req, err := http.NewRequest("GET", srv.URL+"/test", nil) + require.NoError(t, err) + + resp, err := rr.Client().Do(req) + require.NoError(t, err) + + body, err := io.ReadAll(resp.Body) + resp.Body.Close() + require.NoError(t, err) + + expected := responses[i] + assert.Equal(t, expected, string(body), "Cycle %d request %d should get response %d", cycle+1, i+1, i+1) + } + } + }) +} + +// TestCircularBufferWithDifferentResponses tests that different requests +// don't interfere with each other's response buffers +func TestCircularBufferWithDifferentRequests(t *testing.T) { + t.Parallel() + dir := t.TempDir() + file := dir + "/different.httprr" + + // Create a test server that responds differently based on the URL path + handler := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.WriteHeader(http.StatusOK) + switch r.URL.Path { + case "/endpoint1": + w.Write([]byte("Endpoint 1")) + case "/endpoint2": + w.Write([]byte("Endpoint 2")) + default: + w.Write([]byte("Unknown")) + } + }) + srv := httptest.NewServer(handler) + defer srv.Close() + + // Phase 1: Record mode + t.Run("record", func(t *testing.T) { + rr, err := create(file, http.DefaultTransport) + require.NoError(t, err) + defer rr.Close() + + // Record endpoint1 twice + for i := 0; i < 2; i++ { + req, err := http.NewRequest("GET", srv.URL+"/endpoint1", nil) + require.NoError(t, err) + resp, err := rr.Client().Do(req) + require.NoError(t, err) + resp.Body.Close() + } + + // Record endpoint2 once + req, err := http.NewRequest("GET", srv.URL+"/endpoint2", nil) + require.NoError(t, err) + resp, err := rr.Client().Do(req) + require.NoError(t, err) + resp.Body.Close() + }) + + // Phase 2: Replay mode - verify different requests maintain separate buffers + t.Run("replay", func(t *testing.T) { + rr, err := open(file, http.DefaultTransport) + require.NoError(t, err) + + // Request endpoint1 twice + for i := 0; i < 2; i++ { + req, err := http.NewRequest("GET", srv.URL+"/endpoint1", nil) + require.NoError(t, err) + resp, err := rr.Client().Do(req) + require.NoError(t, err) + body, err := io.ReadAll(resp.Body) + resp.Body.Close() + require.NoError(t, err) + assert.Equal(t, "Endpoint 1", string(body)) + } + + // Request endpoint2 once + req, err := http.NewRequest("GET", srv.URL+"/endpoint2", nil) + require.NoError(t, err) + resp, err := rr.Client().Do(req) + require.NoError(t, err) + body, err := io.ReadAll(resp.Body) + resp.Body.Close() + require.NoError(t, err) + assert.Equal(t, "Endpoint 2", string(body)) + + // Request endpoint1 again - should cycle back to first response + req, err = http.NewRequest("GET", srv.URL+"/endpoint1", nil) + require.NoError(t, err) + resp, err = rr.Client().Do(req) + require.NoError(t, err) + body, err = io.ReadAll(resp.Body) + resp.Body.Close() + require.NoError(t, err) + assert.Equal(t, "Endpoint 1", string(body)) + }) +} + +// TestCircularBufferWithBody tests circular buffer with POST requests containing bodies +func TestCircularBufferWithBody(t *testing.T) { + t.Parallel() + dir := t.TempDir() + file := dir + "/withbody.httprr" + + // Create a test server that echoes request number + counter := 0 + handler := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.WriteHeader(http.StatusOK) + counter++ + w.Write([]byte("Response " + string(rune('0'+counter)))) + }) + srv := httptest.NewServer(handler) + defer srv.Close() + + // Phase 1: Record mode - make identical POST requests + t.Run("record", func(t *testing.T) { + rr, err := create(file, http.DefaultTransport) + require.NoError(t, err) + defer rr.Close() + + // Make 3 identical POST requests with the same body + for i := 0; i < 3; i++ { + req, err := http.NewRequest("POST", srv.URL+"/api", strings.NewReader(`{"key":"value"}`)) + require.NoError(t, err) + req.Header.Set("Content-Type", "application/json") + + resp, err := rr.Client().Do(req) + require.NoError(t, err) + resp.Body.Close() + } + }) + + // Phase 2: Replay mode - verify responses cycle + t.Run("replay", func(t *testing.T) { + rr, err := open(file, http.DefaultTransport) + require.NoError(t, err) + + expectedResponses := []string{"Response 1", "Response 2", "Response 3"} + + // Make 6 requests to verify cycling (2 full cycles) + for i := 0; i < 6; i++ { + req, err := http.NewRequest("POST", srv.URL+"/api", strings.NewReader(`{"key":"value"}`)) + require.NoError(t, err) + req.Header.Set("Content-Type", "application/json") + + resp, err := rr.Client().Do(req) + require.NoError(t, err) + + body, err := io.ReadAll(resp.Body) + resp.Body.Close() + require.NoError(t, err) + + expectedIdx := i % 3 + assert.Equal(t, expectedResponses[expectedIdx], string(body), + "Request %d should get response %d (cycling)", i+1, expectedIdx+1) + } + }) +} diff --git a/internal/httprr/rr.go b/internal/httprr/rr.go index b744ee4d..a93d2c66 100644 --- a/internal/httprr/rr.go +++ b/internal/httprr/rr.go @@ -73,6 +73,29 @@ func init() { } } +// replayEntry holds response data for a single request key. +// It supports multiple responses for the same request (e.g., for cache testing) +// and cycles through them in order. +type replayEntry struct { + responses []string // list of responses for this request + mu sync.Mutex + index int // current response index +} + +// next returns the next response in the cycle and advances the index. +func (e *replayEntry) next() string { + e.mu.Lock() + defer e.mu.Unlock() + + if len(e.responses) == 0 { + return "" + } + + resp := e.responses[e.index] + e.index = (e.index + 1) % len(e.responses) + return resp +} + // A RecordReplay is an [http.RoundTripper] that can operate in two modes: record and replay. // // In record mode, the RecordReplay invokes another RoundTripper @@ -80,6 +103,8 @@ func init() { // // In replay mode, the RecordReplay responds to requests by finding // an identical request in the log and sending the logged response. +// If multiple identical requests were recorded, responses are returned +// in order and cycle back to the beginning (circular buffer). type RecordReplay struct { file string // file being read or written real http.RoundTripper // real HTTP connection @@ -87,7 +112,7 @@ type RecordReplay struct { mu sync.Mutex reqScrub []func(*http.Request) error // scrubbers for logging requests respScrub []func(*bytes.Buffer) error // scrubbers for logging responses - replay map[string]string // if replaying, the log + replay map[string]*replayEntry // if replaying, the log with circular response buffer record *os.File // if recording, the file being written writeErr error // if recording, any write error encountered logger *slog.Logger // logger for debug output @@ -274,7 +299,7 @@ func open(file string, rt http.RoundTripper) (*RecordReplay, error) { return nil, fmt.Errorf("read %s: not an httprr trace", file) } - replay := make(map[string]string) + replay := make(map[string]*replayEntry) for data != "" { // Each record starts with a line of the form "n1 n2\n" (or "n1 n2\r\n") // followed by n1 bytes of request encoding and @@ -289,7 +314,14 @@ func open(file string, rt http.RoundTripper) (*RecordReplay, error) { } var req, resp string req, resp, data = data[:n1], data[n1:n1+n2], data[n1+n2:] - replay[req] = resp + + // Support multiple responses for the same request (circular buffer) + entry := replay[req] + if entry == nil { + entry = &replayEntry{responses: make([]string, 0, 1)} + replay[req] = entry + } + entry.responses = append(entry.responses, resp) } rr := &RecordReplay{ @@ -626,7 +658,7 @@ func (rr *RecordReplay) replayRoundTrip(req *http.Request, reqLog string) (*http } } - respLog, ok := rr.replay[reqLog] + entry, ok := rr.replay[reqLog] if !ok { if rr.logger != nil && *debug { rr.logger.Debug("httprr: request not found in replay cache", @@ -638,12 +670,19 @@ func (rr *RecordReplay) replayRoundTrip(req *http.Request, reqLog string) (*http return nil, fmt.Errorf("cached HTTP response not found for:\n%s\n\nHint: Re-run tests with -httprecord=. to record new HTTP interactions\nDebug flags: -httprecord-debug for recording details, -httpdebug for HTTP traffic", reqLog) } + // Get the next response from the circular buffer + respLog := entry.next() + if respLog == "" { + return nil, fmt.Errorf("no responses available for request (empty entry)") + } + // Log that we found a match if rr.logger != nil && *debug { rr.logger.Debug("httprr: found matching request in replay cache", "method", req.Method, "url", req.URL.String(), "file", rr.file, + "response_count", len(entry.responses), "response_size", len(respLog), ) } diff --git a/internal/httprr/rr_unit_test.go b/internal/httprr/rr_unit_test.go index 855d7c05..e6c6ae85 100644 --- a/internal/httprr/rr_unit_test.go +++ b/internal/httprr/rr_unit_test.go @@ -119,7 +119,7 @@ func TestRecordReplayBasics(t *testing.T) { // Test basic struct initialization rr := &RecordReplay{ file: "test.httprr", - replay: make(map[string]string), + replay: make(map[string]*replayEntry), } assert.Equal(t, "test.httprr", rr.file) @@ -560,3 +560,53 @@ func TestInternalStructs(t *testing.T) { assert.Equal(t, []byte("test data"), body.Data) assert.Equal(t, 0, body.ReadOffset) } + +func TestReplayEntry(t *testing.T) { + t.Parallel() + + t.Run("empty entry", func(t *testing.T) { + entry := &replayEntry{ + responses: []string{}, + } + result := entry.next() + assert.Equal(t, "", result) + }) + + t.Run("single response", func(t *testing.T) { + entry := &replayEntry{ + responses: []string{"response1"}, + } + // Should return the same response multiple times + assert.Equal(t, "response1", entry.next()) + assert.Equal(t, "response1", entry.next()) + assert.Equal(t, "response1", entry.next()) + }) + + t.Run("multiple responses - circular", func(t *testing.T) { + entry := &replayEntry{ + responses: []string{"response1", "response2", "response3"}, + } + // First cycle + assert.Equal(t, "response1", entry.next()) + assert.Equal(t, "response2", entry.next()) + assert.Equal(t, "response3", entry.next()) + // Second cycle (circular buffer) + assert.Equal(t, "response1", entry.next()) + assert.Equal(t, "response2", entry.next()) + assert.Equal(t, "response3", entry.next()) + // Third cycle + assert.Equal(t, "response1", entry.next()) + }) + + t.Run("two responses - alternating", func(t *testing.T) { + entry := &replayEntry{ + responses: []string{"A", "B"}, + } + // Should alternate between A and B + assert.Equal(t, "A", entry.next()) + assert.Equal(t, "B", entry.next()) + assert.Equal(t, "A", entry.next()) + assert.Equal(t, "B", entry.next()) + assert.Equal(t, "A", entry.next()) + }) +}