mirror of
https://github.com/vxcontrol/langchaingo.git
synced 2026-07-19 21:54:17 -04:00
httputil: Implement circular buffer for identical requests in httprr
- Added support for recording and replaying multiple identical requests, allowing each to return different responses in a defined order. - Introduced `replayEntry` struct to manage response cycling for identical requests. - Updated documentation to reflect new circular buffer behavior and usage examples for cache testing scenarios. - Enhanced tests to validate the circular response behavior for both GET and POST requests. This change improves the testing capabilities for scenarios where identical requests yield varying responses, such as caching mechanisms.
This commit is contained in:
@@ -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
|
||||
|
||||
|
||||
@@ -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=<regexp>`: 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
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
})
|
||||
}
|
||||
+43
-4
@@ -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),
|
||||
)
|
||||
}
|
||||
|
||||
@@ -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())
|
||||
})
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user