Skip to content

Commit 10b9828

Browse files
committed
fix(restyutil): apply 3-reviewer findings (drop WithRetries, add OnError + WithTransport, log hygiene)
Three reviewers (bug / code-quality / senior-engineer) consolidated the same actionable items: - Drop WithRetries entirely. Resty's default retry condition only fires on transport errors, not 5xx, so the option was misleading without an accompanying retry-condition helper. YAGNI-applied: no current call site needs retries; bring it back when a real caller has a concrete retry policy in mind. - Rename WithRetries' max parameter -> moot (option dropped). The shadow of the Go 1.21 builtin `max` is no longer present. - Add OnError hook so transport errors (connect refused, DNS, ctx deadline, TLS) log too. Without it the helper REGRESSED observability vs. the raw net/http boilerplate it replaces. - Add WithTransport(http.RoundTripper) Option. Wraps the supplied transport with otelhttp so OTel propagation is preserved by construction, even with custom TLS / proxy / dialer. Required before pkg/oidc (which needs InsecureSkipVerify in dev) can migrate. - Strip query string from URL in slog log fields. URLs can carry ?token=... or ?api_key=... in the wild; CLAUDE.md "Never log tokens, passwords, or full message bodies". - Propagate request_id from ctx into the slog line via natsutil.RequestIDFromContext. CLAUDE.md "include in all log lines". Tests cover OnError firing on transport error, WithTransport preserving the custom round-tripper, request_id propagation, and query-string stripping.
1 parent eeb4e72 commit 10b9828

2 files changed

Lines changed: 133 additions & 32 deletions

File tree

pkg/restyutil/restyutil.go

Lines changed: 45 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
1-
// Package restyutil constructs a resty client wired with codebase defaults: OTel transport, slog response logging, 30s timeout.
1+
// Package restyutil constructs a resty client with codebase defaults: OTel transport, slog request/response logging, 30s timeout.
22
package restyutil
33

