Skip to content

Commit 8bdd397

Browse files
authored
feat: write error log when upstream request fails (#268)
1 parent 51228ce commit 8bdd397

4 files changed

Lines changed: 55 additions & 2 deletions

File tree

.gitignore

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,9 @@
33
.idea
44
**/*.swp
55

6+
# AI tools
7+
.claude
8+
69
# Project specific
710
example/aibridge.db
811
AGENTS.local.md

example/main.go

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -46,7 +46,8 @@ func main() {
4646
// Configure providers.
4747
providers := []aibridge.Provider{
4848
aibridge.NewAnthropicProvider(aibridge.AnthropicConfig{
49-
Key: os.Getenv("ANTHROPIC_API_KEY"),
49+
Key: os.Getenv("ANTHROPIC_API_KEY"),
50+
BaseURL: os.Getenv("ANTHROPIC_BASE_URL"),
5051
}, nil),
5152
aibridge.NewOpenAIProvider(aibridge.OpenAIConfig{
5253
Key: os.Getenv("OPENAI_API_KEY"),

intercept/apidump/apidump.go

Lines changed: 16 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -26,6 +26,8 @@ const (
2626
SuffixRequest = ".req.txt"
2727
// SuffixResponse is the file suffix for response dump files.
2828
SuffixResponse = ".resp.txt"
29+
// SuffixError is the file suffix for error dump files written when a request fails.
30+
SuffixError = ".req_error.txt"
2931
)
3032

3133
// MiddlewareNext is the function to call the next middleware or the actual request.
@@ -51,9 +53,11 @@ func NewBridgeMiddleware(baseDir string, provider string, model string, intercep
5153
logger.Named("apidump").Warn(req.Context(), "failed to dump request", slog.Error(err))
5254
}
5355

54-
// TODO: https://github.com/coder/aibridge/issues/129
5556
resp, err := next(req)
5657
if err != nil {
58+
if dumpErr := d.dumpError(err); dumpErr != nil {
59+
logger.Named("apidump").Warn(req.Context(), "failed to dump request error", slog.Error(dumpErr))
60+
}
5761
return resp, err
5862
}
5963

@@ -113,6 +117,14 @@ func (d *dumper) dumpRequest(req *http.Request) error {
113117
return os.WriteFile(dumpPath, buf.Bytes(), 0o644) //nolint:gosec // https://github.com/coder/aibridge/pull/256#discussion_r3072143983
114118
}
115119

120+
func (d *dumper) dumpError(reqErr error) error {
121+
dumpPath := d.dumpPath + SuffixError
122+
if err := os.MkdirAll(filepath.Dir(dumpPath), 0o755); err != nil {
123+
return xerrors.Errorf("create dump dir: %w", err)
124+
}
125+
return os.WriteFile(dumpPath, []byte(reqErr.Error()+"\n"), 0o644) //nolint:gosec // same rationale as other dump files
126+
}
127+
116128
func (d *dumper) dumpResponse(resp *http.Response) error {
117129
dumpPath := d.dumpPath + SuffixResponse
118130

@@ -248,6 +260,9 @@ func (rt *dumpRoundTripper) RoundTrip(req *http.Request) (*http.Response, error)
248260

249261
resp, err := rt.inner.RoundTrip(req)
250262
if err != nil {
263+
if dumpErr := dumper.dumpError(err); dumpErr != nil {
264+
dumper.logger.Named("apidump").Warn(req.Context(), "failed to dump passthrough request error", slog.Error(dumpErr))
265+
}
251266
return resp, err
252267
}
253268

intercept/apidump/apidump_test.go

Lines changed: 34 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -147,6 +147,34 @@ func TestBridgedMiddleware_RedactsSensitiveResponseHeaders(t *testing.T) {
147147
require.Contains(t, content, "X-Request-Id: req-123")
148148
}
149149

150+
func TestBridgedMiddleware_WritesErrorFile_WhenNextFails(t *testing.T) {
151+
t.Parallel()
152+
153+
tmpDir := t.TempDir()
154+
logger := slogtest.Make(t, &slogtest.Options{IgnoreErrors: false}).Leveled(slog.LevelDebug)
155+
clk := quartz.NewMock(t)
156+
interceptionID := uuid.New()
157+
158+
middleware := NewBridgeMiddleware(tmpDir, "openai", "gpt-4", interceptionID, logger, clk)
159+
require.NotNil(t, middleware)
160+
161+
req, err := http.NewRequestWithContext(t.Context(), http.MethodPost, "https://api.openai.com/v1/chat/completions", bytes.NewReader([]byte(`{}`)))
162+
require.NoError(t, err)
163+
164+
upstreamErr := io.ErrUnexpectedEOF
165+
resp, err := middleware(req, func(_ *http.Request) (*http.Response, error) { //nolint:bodyclose // resp is nil on error
166+
return nil, upstreamErr
167+
})
168+
require.ErrorIs(t, err, upstreamErr)
169+
require.Nil(t, resp)
170+
171+
modelDir := filepath.Join(tmpDir, "openai", "gpt-4")
172+
errDumpPath := findDumpFile(t, modelDir, SuffixError)
173+
content, readErr := os.ReadFile(errDumpPath)
174+
require.NoError(t, readErr)
175+
require.Contains(t, string(content), upstreamErr.Error())
176+
}
177+
150178
func TestBridgedMiddleware_EmptyBaseDir_ReturnsNil(t *testing.T) {
151179
t.Parallel()
152180

@@ -365,6 +393,12 @@ func TestPassthroughMiddleware(t *testing.T) {
365393
resp, err := rt.RoundTrip(req) //nolint:bodyclose // resp is nil on error
366394
require.ErrorIs(t, err, innerErr)
367395
require.Nil(t, resp)
396+
397+
passthroughDir := filepath.Join(tmpDir, "openai", "passthrough")
398+
errDumpPath := findDumpFile(t, passthroughDir, SuffixError)
399+
content, readErr := os.ReadFile(errDumpPath)
400+
require.NoError(t, readErr)
401+
require.Contains(t, string(content), innerErr.Error())
368402
})
369403

370404
t.Run("dumps_request_and_response", func(t *testing.T) {

0 commit comments

Comments
 (0)