Skip to content

Commit ff3adbc

Browse files
ofalvaiclaude
andauthored
Track per-attempt HTTP metrics (#296)
Implemented per-attempt tracking for HTTP requests to eliminate retry inflation in duration metrics. Each HTTP attempt (including retries) now generates a separate metric event with an `is_retry` flag. Changes: - Added trackingRoundTripper wrapper that measures per-attempt duration - Updated Tracker.TrackAPIRequest() to include isRetry boolean parameter - Integrated with retryablehttp RequestLogHook to mark retry attempts - Removed aggregate duration tracking from Client.Do() - All attempts now report metrics independently This allows accurate alerting based on individual response times rather than aggregate times that include retry backoff. 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-authored-by: Claude <[email protected]>
1 parent e1380c0 commit ff3adbc

File tree

7 files changed

+223
-24
lines changed

7 files changed

+223
-24
lines changed

_integration_tests/appstoreconnect_tests/appstoreconnect_test.go

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -10,7 +10,8 @@ import (
1010
func TestListBundleIDs(t *testing.T) {
1111
keyID, issuerID, privateKey, enterpriseAccount := getAPIKey(t)
1212

13-
client := appstoreconnect.NewClient(appstoreconnect.NewRetryableHTTPClient(), keyID, issuerID, []byte(privateKey), enterpriseAccount, appstoreconnect.NoOpAnalyticsTracker{})
13+
tracker := appstoreconnect.NoOpAnalyticsTracker{}
14+
client := appstoreconnect.NewClient(appstoreconnect.NewRetryableHTTPClient(tracker), keyID, issuerID, []byte(privateKey), enterpriseAccount, tracker)
1415

1516
response, err := client.Provisioning.ListBundleIDs(&appstoreconnect.ListBundleIDsOptions{})
1617
require.NoError(t, err)

autocodesign/devportalclient/appstoreconnect/appstoreconnect.go

Lines changed: 17 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -73,8 +73,23 @@ type Client struct {
7373
}
7474

7575
// NewRetryableHTTPClient create a new http client with retry settings.
76-
func NewRetryableHTTPClient() *http.Client {
76+
func NewRetryableHTTPClient(tracker Tracker) *http.Client {
7777
client := retry.NewHTTPClient()
78+
79+
trackingTransport := newTrackingRoundTripper(client.HTTPClient.Transport, tracker)
80+
client.HTTPClient.Transport = trackingTransport
81+
82+
// RequestLogHook is called before each retry (attemptNum > 0 for retries, 0 for initial request).
83+
// We mark retry attempts in the request context so RoundTrip can track which attempts are retries.
84+
// We use pointer dereference (*req = *...) to modify the request in-place because RequestLogHook
85+
// doesn't return a value - it modifies the request through side effects. This updates the request's
86+
// context field, which will be present when RoundTrip is called immediately after.
87+
client.RequestLogHook = func(_ retryablehttp.Logger, req *http.Request, attemptNum int) {
88+
if attemptNum > 0 {
89+
*req = *trackingTransport.markAsRetry(req)
90+
}
91+
}
92+
7893
client.CheckRetry = func(ctx context.Context, resp *http.Response, err error) (bool, error) {
7994
if resp != nil && resp.StatusCode == http.StatusUnauthorized {
8095
log.Debugf("Received HTTP 401 (Unauthorized), retrying request...")
@@ -115,6 +130,7 @@ func NewRetryableHTTPClient() *http.Client {
115130

116131
return shouldRetry, err
117132
}
133+
118134
return client.StandardClient()
119135
}
120136

@@ -245,8 +261,6 @@ func (c *Client) Debugf(format string, v ...interface{}) {
245261

246262
// Do ...
247263
func (c *Client) Do(req *http.Request, v interface{}) (*http.Response, error) {
248-
startTime := time.Now()
249-
250264
c.Debugf("Request:")
251265
if c.EnableDebugLogs {
252266
if err := httputil.PrintRequest(req); err != nil {
@@ -255,7 +269,6 @@ func (c *Client) Do(req *http.Request, v interface{}) (*http.Response, error) {
255269
}
256270

257271
resp, err := c.client.Do(req)
258-
duration := time.Since(startTime)
259272

260273
c.Debugf("Response:")
261274
if c.EnableDebugLogs {
@@ -276,13 +289,10 @@ func (c *Client) Do(req *http.Request, v interface{}) (*http.Response, error) {
276289
}()
277290

278291
if err := checkResponse(resp); err != nil {
279-
c.tracker.TrackAPIRequest(req.Method, req.URL.Host, req.URL.Path, resp.StatusCode, duration)
280292
c.tracker.TrackAPIError(req.Method, req.URL.Host, req.URL.Path, resp.StatusCode, err.Error())
281293
return resp, err
282294
}
283295

284-
c.tracker.TrackAPIRequest(req.Method, req.URL.Host, req.URL.Path, resp.StatusCode, duration)
285-
286296
if v != nil {
287297
decErr := json.NewDecoder(resp.Body).Decode(v)
288298
if decErr == io.EOF {

autocodesign/devportalclient/appstoreconnect/appstoreconnect_test.go

Lines changed: 29 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -17,7 +17,8 @@ import (
1717
)
1818

1919
func TestNewClient(t *testing.T) {
20-
got := NewClient(NewRetryableHTTPClient(), "keyID", "issuerID", []byte{}, false, NoOpAnalyticsTracker{})
20+
tracker := NoOpAnalyticsTracker{}
21+
got := NewClient(NewRetryableHTTPClient(tracker), "keyID", "issuerID", []byte{}, false, tracker)
2122

2223
require.Equal(t, "appstoreconnect-v1", got.audience)
2324

@@ -27,7 +28,8 @@ func TestNewClient(t *testing.T) {
2728
}
2829

2930
func TestNewEnterpriseClient(t *testing.T) {
30-
got := NewClient(NewRetryableHTTPClient(), "keyID", "issuerID", []byte{}, true, NoOpAnalyticsTracker{})
31+
tracker := NoOpAnalyticsTracker{}
32+
got := NewClient(NewRetryableHTTPClient(tracker), "keyID", "issuerID", []byte{}, true, tracker)
3133

3234
require.Equal(t, "apple-developer-enterprise-v1", got.audience)
3335

@@ -48,6 +50,7 @@ type apiRequestRecord struct {
4850
endpoint string
4951
statusCode int
5052
duration time.Duration
53+
isRetry bool
5154
}
5255

5356
type apiErrorRecord struct {
@@ -58,13 +61,14 @@ type apiErrorRecord struct {
5861
errorMessage string
5962
}
6063

61-
func (m *mockAnalyticsTracker) TrackAPIRequest(method, host, endpoint string, statusCode int, duration time.Duration) {
64+
func (m *mockAnalyticsTracker) TrackAPIRequest(method, host, endpoint string, statusCode int, duration time.Duration, isRetry bool) {
6265
m.apiRequests = append(m.apiRequests, apiRequestRecord{
6366
method: method,
6467
host: host,
6568
endpoint: endpoint,
6669
statusCode: statusCode,
6770
duration: duration,
71+
isRetry: isRetry,
6872
})
6973
}
7074

@@ -93,6 +97,11 @@ func (m *mockHTTPClient) Do(req *http.Request) (*http.Response, error) {
9397
return m.resp, m.err
9498
}
9599

100+
func (m *mockHTTPClient) RoundTrip(req *http.Request) (*http.Response, error) {
101+
m.called = true
102+
return m.resp, m.err
103+
}
104+
96105
func TestTracking(t *testing.T) {
97106
t.Run("successful request", func(t *testing.T) {
98107
mockTracker := &mockAnalyticsTracker{}
@@ -103,8 +112,11 @@ func TestTracking(t *testing.T) {
103112
}))
104113
defer server.Close()
105114

115+
httpClient := &http.Client{}
116+
httpClient.Transport = newTrackingRoundTripper(httpClient.Transport, mockTracker)
117+
106118
client := &Client{
107-
client: &http.Client{},
119+
client: httpClient,
108120
tracker: mockTracker,
109121
}
110122

@@ -132,16 +144,19 @@ func TestTracking(t *testing.T) {
132144

133145
t.Run("error response", func(t *testing.T) {
134146
mockTracker := &mockAnalyticsTracker{}
135-
mockHTTPClient := &mockHTTPClient{
147+
mockTransport := &mockHTTPClient{
136148
resp: &http.Response{
137149
StatusCode: 400,
138150
Body: io.NopCloser(strings.NewReader(`{"errors": [{"code": "PARAMETER_ERROR.INVALID", "title": "Invalid parameter"}]}`)),
139151
Header: http.Header{},
140152
},
141153
}
142154

155+
httpClient := &http.Client{}
156+
httpClient.Transport = newTrackingRoundTripper(mockTransport, mockTracker)
157+
143158
client := &Client{
144-
client: mockHTTPClient,
159+
client: httpClient,
145160
tracker: mockTracker,
146161
}
147162

@@ -150,7 +165,7 @@ func TestTracking(t *testing.T) {
150165
_, err = client.Do(req, nil)
151166
require.Error(t, err, "Expected error due to 400 Bad Request response")
152167

153-
require.True(t, mockHTTPClient.called, "Expected HTTP client to be called")
168+
require.True(t, mockTransport.called, "Expected HTTP client to be called")
154169

155170
require.Len(t, mockTracker.apiRequests, 1, "Expected 1 (failed) API requests tracked")
156171
require.Len(t, mockTracker.apiErrors, 1, "Expected 1 API error tracked")
@@ -163,12 +178,15 @@ func TestTracking(t *testing.T) {
163178
t.Run("network error", func(t *testing.T) {
164179
mockTracker := &mockAnalyticsTracker{}
165180

166-
mockHTTPClient := &mockHTTPClient{
181+
mockTransport := &mockHTTPClient{
167182
err: errors.New("network connection failed"),
168183
}
169184

185+
httpClient := &http.Client{}
186+
httpClient.Transport = newTrackingRoundTripper(mockTransport, mockTracker)
187+
170188
client := &Client{
171-
client: mockHTTPClient,
189+
client: httpClient,
172190
tracker: mockTracker,
173191
}
174192

@@ -177,12 +195,12 @@ func TestTracking(t *testing.T) {
177195
_, err = client.Do(req, nil)
178196
require.Error(t, err)
179197

180-
require.Len(t, mockTracker.apiRequests, 0, "Expected 0 API requests tracked")
198+
require.Len(t, mockTracker.apiRequests, 1, "Expected 1 API request tracked (even though it failed)")
181199
require.Len(t, mockTracker.apiErrors, 1, "Expected 1 API error tracked")
182200

183201
record := mockTracker.apiErrors[0]
184202
require.Equal(t, "GET", record.method)
185203
require.Equal(t, 0, record.statusCode)
186-
require.Equal(t, "network connection failed", record.errorMessage)
204+
require.Contains(t, record.errorMessage, "network connection failed")
187205
})
188206
}
Lines changed: 59 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,59 @@
1+
package appstoreconnect
2+
3+
import (
4+
"context"
5+
"net/http"
6+
"time"
7+
)
8+
9+
// trackingRoundTripper wraps an http.RoundTripper and tracks metrics for each HTTP attempt,
10+
// including retries. It measures per-attempt duration without retry wait times included,
11+
// allowing accurate tracking even when requests are retried due to rate limits or other errors.
12+
type trackingRoundTripper struct {
13+
wrapped http.RoundTripper
14+
tracker Tracker
15+
}
16+
17+
// isRetryContextKey is used to store whether a request is a retry attempt in the request context.
18+
type isRetryContextKey struct{}
19+
20+
func newTrackingRoundTripper(wrapped http.RoundTripper, tracker Tracker) *trackingRoundTripper {
21+
if wrapped == nil {
22+
wrapped = http.DefaultTransport
23+
}
24+
return &trackingRoundTripper{
25+
wrapped: wrapped,
26+
tracker: tracker,
27+
}
28+
}
29+
30+
// markAsRetry stores a flag in the request context indicating this is a retry attempt.
31+
// It returns a new request with the updated context. This approach avoids shared state
32+
// between concurrent requests (e.g., multiple POSTs to the same endpoint with different bodies).
33+
func (t *trackingRoundTripper) markAsRetry(req *http.Request) *http.Request {
34+
ctx := context.WithValue(req.Context(), isRetryContextKey{}, true)
35+
return req.WithContext(ctx)
36+
}
37+
38+
// RoundTrip executes an HTTP request and tracks its duration and retry status.
39+
// Each HTTP attempt (including retries) generates a separate metric event, allowing
40+
// accurate alerting based on individual response times rather than aggregate times
41+
// that include retry backoff delays.
42+
func (t *trackingRoundTripper) RoundTrip(req *http.Request) (*http.Response, error) {
43+
// Check if this request was marked as a retry by RequestLogHook
44+
isRetry := req.Context().Value(isRetryContextKey{}) != nil
45+
46+
startTime := time.Now()
47+
resp, err := t.wrapped.RoundTrip(req)
48+
duration := time.Since(startTime)
49+
50+
statusCode := 0
51+
if resp != nil {
52+
statusCode = resp.StatusCode
53+
}
54+
55+
// Track this attempt with its actual duration (no retry waits included)
56+
t.tracker.TrackAPIRequest(req.Method, req.URL.Host, req.URL.Path, statusCode, duration, isRetry)
57+
58+
return resp, err
59+
}
Lines changed: 110 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,110 @@
1+
package appstoreconnect
2+
3+
import (
4+
"net/http"
5+
"net/http/httptest"
6+
"sync"
7+
"testing"
8+
"time"
9+
10+
"github.com/stretchr/testify/require"
11+
)
12+
13+
type attemptTracker struct {
14+
mu sync.Mutex
15+
attempts []attemptRecord
16+
}
17+
18+
type attemptRecord struct {
19+
method string
20+
host string
21+
endpoint string
22+
statusCode int
23+
duration time.Duration
24+
isRetry bool
25+
}
26+
27+
func (a *attemptTracker) TrackAPIRequest(method, host, endpoint string, statusCode int, duration time.Duration, isRetry bool) {
28+
a.mu.Lock()
29+
defer a.mu.Unlock()
30+
a.attempts = append(a.attempts, attemptRecord{
31+
method: method,
32+
host: host,
33+
endpoint: endpoint,
34+
statusCode: statusCode,
35+
duration: duration,
36+
isRetry: isRetry,
37+
})
38+
}
39+
40+
func (a *attemptTracker) TrackAPIError(method, host, endpoint string, statusCode int, errorMessage string) {
41+
}
42+
43+
func (a *attemptTracker) TrackAuthError(errorMessage string) {
44+
}
45+
46+
func TestTrackingRoundTripper(t *testing.T) {
47+
t.Run("tracks single successful request", func(t *testing.T) {
48+
tracker := &attemptTracker{}
49+
50+
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
51+
w.WriteHeader(http.StatusOK)
52+
}))
53+
defer server.Close()
54+
55+
transport := newTrackingRoundTripper(http.DefaultTransport, tracker)
56+
client := &http.Client{Transport: transport}
57+
58+
req, err := http.NewRequest("GET", server.URL+"/test", nil)
59+
require.NoError(t, err)
60+
61+
resp, err := client.Do(req)
62+
require.NoError(t, err)
63+
require.Equal(t, http.StatusOK, resp.StatusCode)
64+
65+
tracker.mu.Lock()
66+
defer tracker.mu.Unlock()
67+
68+
require.Len(t, tracker.attempts, 1)
69+
require.False(t, tracker.attempts[0].isRetry)
70+
require.Equal(t, http.StatusOK, tracker.attempts[0].statusCode)
71+
})
72+
73+
t.Run("tracks multiple attempts for same request", func(t *testing.T) {
74+
tracker := &attemptTracker{}
75+
attemptCount := 0
76+
77+
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
78+
attemptCount++
79+
if attemptCount < 3 {
80+
w.WriteHeader(http.StatusTooManyRequests)
81+
} else {
82+
w.WriteHeader(http.StatusOK)
83+
}
84+
}))
85+
defer server.Close()
86+
87+
retryableClient := NewRetryableHTTPClient(tracker)
88+
89+
req, err := http.NewRequest("GET", server.URL+"/test", nil)
90+
require.NoError(t, err)
91+
92+
resp, err := retryableClient.Do(req)
93+
require.NoError(t, err)
94+
require.Equal(t, http.StatusOK, resp.StatusCode)
95+
96+
tracker.mu.Lock()
97+
defer tracker.mu.Unlock()
98+
99+
require.Len(t, tracker.attempts, 3, "Expected 3 attempts to be tracked")
100+
101+
require.False(t, tracker.attempts[0].isRetry)
102+
require.Equal(t, http.StatusTooManyRequests, tracker.attempts[0].statusCode)
103+
104+
require.True(t, tracker.attempts[1].isRetry)
105+
require.Equal(t, http.StatusTooManyRequests, tracker.attempts[1].statusCode)
106+
107+
require.True(t, tracker.attempts[2].isRetry)
108+
require.Equal(t, http.StatusOK, tracker.attempts[2].statusCode)
109+
})
110+
}

0 commit comments

Comments
 (0)