44
import (
@@ -8,8 +8,11 @@ import (
88

99
"github.com/go-resty/resty/v2"
1010
"go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp"
11+
12+
"github.com/hmchangw/chat/pkg/natsutil"
1113
)
1214

15+
// 30s suits arbitrary downstream HTTP, distinct from the 5s probe in mongoutil/valkeyutil.
1316
const defaultTimeout = 30 * time.Second
1417

1518
type Option func(*resty.Client)
@@ -26,11 +29,9 @@ func WithBearerToken(token string) Option {
2629
return func(c *resty.Client) { c.SetAuthToken(token) }
2730
}
2831

29-
// WithRetries configures n retries with exponential backoff between wait and max.
30-
func WithRetries(n int, wait, max time.Duration) Option {
31-
return func(c *resty.Client) {
32-
c.SetRetryCount(n).SetRetryWaitTime(wait).SetRetryMaxWaitTime(max)
33-
}
32+
// WithTransport replaces the default transport; OTel is wrapped on for you.
33+
func WithTransport(rt http.RoundTripper) Option {
34+
return func(c *resty.Client) { c.SetTransport(otelhttp.NewTransport(rt)) }
3435
}
3536

3637
func New(baseURL string, opts ...Option) *resty.Client {
@@ -39,18 +40,47 @@ func New(baseURL string, opts ...Option) *resty.Client {
3940
SetTimeout(defaultTimeout).
4041
SetTransport(otelhttp.NewTransport(http.DefaultTransport))
4142

42-
c.OnAfterResponse(func(_ *resty.Client, resp *resty.Response) error {
43-
slog.Debug("http response",
44-
"method", resp.Request.Method,
45-
"url", resp.Request.URL,
46-
"status", resp.StatusCode(),
47-
"duration_ms", resp.Time().Milliseconds(),
48-
)
49-
return nil
50-
})
43+
c.OnAfterResponse(logResponse)
44+
c.OnError(logError)
5145

5246
for _, opt := range opts {
5347
opt(c)
5448
}
5549
return c
5650
}
51+
52+
func logResponse(_ *resty.Client, resp *resty.Response) error {
53+
slog.Debug("http response", logFields(resp.Request, resp.StatusCode(), resp.Time(), nil)...)
54+
return nil
55+
}
56+
57+
func logError(req *resty.Request, err error) {
58+
slog.Debug("http error", logFields(req, 0, time.Since(req.Time), err)...)
59+
}
60+
61+
func logFields(req *resty.Request, status int, dur time.Duration, err error) []any {
62+
// Strip RawQuery from URL — query strings can carry tokens (CLAUDE.md: never log tokens).
63+
host, path := "", req.URL
64+
if rr := req.RawRequest; rr != nil && rr.URL != nil {
65+
host = rr.URL.Host
66+
path = rr.URL.Path
67+
}
68+
fields := []any{
69+
"method", req.Method,
70+
"host", host,
71+
"path", path,
72+
"duration_ms", dur.Milliseconds(),
73+
}
74+
if rr := req.RawRequest; rr != nil {
75+
if id := natsutil.RequestIDFromContext(rr.Context()); id != "" {
76+
fields = append(fields, "request_id", id)
77+
}
78+
}
79+
if status > 0 {
80+
fields = append(fields, "status", status)
81+
}
82+
if err != nil {
83+
fields = append(fields, "error", err.Error())
84+
}
85+
return fields
86+
}

pkg/restyutil/restyutil_test.go

Lines changed: 88 additions & 17 deletions
Original file line numberDiff line numberDiff line change
@@ -1,14 +1,22 @@
11
package restyutil
22

33
import (
4+
"bytes"
5+
"context"
6+
"log/slog"
7+
"net"
48
"net/http"
59
"net/http/httptest"
10+
"strings"
11+
"sync/atomic"
612
"testing"
713
"time"
814

915
"github.com/go-resty/resty/v2"
1016
"github.com/stretchr/testify/assert"
1117
"github.com/stretchr/testify/require"
18+
19+
"github.com/hmchangw/chat/pkg/natsutil"
1220
)
1321

1422
func TestNew_DefaultTimeout(t *testing.T) {
@@ -44,11 +52,21 @@ func TestWithBearerToken(t *testing.T) {
4452
assert.Equal(t, "Bearer abc123", got)
4553
}
4654

47-
func TestWithRetries(t *testing.T) {
48-
c := New("http://example", WithRetries(3, 10*time.Millisecond, 100*time.Millisecond))
49-
assert.Equal(t, 3, c.RetryCount)
50-
assert.Equal(t, 10*time.Millisecond, c.RetryWaitTime)
51-
assert.Equal(t, 100*time.Millisecond, c.RetryMaxWaitTime)
55+
func TestWithTransport_PreservesOTelWrap(t *testing.T) {
56+
var hits atomic.Int32
57+
custom := roundTripperFunc(func(r *http.Request) (*http.Response, error) {
58+
hits.Add(1)
59+
return http.DefaultTransport.RoundTrip(r)
60+
})
61+
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
62+
w.WriteHeader(http.StatusOK)
63+
}))
64+
defer srv.Close()
65+
66+
c := New(srv.URL, WithTransport(custom))
67+
_, err := c.R().Get("/")
68+
require.NoError(t, err)
69+
assert.Equal(t, int32(1), hits.Load(), "custom transport must run")
5270
}
5371

5472
// End-to-end: real httptest server, GET, JSON decode into a struct.
@@ -74,22 +92,75 @@ func TestNew_RoundTrip(t *testing.T) {
7492
assert.Equal(t, "pong", got.Msg)
7593
}
7694

77-
// Verify retries actually fire on 5xx responses when WithRetries is set.
78-
func TestWithRetries_FiresOn5xx(t *testing.T) {
79-
var hits int
95+
// Verify the Debug log line includes request_id when the ctx carries it.
96+
func TestLog_PropagatesRequestID(t *testing.T) {
97+
var buf bytes.Buffer
98+
prev := slog.Default()
99+
slog.SetDefault(slog.New(slog.NewTextHandler(&buf, &slog.HandlerOptions{Level: slog.LevelDebug})))
100+
defer slog.SetDefault(prev)
101+
80102
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
81-
hits++
82-
w.WriteHeader(http.StatusInternalServerError)
103+
w.WriteHeader(http.StatusOK)
83104
}))
84105
defer srv.Close()
85106

86-
c := New(srv.URL, WithRetries(2, time.Millisecond, 5*time.Millisecond)).
87-
AddRetryCondition(func(r *resty.Response, _ error) bool {
88-
return r.StatusCode() >= 500
89-
})
107+
ctx := natsutil.WithRequestID(context.Background(), "req-abc-123")
108+
c := New(srv.URL)
109+
_, err := c.R().SetContext(ctx).Get("/x?token=secret")
110+
require.NoError(t, err)
111+
112+
out := buf.String()
113+
assert.Contains(t, out, "request_id=req-abc-123")
114+
assert.NotContains(t, out, "secret", "query string must be stripped from logs")
115+
}
116+
117+
// Verify OnError fires on transport failure (not just OnAfterResponse).
118+
func TestLog_FiresOnTransportError(t *testing.T) {
119+
var buf bytes.Buffer
120+
prev := slog.Default()
121+
slog.SetDefault(slog.New(slog.NewTextHandler(&buf, &slog.HandlerOptions{Level: slog.LevelDebug})))
122+
defer slog.SetDefault(prev)
90123

91-
resp, err := c.R().Get("/")
124+
// Bind+close a port to guarantee connect-refused.
125+
l, err := net.Listen("tcp", "127.0.0.1:0")
92126
require.NoError(t, err)
93-
assert.Equal(t, http.StatusInternalServerError, resp.StatusCode())
94-
assert.Equal(t, 3, hits, "1 initial + 2 retries")
127+
addr := l.Addr().String()
128+
require.NoError(t, l.Close())
129+
130+
c := New("http://"+addr, WithTimeout(500*time.Millisecond))
131+
_, err = c.R().Get("/")
132+
require.Error(t, err)
133+
assert.Contains(t, buf.String(), "http error")
134+
}
135+
136+
type roundTripperFunc func(*http.Request) (*http.Response, error)
137+
138+
func (f roundTripperFunc) RoundTrip(r *http.Request) (*http.Response, error) { return f(r) }
139+
140+
// Sanity: log fields construction doesn't panic on a nil RawRequest (rare; defensive guard).
141+
func TestLogFields_NilRawRequest(t *testing.T) {
142+
req := (&resty.Client{}).R()
143+
fields := logFields(req, 200, 5*time.Millisecond, nil)
144+
require.NotEmpty(t, fields)
145+
assert.Equal(t, "method", fields[0])
146+
// Should NOT panic and should not include path-from-RawRequest (since RawRequest is nil).
147+
joined := strings.Join(toStrings(fields), " ")
148+
assert.NotContains(t, joined, "panic")
149+
}
150+
151+
func toStrings(in []any) []string {
152+
out := make([]string, len(in))
153+
for i, v := range in {
154+
out[i] = fmtAny(v)
155+
}
156+
return out
157+
}
158+
159+
func fmtAny(v any) string {
160+
switch s := v.(type) {
161+
case string:
162+
return s
163+
default:
164+
return ""
165+
}
95166
}

0 commit comments

Comments
 (0)