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
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:
+21
-1
@@ -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
@@ -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)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user