From bed8bca96d5e9e654dd0d68796271808fba92b75 Mon Sep 17 00:00:00 2001 From: Marcin Tojek Date: Fri, 17 Apr 2026 09:53:29 +0200 Subject: [PATCH 1/3] feat: write error log when upstream request fails (#129) When the upstream request fails (e.g. network error, connection refused, timeout), apidump previously wrote only a .req.txt with no indication of what went wrong. Debugging required correlating logs from elsewhere. Now a .req_error.txt file is written alongside the request dump containing the error message, making it immediately clear why there is no response log. Also adds ANTHROPIC_BASE_URL env var support to the example server to make it easier to test failure scenarios locally. --- .gitignore | 3 +++ example/main.go | 3 ++- intercept/apidump/apidump.go | 17 +++++++++++++++- intercept/apidump/apidump_test.go | 34 +++++++++++++++++++++++++++++++ 4 files changed, 55 insertions(+), 2 deletions(-) diff --git a/.gitignore b/.gitignore index aff78ef6..ce234077 100644 --- a/.gitignore +++ b/.gitignore @@ -3,6 +3,9 @@ .idea **/*.swp +# AI tools +.claude + # Project specific example/aibridge.db build/ diff --git a/example/main.go b/example/main.go index b6011416..0a54aace 100644 --- a/example/main.go +++ b/example/main.go @@ -46,7 +46,8 @@ func main() { // Configure providers. providers := []aibridge.Provider{ aibridge.NewAnthropicProvider(aibridge.AnthropicConfig{ - Key: os.Getenv("ANTHROPIC_API_KEY"), + Key: os.Getenv("ANTHROPIC_API_KEY"), + BaseURL: os.Getenv("ANTHROPIC_BASE_URL"), }, nil), aibridge.NewOpenAIProvider(aibridge.OpenAIConfig{ Key: os.Getenv("OPENAI_API_KEY"), diff --git a/intercept/apidump/apidump.go b/intercept/apidump/apidump.go index 9f6f5ec3..63fccf7d 100644 --- a/intercept/apidump/apidump.go +++ b/intercept/apidump/apidump.go @@ -26,6 +26,8 @@ const ( SuffixRequest = ".req.txt" // SuffixResponse is the file suffix for response dump files. SuffixResponse = ".resp.txt" + // SuffixError is the file suffix for error dump files written when a request fails. + SuffixError = ".req_error.txt" ) // 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 logger.Named("apidump").Warn(req.Context(), "failed to dump request", slog.Error(err)) } - // TODO: https://github.com/coder/aibridge/issues/129 resp, err := next(req) if err != nil { + if dumpErr := d.dumpError(err); dumpErr != nil { + logger.Named("apidump").Warn(req.Context(), "failed to dump request error", slog.Error(dumpErr)) + } return resp, err } @@ -113,6 +117,14 @@ func (d *dumper) dumpRequest(req *http.Request) error { return os.WriteFile(dumpPath, buf.Bytes(), 0o644) //nolint:gosec // https://github.com/coder/aibridge/pull/256#discussion_r3072143983 } +func (d *dumper) dumpError(reqErr error) error { + dumpPath := d.dumpPath + SuffixError + if err := os.MkdirAll(filepath.Dir(dumpPath), 0o755); err != nil { + return xerrors.Errorf("create dump dir: %w", err) + } + return os.WriteFile(dumpPath, []byte(reqErr.Error()+"\n"), 0o644) //nolint:gosec // same rationale as other dump files +} + func (d *dumper) dumpResponse(resp *http.Response) error { dumpPath := d.dumpPath + SuffixResponse @@ -248,6 +260,9 @@ func (rt *dumpRoundTripper) RoundTrip(req *http.Request) (*http.Response, error) resp, err := rt.inner.RoundTrip(req) if err != nil { + if dumpErr := dumper.dumpError(err); dumpErr != nil { + dumper.logger.Named("apidump").Warn(req.Context(), "failed to dump passthrough request error", slog.Error(dumpErr)) + } return resp, err } diff --git a/intercept/apidump/apidump_test.go b/intercept/apidump/apidump_test.go index 1aaf56c0..260ccf64 100644 --- a/intercept/apidump/apidump_test.go +++ b/intercept/apidump/apidump_test.go @@ -147,6 +147,34 @@ func TestBridgedMiddleware_RedactsSensitiveResponseHeaders(t *testing.T) { require.Contains(t, content, "X-Request-Id: req-123") } +func TestBridgedMiddleware_WritesErrorFile_WhenNextFails(t *testing.T) { + t.Parallel() + + tmpDir := t.TempDir() + logger := slogtest.Make(t, &slogtest.Options{IgnoreErrors: true}).Leveled(slog.LevelDebug) + clk := quartz.NewMock(t) + interceptionID := uuid.New() + + middleware := NewBridgeMiddleware(tmpDir, "openai", "gpt-4", interceptionID, logger, clk) + require.NotNil(t, middleware) + + req, err := http.NewRequestWithContext(t.Context(), http.MethodPost, "https://api.openai.com/v1/chat/completions", bytes.NewReader([]byte(`{}`))) + require.NoError(t, err) + + upstreamErr := io.ErrUnexpectedEOF + resp, err := middleware(req, func(_ *http.Request) (*http.Response, error) { + return nil, upstreamErr + }) + require.ErrorIs(t, err, upstreamErr) + require.Nil(t, resp) + + modelDir := filepath.Join(tmpDir, "openai", "gpt-4") + errDumpPath := findDumpFile(t, modelDir, SuffixError) + content, readErr := os.ReadFile(errDumpPath) + require.NoError(t, readErr) + require.Contains(t, string(content), upstreamErr.Error()) +} + func TestBridgedMiddleware_EmptyBaseDir_ReturnsNil(t *testing.T) { t.Parallel() @@ -365,6 +393,12 @@ func TestPassthroughMiddleware(t *testing.T) { resp, err := rt.RoundTrip(req) //nolint:bodyclose // resp is nil on error require.ErrorIs(t, err, innerErr) require.Nil(t, resp) + + passthroughDir := filepath.Join(tmpDir, "openai", "passthrough") + errDumpPath := findDumpFile(t, passthroughDir, SuffixError) + content, readErr := os.ReadFile(errDumpPath) + require.NoError(t, readErr) + require.Contains(t, string(content), innerErr.Error()) }) t.Run("dumps_request_and_response", func(t *testing.T) { From 135332d3c6723585fcf53d2e07c43685679bc39f Mon Sep 17 00:00:00 2001 From: Marcin Tojek Date: Fri, 17 Apr 2026 09:57:29 +0200 Subject: [PATCH 2/3] chore: fix bodyclose linter warning in apidump test --- intercept/apidump/apidump_test.go | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/intercept/apidump/apidump_test.go b/intercept/apidump/apidump_test.go index 260ccf64..522caa7d 100644 --- a/intercept/apidump/apidump_test.go +++ b/intercept/apidump/apidump_test.go @@ -162,7 +162,7 @@ func TestBridgedMiddleware_WritesErrorFile_WhenNextFails(t *testing.T) { require.NoError(t, err) upstreamErr := io.ErrUnexpectedEOF - resp, err := middleware(req, func(_ *http.Request) (*http.Response, error) { + resp, err := middleware(req, func(_ *http.Request) (*http.Response, error) { //nolint:bodyclose // resp is nil on error return nil, upstreamErr }) require.ErrorIs(t, err, upstreamErr) From 362fc49e7163048c31f773a90a9d3022a81e364d Mon Sep 17 00:00:00 2001 From: Marcin Tojek Date: Fri, 17 Apr 2026 12:04:19 +0200 Subject: [PATCH 3/3] fix: ignore errors --- intercept/apidump/apidump_test.go | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/intercept/apidump/apidump_test.go b/intercept/apidump/apidump_test.go index 522caa7d..f3fb30e6 100644 --- a/intercept/apidump/apidump_test.go +++ b/intercept/apidump/apidump_test.go @@ -151,7 +151,7 @@ func TestBridgedMiddleware_WritesErrorFile_WhenNextFails(t *testing.T) { t.Parallel() tmpDir := t.TempDir() - logger := slogtest.Make(t, &slogtest.Options{IgnoreErrors: true}).Leveled(slog.LevelDebug) + logger := slogtest.Make(t, &slogtest.Options{IgnoreErrors: false}).Leveled(slog.LevelDebug) clk := quartz.NewMock(t) interceptionID := uuid.New()