-
Notifications
You must be signed in to change notification settings - Fork 2.2k
Add Metrics to RetryableHTTPClient #4545
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Changes from 1 commit
86d0fb0
5ca7474
bd4c865
7b62061
66addf9
117f95d
0dc81d8
b81d6cd
650632d
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -135,6 +135,15 @@ func (t *InstrumentedTransport) RoundTrip(req *http.Request) (*http.Response, er | |
| if resp != nil { | ||
| // record latency and increment counter for non-200 status code | ||
| recordHTTPResponse(sanitizedURL, resp.StatusCode, duration.Seconds()) | ||
|
|
||
| // wrap the response body to count bytes read | ||
| resp.Body = &responseSizeCounterReadCloser{ | ||
| ReadCloser: resp.Body, | ||
| recordSizeFunc: func(size int) { | ||
| // Record response body size | ||
| recordResponseBodySize(sanitizedURL, size) | ||
| }, | ||
| } | ||
| } | ||
|
|
||
| return resp, err | ||
|
|
@@ -147,6 +156,35 @@ func NewInstrumentedTransport(T http.RoundTripper) *InstrumentedTransport { | |
| return &InstrumentedTransport{T} | ||
| } | ||
|
|
||
| type responseSizeCounterReadCloser struct { | ||
| io.ReadCloser | ||
| bytesRead *int | ||
| recordSizeFunc func(int) | ||
| } | ||
|
|
||
| func (r *responseSizeCounterReadCloser) Read(p []byte) (n int, err error) { | ||
| n, err = r.ReadCloser.Read(p) | ||
| if r.bytesRead == nil { | ||
| r.bytesRead = new(int) | ||
| } | ||
| *r.bytesRead += n | ||
| return n, err | ||
| } | ||
|
|
||
| func (r *responseSizeCounterReadCloser) Close() error { | ||
| if r.recordSizeFunc != nil { | ||
| if r.bytesRead == nil { | ||
|
||
| // this means downstream code never consumed the body, we must drain the body to get its size | ||
| _, err := io.Copy(io.Discard, r) | ||
|
||
| if err != nil { | ||
| return err | ||
| } | ||
| } | ||
| r.recordSizeFunc(*r.bytesRead) | ||
| } | ||
| return r.ReadCloser.Close() | ||
| } | ||
|
|
||
| func ConstantResponseHttpClient(statusCode int, body string) *http.Client { | ||
| return &http.Client{ | ||
| Timeout: DefaultResponseTimeout, | ||
|
|
@@ -198,7 +236,7 @@ func WithRetryWaitMax(wait time.Duration) ClientOption { | |
| func PinnedRetryableHttpClient() *http.Client { | ||
| httpClient := retryablehttp.NewClient() | ||
| httpClient.Logger = nil | ||
| httpClient.HTTPClient.Transport = NewCustomTransport(&http.Transport{ | ||
| httpClient.HTTPClient.Transport = NewInstrumentedTransport(NewCustomTransport(&http.Transport{ | ||
| TLSClientConfig: &tls.Config{ | ||
| RootCAs: PinnedCertPool(), | ||
| }, | ||
|
|
@@ -212,15 +250,15 @@ func PinnedRetryableHttpClient() *http.Client { | |
| IdleConnTimeout: 90 * time.Second, | ||
| TLSHandshakeTimeout: 10 * time.Second, | ||
| ExpectContinueTimeout: 1 * time.Second, | ||
| }) | ||
| })) | ||
| return httpClient.StandardClient() | ||
| } | ||
|
|
||
| func RetryableHTTPClient(opts ...ClientOption) *http.Client { | ||
| httpClient := retryablehttp.NewClient() | ||
| httpClient.RetryMax = 3 | ||
| httpClient.Logger = nil | ||
| httpClient.HTTPClient.Transport = NewCustomTransport(nil) | ||
| httpClient.HTTPClient.Transport = NewInstrumentedTransport(NewCustomTransport(nil)) | ||
|
|
||
| for _, opt := range opts { | ||
| opt(httpClient) | ||
|
|
@@ -234,7 +272,7 @@ func RetryableHTTPClientTimeout(timeOutSeconds int64, opts ...ClientOption) *htt | |
| httpClient.RetryMax = 3 | ||
| httpClient.Logger = nil | ||
| httpClient.HTTPClient.Timeout = time.Duration(timeOutSeconds) * time.Second | ||
| httpClient.HTTPClient.Transport = NewCustomTransport(nil) | ||
| httpClient.HTTPClient.Transport = NewInstrumentedTransport(NewCustomTransport(nil)) | ||
|
|
||
| for _, opt := range opts { | ||
| opt(httpClient) | ||
|
|
||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -2,6 +2,7 @@ package common | |
|
|
||
| import ( | ||
| "context" | ||
| "io" | ||
| "math" | ||
| "net/http" | ||
| "net/http/httptest" | ||
|
|
@@ -405,6 +406,76 @@ func TestSaneHttpClientMetrics(t *testing.T) { | |
| } | ||
| } | ||
|
|
||
| func TestRetryableHttpClientMetrics(t *testing.T) { | ||
| // Create a test server that returns different status codes | ||
| server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { | ||
| switch r.URL.Path { | ||
| case "/success": | ||
| w.WriteHeader(http.StatusOK) | ||
| _, _ = w.Write([]byte("success")) | ||
| case "/error": | ||
| w.WriteHeader(http.StatusInternalServerError) | ||
| _, _ = w.Write([]byte("error")) | ||
| case "/notfound": | ||
| w.WriteHeader(http.StatusNotFound) | ||
| _, _ = w.Write([]byte("not found")) | ||
| default: | ||
| w.WriteHeader(http.StatusOK) | ||
| _, _ = w.Write([]byte("default")) | ||
| } | ||
| })) | ||
| defer server.Close() | ||
|
|
||
| // Create a RetryableHttpClient | ||
| client := RetryableHTTPClient() | ||
|
|
||
| testCases := []struct { | ||
| name string | ||
| path string | ||
| expectedStatusCode int | ||
| expectsNon200 bool | ||
|
||
| }{ | ||
| { | ||
| name: "successful request", | ||
| path: "/success", | ||
| expectedStatusCode: 200, | ||
| expectsNon200: false, | ||
| }, | ||
| { | ||
| name: "not found request", | ||
| path: "/notfound", | ||
| expectedStatusCode: 404, | ||
| expectsNon200: true, | ||
| }, | ||
| } | ||
|
|
||
| for _, tc := range testCases { | ||
| t.Run(tc.name, func(t *testing.T) { | ||
| var requestURL string | ||
| if strings.HasPrefix(tc.path, "http") { | ||
| requestURL = tc.path | ||
| } else { | ||
| requestURL = server.URL + tc.path | ||
| } | ||
|
|
||
| // Get initial metric values | ||
| sanitizedURL := sanitizeURL(requestURL) | ||
| initialRequestsTotal := testutil.ToFloat64(httpRequestsTotal.WithLabelValues(sanitizedURL)) | ||
|
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Whoa I didn't know we could test Prometheus metrics like this! |
||
|
|
||
| // Make the request | ||
| resp, err := client.Get(requestURL) | ||
|
|
||
| require.NoError(t, err) | ||
| defer resp.Body.Close() | ||
| assert.Equal(t, tc.expectedStatusCode, resp.StatusCode) | ||
|
|
||
| // Check that request counter was incremented | ||
| requestsTotal := testutil.ToFloat64(httpRequestsTotal.WithLabelValues(sanitizedURL)) | ||
| assert.Equal(t, initialRequestsTotal+1, requestsTotal) | ||
| }) | ||
| } | ||
| } | ||
|
|
||
| func TestInstrumentedTransport(t *testing.T) { | ||
| // Create a mock transport that we can control | ||
| server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { | ||
|
|
@@ -439,3 +510,51 @@ func TestInstrumentedTransport(t *testing.T) { | |
| // Note: Testing histogram metrics is complex due to the way Prometheus handles them | ||
| // The main thing is that the request completed successfully and counters were incremented | ||
| } | ||
|
|
||
| func TestResponseSizeCounterReadCloser(t *testing.T) { | ||
| bodyContent := "This is a test response body." | ||
| expectedSize := len(bodyContent) | ||
| resp := &http.Response{ | ||
| Body: io.NopCloser(strings.NewReader(bodyContent)), | ||
| } | ||
|
|
||
| var recordedSize int | ||
| readCloser := &responseSizeCounterReadCloser{ | ||
| ReadCloser: resp.Body, | ||
| recordSizeFunc: func(size int) { | ||
| recordedSize = size | ||
| }, | ||
| } | ||
| // Read the entire body | ||
| _, err := io.ReadAll(readCloser) | ||
| require.NoError(t, err) | ||
|
|
||
| // Close the ReadCloser to trigger size recording | ||
| err = readCloser.Close() | ||
| require.NoError(t, err) | ||
|
|
||
| assert.Equal(t, expectedSize, recordedSize, "Response body size does not match expected") | ||
| } | ||
|
|
||
| func TestResponseSizeCounterReadCloser_UnreadBody(t *testing.T) { | ||
| bodyContent := "This is a test response body." | ||
| expectedSize := len(bodyContent) | ||
| resp := &http.Response{ | ||
| Body: io.NopCloser(strings.NewReader(bodyContent)), | ||
| } | ||
|
|
||
| var recordedSize int | ||
| readCloser := &responseSizeCounterReadCloser{ | ||
| ReadCloser: resp.Body, | ||
| recordSizeFunc: func(size int) { | ||
| recordedSize = size | ||
| }, | ||
| } | ||
| // Do not read the body | ||
|
|
||
| // Close the ReadCloser to trigger size recording | ||
| err := readCloser.Close() | ||
| require.NoError(t, err) | ||
|
|
||
| assert.Equal(t, expectedSize, recordedSize, "Response body size does not match expected for unread body") | ||
| } | ||
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
This would also start generating metrics for SaneHTTPClient. Just wanna make sure if this is intentional.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Yes, this is intended. We also want to add this metric to
SaneHTTPClient