fix(gitea): address MINOR review findings on retry logic
PR Ready Gate / clear-labels (pull_request) Successful in 2s
CI / test (pull_request) Successful in 17s
CI / review (anthropic--claude-4.6-sonnet, sonnet, SONNET_REVIEW_TOKEN) (pull_request) Successful in 39s
CI / review (gpt-5, security, SECURITY_REVIEW.md, SECURITY_REVIEW_TOKEN) (pull_request) Successful in 50s
CI / review (gpt-5, gpt, GPT_REVIEW_TOKEN) (pull_request) Successful in 1m28s

1. Fix non-deterministic test TestDoGet_RetriesOnTemporaryNetError:
   - Replace timing-dependent listener approach with mockTransport
   - mockTransport allows controlled injection of net.OpError failures
   - Test now makes deterministic assertions: exactly 3 attempts (2 fail + 1 success)
   - Added SetHTTPClient() method for test transport injection

2. Sanitize error content in retry warning logs:
   - Added sanitizeErrorForLog() helper that omits response body content
   - For APIError: logs only 'HTTP <status>' instead of full body
   - For other errors: preserves error type information
   - Addresses security concern about logging server error content at WARN level
   - Full error with body still returned to caller for proper error handling

Both changes have corresponding test coverage.
This commit is contained in:
Rodin
2026-05-11 05:15:07 -07:00
parent ecfbfddc7c
commit 5498dccd60
2 changed files with 111 additions and 37 deletions
+21 -1
View File
@@ -72,6 +72,12 @@ func NewClient(baseURL, token string) *Client {
} }
} }
// SetHTTPClient sets the underlying HTTP client used for requests.
// This is intended for testing to inject mock transports.
func (c *Client) SetHTTPClient(hc *http.Client) {
c.http = hc
}
// PullRequest holds relevant PR metadata. // PullRequest holds relevant PR metadata.
type PullRequest struct { type PullRequest struct {
Title string `json:"title"` Title string `json:"title"`
@@ -302,6 +308,20 @@ func redactURL(rawURL string) string {
return parsed.String() return parsed.String()
} }
// sanitizeErrorForLog returns a loggable version of an error that omits
// potentially sensitive content like response bodies. For APIError, only
// the status code is included; for other errors, the type is preserved.
func sanitizeErrorForLog(err error) string {
if err == nil {
return "<nil>"
}
var apiErr *APIError
if errors.As(err, &apiErr) {
return fmt.Sprintf("HTTP %d", apiErr.StatusCode)
}
return err.Error()
}
// doGet performs an HTTP GET request with retry on 5xx errors and temporary // doGet performs an HTTP GET request with retry on 5xx errors and temporary
// network errors. Retries up to 3 times with exponential backoff (1s, 2s delays // network errors. Retries up to 3 times with exponential backoff (1s, 2s delays
// by default; configurable via Client.RetryBackoff for testing). // by default; configurable via Client.RetryBackoff for testing).
@@ -334,7 +354,7 @@ func (c *Client) doGet(ctx context.Context, reqURL string) ([]byte, error) {
"attempt", attempt+1, "attempt", attempt+1,
"url", redactURL(reqURL), "url", redactURL(reqURL),
"delay", delay.String(), "delay", delay.String(),
"lastError", lastErr) "lastError", sanitizeErrorForLog(lastErr))
timer := time.NewTimer(delay) timer := time.NewTimer(delay)
select { select {
+90 -36
View File
@@ -10,6 +10,7 @@ import (
"net/http" "net/http"
"net/http/httptest" "net/http/httptest"
"strings" "strings"
"sync/atomic"
"syscall" "syscall"
"testing" "testing"
"time" "time"
@@ -891,49 +892,60 @@ func TestDoGet_RespectsContextCancellation(t *testing.T) {
} }
} }
func TestDoGet_RetriesOnTemporaryNetError(t *testing.T) {
attempts := 0
// Create a listener that we can close to simulate connection refused // mockTransport is a test helper that returns errors for the first N calls,
ln, err := net.Listen("tcp", "127.0.0.1:0") // then delegates to a real server.
if err != nil { type mockTransport struct {
t.Fatalf("failed to create listener: %v", err) failCount int32 // number of failures remaining (atomic)
failErr error // error to return on failure
realServer *httptest.Server
attemptsMade atomic.Int32 // tracks total attempts
}
func (m *mockTransport) RoundTrip(req *http.Request) (*http.Response, error) {
m.attemptsMade.Add(1)
remaining := atomic.AddInt32(&m.failCount, -1)
if remaining >= 0 {
// Still have failures to return
return nil, m.failErr
} }
addr := ln.Addr().String() // Redirect to real server
req.URL.Host = m.realServer.Listener.Addr().String()
req.URL.Scheme = "http"
return http.DefaultTransport.RoundTrip(req)
}
// Close immediately to cause connection refused on first attempts func TestDoGet_RetriesOnTemporaryNetError(t *testing.T) {
ln.Close() // Real server that will handle successful requests
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.WriteHeader(http.StatusOK)
w.Write([]byte(`{"status":"ok"}`))
}))
defer server.Close()
// Start a server after a short delay to succeed on retry // Mock transport: fail twice with ECONNREFUSED, then succeed
go func() { mt := &mockTransport{
time.Sleep(5 * time.Millisecond) failCount: 2,
newLn, err := net.Listen("tcp", addr) failErr: &net.OpError{Op: "dial", Net: "tcp", Err: syscall.ECONNREFUSED},
if err != nil { realServer: server,
// Port might be reused; not critical for this test }
return
}
defer newLn.Close()
for { client := NewClient("http://fake-host/", "test-token")
conn, err := newLn.Accept() client.SetHTTPClient(&http.Client{Transport: mt})
if err != nil { client.RetryBackoff = []time.Duration{1 * time.Millisecond, 1 * time.Millisecond}
return
}
attempts++
// Respond with success
conn.Write([]byte("HTTP/1.1 200 OK\r\nContent-Length: 2\r\n\r\nok"))
conn.Close()
}
}()
client := NewClient("http://"+addr, "test-token") body, err := client.doGet(context.Background(), "http://fake-host/test")
client.RetryBackoff = []time.Duration{10 * time.Millisecond, 10 * time.Millisecond} if err != nil {
t.Fatalf("expected success after retries, got error: %v", err)
}
if string(body) != `{"status":"ok"}` {
t.Errorf("body = %q, want %q", string(body), `{"status":"ok"}`)
}
// The request might succeed or fail depending on timing, but the key is // Should have made exactly 3 attempts: 2 failures + 1 success
// that we attempt retry on connection refused if got := mt.attemptsMade.Load(); got != 3 {
_, _ = client.doGet(context.Background(), "http://"+addr+"/test") t.Errorf("attempts = %d, want 3 (2 failures + 1 success)", got)
}
// This test verifies the code path exists; actual retry behavior depends on timing
} }
func TestIsTemporaryNetError(t *testing.T) { func TestIsTemporaryNetError(t *testing.T) {
@@ -1037,3 +1049,45 @@ func TestRedactURL(t *testing.T) {
}) })
} }
} }
func TestSanitizeErrorForLog(t *testing.T) {
tests := []struct {
name string
err error
want string
}{
{
name: "nil error",
err: nil,
want: "<nil>",
},
{
name: "APIError omits body",
err: &APIError{StatusCode: 500, Body: "internal error: database connection failed"},
want: "HTTP 500",
},
{
name: "APIError with large body still only shows status",
err: &APIError{StatusCode: 502, Body: strings.Repeat("x", 1000)},
want: "HTTP 502",
},
{
name: "non-API error preserved",
err: fmt.Errorf("connection refused"),
want: "connection refused",
},
{
name: "wrapped APIError",
err: fmt.Errorf("request failed: %w", &APIError{StatusCode: 503, Body: "service unavailable"}),
want: "HTTP 503",
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
got := sanitizeErrorForLog(tt.err)
if got != tt.want {
t.Errorf("sanitizeErrorForLog() = %q, want %q", got, tt.want)
}
})
}
}