From b4377d38e6636c9a47ffa102cc600d984284e6c6 Mon Sep 17 00:00:00 2001 From: Eoin Motherway <25342760+YuKitsune@users.noreply.github.com> Date: Thu, 15 Oct 2026 11:56:09 +1000 Subject: [PATCH 1/9] Respect rate limiting headers --- README.md | 46 ++ .../create_rate_limited_client.go | 60 +++ pkg/client/client_options_test.go | 123 +++++ pkg/client/octopusdeploy.go | 23 + pkg/newclient/httpsession.go | 3 + pkg/newclient/httpsession_test.go | 48 ++ pkg/ratelimit/transport.go | 237 +++++++++ pkg/ratelimit/transport_test.go | 468 ++++++++++++++++++ 8 files changed, 1008 insertions(+) create mode 100644 examples/rate_limiting/create_rate_limited_client.go create mode 100644 pkg/client/client_options_test.go create mode 100644 pkg/newclient/httpsession_test.go create mode 100644 pkg/ratelimit/transport.go create mode 100644 pkg/ratelimit/transport_test.go diff --git a/README.md b/README.md index 3f5b6ced..b59da3f0 100644 --- a/README.md +++ b/README.md @@ -59,6 +59,52 @@ if err != nil { Operations like `Add`, `DeleteByID`, `GetByID`, and `Update` are supported by most services that are exposed through the client if not exposed in the package. These operations are configured at runtime since the Octopus REST API is hypermedia-driven. +### Rate limiting + +Octopus Server can [limit the rate of requests](https://octopus.com/docs/administration/managing-infrastructure/rate-limiting) from each client. When a client sends too many requests, the server returns `429 Too Many Requests` and does not process the request. + +By default, the client does not handle rate limits. To make the client obey the rate limits, use `NewClientWithOptions` and set `RateLimit`: + +```go +import ( + "github.com/OctopusDeploy/go-octopusdeploy/v2/pkg/client" + "github.com/OctopusDeploy/go-octopusdeploy/v2/pkg/ratelimit" +) + +credentials, err := client.NewApiKey(apiKey) +if err != nil { + _ = fmt.Errorf("error creating API key credentials: %v", err) + return +} + +rateLimitOptions := ratelimit.DefaultOptions() + +octopusClient, err := client.NewClientWithOptions(nil, apiURL, credentials, spaceID, "", &client.ClientOptions{ + RateLimit: &rateLimitOptions, +}) +if err != nil { + _ = fmt.Errorf("error creating API client: %v", err) + return +} +``` + +With rate limiting on, the client does these things: + +- When the server returns `429 Too Many Requests`, the client waits and sends the request again. It waits for the time in the `Retry-After` header. If the server does not send this header, it uses exponential backoff. +- When the `Octopus-RateLimit` advisory header shows that no requests remain, the client waits before it sends the next request. This prevents most `429` responses. + +You can change these settings in `ratelimit.Options`: + +| Setting | Default | Description | +| -------------------- | ---------- | --------------------------------------------------------------------------------------------------------------------------------------------------- | +| `MaxRetries` | `3` | The maximum number of times to send a request again after a `429` response. Set to `0` to turn off retries. | +| `MaxWait` | `60s` | The maximum time to wait before a request. If `Retry-After` is longer than this time, the client does not retry and returns the `429` response. | +| `UseAdvisoryHeaders` | `true` | Wait before a request when the advisory headers show that no requests remain. | + +The client retries a request only if it can send the request body again. For example, the client does not retry a package upload. + +If you supply an `http.Client`, the client uses a copy of it and does not change it. To use rate limiting with an `http.Client` of your own in other code, wrap its transport with `ratelimit.NewTransport`. + Numerous code samples that showcase the API and this client are available in the [examples](/examples) directory. There are also many [integration](/test) and unit tests available to examine that demonstrate the capabilities of this API client. ## Testing diff --git a/examples/rate_limiting/create_rate_limited_client.go b/examples/rate_limiting/create_rate_limited_client.go new file mode 100644 index 00000000..5a8a655c --- /dev/null +++ b/examples/rate_limiting/create_rate_limited_client.go @@ -0,0 +1,60 @@ +package examples + +import ( + "fmt" + "net/url" + "time" + + "github.com/OctopusDeploy/go-octopusdeploy/v2/pkg/client" + "github.com/OctopusDeploy/go-octopusdeploy/v2/pkg/ratelimit" + "github.com/OctopusDeploy/go-octopusdeploy/v2/pkg/spaces" +) + +// CreateRateLimitedClientExample provides an example of how to create a Go API client +// that obeys the rate limits of Octopus Server. +func CreateRateLimitedClientExample() { + var ( + apiKey string = "API-YOUR_API_KEY" + octopusURL string = "https://your_octopus_url" + spaceID string = "space-id" + ) + + apiURL, err := url.Parse(octopusURL) + if err != nil { + _ = fmt.Errorf("error parsing URL for Octopus API: %v", err) + return + } + + credentials, err := client.NewApiKey(apiKey) + if err != nil { + _ = fmt.Errorf("error creating API key credentials: %v", err) + return + } + + // start from the recommended settings: 3 retries, a maximum wait of 60 seconds, + // and throttling with the advisory rate limit headers + rateLimitOptions := ratelimit.DefaultOptions() + + // change the settings if necessary + rateLimitOptions.MaxRetries = 5 + rateLimitOptions.MaxWait = 30 * time.Second + + options := &client.ClientOptions{ + RateLimit: &rateLimitOptions, + } + + octopusClient, err := client.NewClientWithOptions(nil, apiURL, credentials, spaceID, "", options) + if err != nil { + _ = fmt.Errorf("error creating API client: %v", err) + return + } + + // all requests from this client now obey the rate limits + space, err := spaces.GetByID(octopusClient, spaceID) + if err != nil { + _ = fmt.Errorf("error getting space: %v", err) + return + } + + fmt.Printf("space: (%s)\n", space.GetID()) +} diff --git a/pkg/client/client_options_test.go b/pkg/client/client_options_test.go new file mode 100644 index 00000000..dfabecd2 --- /dev/null +++ b/pkg/client/client_options_test.go @@ -0,0 +1,123 @@ +package client + +import ( + "io" + "net/http" + "net/url" + "strings" + "sync" + "testing" + "time" + + "github.com/OctopusDeploy/go-octopusdeploy/v2/pkg/constants" + "github.com/OctopusDeploy/go-octopusdeploy/v2/pkg/ratelimit" + "github.com/stretchr/testify/require" +) + +const testRateLimitAPIKey = "API-ABCDEFGHIJKLMNOPQRSTUVWXYZ" + +// newRateLimitedHttpClient returns an http.Client that returns 429 for the first request to the root resource. +// After that, it returns the root resource for /api and an empty list for all other paths. +func newRateLimitedHttpClient() (*http.Client, func() int) { + var mutex sync.Mutex + rootRequestCount := 0 + + httpClient := &http.Client{ + Transport: roundTripFunc(func(request *http.Request) (*http.Response, error) { + if request.URL.Path == "/api" { + mutex.Lock() + rootRequestCount++ + count := rootRequestCount + mutex.Unlock() + + if count == 1 { + return &http.Response{ + StatusCode: http.StatusTooManyRequests, + Header: http.Header{"Retry-After": []string{"0"}}, + Body: io.NopCloser(strings.NewReader(`{"ErrorMessage":"Too many requests"}`)), + }, nil + } + + return &http.Response{ + StatusCode: http.StatusOK, + Body: io.NopCloser(strings.NewReader(constants.ApiReplyRoot)), + }, nil + } + + return &http.Response{ + StatusCode: http.StatusOK, + Body: io.NopCloser(strings.NewReader(`{"Items":[]}`)), + }, nil + }), + } + + return httpClient, func() int { + mutex.Lock() + defer mutex.Unlock() + return rootRequestCount + } +} + +func newTestRateLimitOptions() *ClientOptions { + return &ClientOptions{ + RateLimit: &ratelimit.Options{ + MaxRetries: 3, + MaxWait: time.Second, + }, + } +} + +func TestNewClientWithOptionsRetriesTooManyRequests(t *testing.T) { + httpClient, rootRequestCount := newRateLimitedHttpClient() + apiURL, err := url.Parse("https://octopus.example") + require.NoError(t, err) + apiKey, err := NewApiKey(testRateLimitAPIKey) + require.NoError(t, err) + + octopusClient, err := NewClientWithOptions(httpClient, apiURL, apiKey, "", "test", newTestRateLimitOptions()) + + require.NoError(t, err) + require.NotNil(t, octopusClient) + require.Equal(t, 2, rootRequestCount()) +} + +func TestNewClientWithOptionsDoesNotChangeTheHttpClient(t *testing.T) { + httpClient, _ := newRateLimitedHttpClient() + apiURL, err := url.Parse("https://octopus.example") + require.NoError(t, err) + apiKey, err := NewApiKey(testRateLimitAPIKey) + require.NoError(t, err) + + _, err = NewClientWithOptions(httpClient, apiURL, apiKey, "", "test", newTestRateLimitOptions()) + + require.NoError(t, err) + require.IsType(t, roundTripFunc(nil), httpClient.Transport) +} + +func TestNewClientWithOptionsDoesNotRetryWhenRateLimitIsNil(t *testing.T) { + httpClient, rootRequestCount := newRateLimitedHttpClient() + apiURL, err := url.Parse("https://octopus.example") + require.NoError(t, err) + apiKey, err := NewApiKey(testRateLimitAPIKey) + require.NoError(t, err) + + octopusClient, err := NewClientWithOptions(httpClient, apiURL, apiKey, "", "test", &ClientOptions{}) + + require.Error(t, err) + require.Nil(t, octopusClient) + require.Equal(t, 1, rootRequestCount()) +} + +func TestNewClientWithOptionsDoesNotRetryWhenOptionsAreNil(t *testing.T) { + httpClient, rootRequestCount := newRateLimitedHttpClient() + apiURL, err := url.Parse("https://octopus.example") + require.NoError(t, err) + apiKey, err := NewApiKey(testRateLimitAPIKey) + require.NoError(t, err) + + octopusClient, err := NewClientWithOptions(httpClient, apiURL, apiKey, "", "test", nil) + + require.Error(t, err) + require.Nil(t, octopusClient) + require.Equal(t, 1, rootRequestCount()) +} diff --git a/pkg/client/octopusdeploy.go b/pkg/client/octopusdeploy.go index 629c7275..b4baa5c8 100644 --- a/pkg/client/octopusdeploy.go +++ b/pkg/client/octopusdeploy.go @@ -49,6 +49,7 @@ import ( "github.com/OctopusDeploy/go-octopusdeploy/v2/pkg/projects" "github.com/OctopusDeploy/go-octopusdeploy/v2/pkg/projectvariables" "github.com/OctopusDeploy/go-octopusdeploy/v2/pkg/proxies" + "github.com/OctopusDeploy/go-octopusdeploy/v2/pkg/ratelimit" "github.com/OctopusDeploy/go-octopusdeploy/v2/pkg/releases" "github.com/OctopusDeploy/go-octopusdeploy/v2/pkg/reporting" "github.com/OctopusDeploy/go-octopusdeploy/v2/pkg/runbooks" @@ -208,6 +209,22 @@ func NewClientForTool(httpClient *http.Client, apiURL *url.URL, apiKey string, s // NewClientWithCredentials returns a new Octopus API client with the specified credentials and a tool reference in the useragent string. // If a nil client is provided, a new http.Client will be used. func NewClientWithCredentials(httpClient *http.Client, apiURL *url.URL, apiCredentials ICredential, spaceID string, requestingTool string) (*Client, error) { + return NewClientWithOptions(httpClient, apiURL, apiCredentials, spaceID, requestingTool, nil) +} + +// ClientOptions contains optional settings for a Client. +type ClientOptions struct { + // RateLimit makes the client obey the rate limits of Octopus Server. If RateLimit is nil, the client does not + // retry 429 responses and does not read the advisory rate limit headers. + RateLimit *ratelimit.Options +} + +// NewClientWithOptions returns a new Octopus API client with the specified credentials, a tool reference in the +// useragent string, and the specified options. If a nil client is provided, a new http.Client will be used. +// If a nil options is provided, the default behaviour is used. +// +// If options.RateLimit is set, the client uses a copy of httpClient. The client does not change httpClient. +func NewClientWithOptions(httpClient *http.Client, apiURL *url.URL, apiCredentials ICredential, spaceID string, requestingTool string, options *ClientOptions) (*Client, error) { if apiURL == nil { return nil, internal.CreateInvalidParameterError("NewClient", "apiURL") } @@ -227,6 +244,12 @@ func NewClientWithCredentials(httpClient *http.Client, apiURL *url.URL, apiCrede httpClient = &http.Client{} } + if options != nil && options.RateLimit != nil { + rateLimitedClient := *httpClient + rateLimitedClient.Transport = ratelimit.NewTransport(httpClient.Transport, *options.RateLimit) + httpClient = &rateLimitedClient + } + // fetch root resource and process paths base, root, err := getRoot(httpClient, baseURLWithAPI, apiCredentials, requestingTool) if err != nil { diff --git a/pkg/newclient/httpsession.go b/pkg/newclient/httpsession.go index b1e42819..36c47482 100644 --- a/pkg/newclient/httpsession.go +++ b/pkg/newclient/httpsession.go @@ -73,6 +73,9 @@ func (h *HttpSession) DoRawJsonRequest(req *http.Request, requestBody any, outpu req.Header.Set("Content-Type", "application/json") } req.Body = io.NopCloser(bytes.NewReader(body)) + req.GetBody = func() (io.ReadCloser, error) { + return io.NopCloser(bytes.NewReader(body)), nil + } } if req.Header.Get("Accept") == "" { diff --git a/pkg/newclient/httpsession_test.go b/pkg/newclient/httpsession_test.go new file mode 100644 index 00000000..bc637e7a --- /dev/null +++ b/pkg/newclient/httpsession_test.go @@ -0,0 +1,48 @@ +package newclient + +import ( + "io" + "net/http" + "net/url" + "strings" + "testing" + + "github.com/stretchr/testify/require" +) + +type roundTripFunc func(req *http.Request) (*http.Response, error) + +func (f roundTripFunc) RoundTrip(req *http.Request) (*http.Response, error) { + return f(req) +} + +func TestDoRawJsonRequestSetsGetBody(t *testing.T) { + var sentRequest *http.Request + httpSession := &HttpSession{ + HttpClient: &http.Client{ + Transport: roundTripFunc(func(req *http.Request) (*http.Response, error) { + sentRequest = req + return &http.Response{ + StatusCode: http.StatusNoContent, + Body: io.NopCloser(strings.NewReader("")), + }, nil + }), + }, + } + + requestURL, err := url.Parse("https://octopus.example/api/projects") + require.NoError(t, err) + req := &http.Request{Method: http.MethodPost, URL: requestURL} + + _, err = httpSession.DoRawJsonRequest(req, map[string]string{"Name": "test"}, nil, nil) + require.NoError(t, err) + + require.NotNil(t, sentRequest.GetBody) + for range 2 { + body, err := sentRequest.GetBody() + require.NoError(t, err) + content, err := io.ReadAll(body) + require.NoError(t, err) + require.JSONEq(t, `{"Name":"test"}`, string(content)) + } +} diff --git a/pkg/ratelimit/transport.go b/pkg/ratelimit/transport.go new file mode 100644 index 00000000..0aba4b8b --- /dev/null +++ b/pkg/ratelimit/transport.go @@ -0,0 +1,237 @@ +// Package ratelimit contains an http.RoundTripper that obeys the rate limits of Octopus Server. +// +// See https://octopus.com/docs/administration/managing-infrastructure/rate-limiting +package ratelimit + +import ( + "context" + "io" + "math/rand/v2" + "net/http" + "strconv" + "strings" + "sync" + "time" +) + +const ( + // PolicyHeader is the advisory header that tells the burst limit and the requests per minute. + // For example: "l=200;rpm=600". + PolicyHeader = "Octopus-RateLimit-Policy" + + // StateHeader is the advisory header that tells the remaining tokens and the seconds until all tokens are available again. + // For example: "r=7;t=18". + StateHeader = "Octopus-RateLimit" + + retryAfterHeader = "Retry-After" + initialBackoff = time.Second + maxBackoffShift = 10 +) + +// Options are the settings for a Transport. +type Options struct { + // MaxRetries is the maximum number of times that the Transport sends a request again after a 429 response. + MaxRetries int + + // MaxWait is the maximum time that the Transport waits before it sends a request. + // If the server tells the Transport to wait longer before a retry, the Transport does not retry. It returns the 429 response. + MaxWait time.Duration + + // UseAdvisoryHeaders makes the Transport wait before it sends a request when the advisory headers + // show that no tokens remain. This prevents most 429 responses. + UseAdvisoryHeaders bool +} + +// DefaultOptions returns the recommended settings for a Transport. +func DefaultOptions() Options { + return Options{ + MaxRetries: 3, + MaxWait: 60 * time.Second, + UseAdvisoryHeaders: true, + } +} + +// Transport is an http.RoundTripper that obeys the rate limits of Octopus Server. +// +// When the server returns 429 (Too Many Requests), the Transport waits and sends the request again. +// The server does not process a request that it rejects, thus a retry is safe for all HTTP methods. +// The Transport retries a request only if it can read the request body again (the body is empty, or +// http.Request.GetBody is set). +// +// When UseAdvisoryHeaders is true, the Transport also reads the advisory headers on each response. If no tokens remain, +// the Transport waits for one token before it sends the next request. +type Transport struct { + base http.RoundTripper + options Options + + mutex sync.Mutex + nextRequestTime time.Time +} + +// NewTransport returns a Transport that sends requests with base. If base is nil, the Transport uses http.DefaultTransport. +func NewTransport(base http.RoundTripper, options Options) *Transport { + if base == nil { + base = http.DefaultTransport + } + + return &Transport{ + base: base, + options: options, + } +} + +// RoundTrip implements http.RoundTripper. +func (t *Transport) RoundTrip(req *http.Request) (*http.Response, error) { + for attempt := 0; ; attempt++ { + if t.options.UseAdvisoryHeaders { + if err := wait(req.Context(), t.throttleDelay()); err != nil { + return nil, err + } + } + + attemptRequest, err := requestForAttempt(req, attempt) + if err != nil { + return nil, err + } + + resp, err := t.base.RoundTrip(attemptRequest) + if err != nil { + return nil, err + } + + if t.options.UseAdvisoryHeaders { + t.updateThrottle(resp) + } + + if resp.StatusCode != http.StatusTooManyRequests || attempt >= t.options.MaxRetries || !canRetry(req) { + return resp, nil + } + + delay, found := retryAfter(resp) + if !found { + delay = min(backoff(attempt), t.options.MaxWait) + } + if delay > t.options.MaxWait { + return resp, nil + } + + closeResponse(resp) + if err := wait(req.Context(), delay); err != nil { + return nil, err + } + } +} + +// throttleDelay returns the time to wait before the next request, as the advisory headers tell. +func (t *Transport) throttleDelay() time.Duration { + t.mutex.Lock() + nextRequestTime := t.nextRequestTime + t.mutex.Unlock() + + return min(time.Until(nextRequestTime), t.options.MaxWait) +} + +// updateThrottle reads the advisory headers on resp. If no tokens remain, it sets the time of the next request +// to the time when one token is available again. +func (t *Transport) updateThrottle(resp *http.Response) { + state := parseHeader(resp.Header.Get(StateHeader)) + remaining, ok := state["r"] + if !ok { + return + } + + nextRequestTime := time.Time{} + if remaining <= 0 { + policy := parseHeader(resp.Header.Get(PolicyHeader)) + if requestsPerMinute := policy["rpm"]; requestsPerMinute > 0 { + nextRequestTime = time.Now().Add(time.Minute / time.Duration(requestsPerMinute)) + } + } + + t.mutex.Lock() + t.nextRequestTime = nextRequestTime + t.mutex.Unlock() +} + +// canRetry returns true if the body of req can be read again. +func canRetry(req *http.Request) bool { + return req.Body == nil || req.Body == http.NoBody || req.GetBody != nil +} + +// requestForAttempt returns req for the first attempt. For a retry, it returns a copy of req with a new body. +func requestForAttempt(req *http.Request, attempt int) (*http.Request, error) { + if attempt == 0 { + return req, nil + } + + retryRequest := req.Clone(req.Context()) + if req.Body != nil && req.Body != http.NoBody { + body, err := req.GetBody() + if err != nil { + return nil, err + } + retryRequest.Body = body + } + + return retryRequest, nil +} + +// retryAfter returns the time in the Retry-After header of resp. It returns false if the header is missing or not valid. +func retryAfter(resp *http.Response) (time.Duration, bool) { + seconds, err := strconv.Atoi(strings.TrimSpace(resp.Header.Get(retryAfterHeader))) + if err != nil || seconds < 0 { + return 0, false + } + + return time.Duration(seconds) * time.Second, true +} + +// backoff returns an exponential backoff with jitter for the attempt. +func backoff(attempt int) time.Duration { + delay := initialBackoff << min(attempt, maxBackoffShift) + return delay/2 + rand.N(delay/2) +} + +// parseHeader parses a header value such as "l=200;rpm=600" into a map of names to integer values. +// It ignores the parts that it cannot parse. +func parseHeader(value string) map[string]int { + values := map[string]int{} + for part := range strings.SplitSeq(value, ";") { + name, number, found := strings.Cut(part, "=") + if !found { + continue + } + + parsedNumber, err := strconv.Atoi(strings.TrimSpace(number)) + if err != nil { + continue + } + + values[strings.TrimSpace(name)] = parsedNumber + } + + return values +} + +// wait stops for the time in delay, or until ctx is cancelled. +func wait(ctx context.Context, delay time.Duration) error { + if delay <= 0 { + return nil + } + + timer := time.NewTimer(delay) + defer timer.Stop() + + select { + case <-ctx.Done(): + return ctx.Err() + case <-timer.C: + return nil + } +} + +// closeResponse reads all of the response body and closes it, so that the connection can be used again. +func closeResponse(resp *http.Response) { + _, _ = io.Copy(io.Discard, resp.Body) + _ = resp.Body.Close() +} diff --git a/pkg/ratelimit/transport_test.go b/pkg/ratelimit/transport_test.go new file mode 100644 index 00000000..221e88c7 --- /dev/null +++ b/pkg/ratelimit/transport_test.go @@ -0,0 +1,468 @@ +package ratelimit + +import ( + "bytes" + "context" + "io" + "net/http" + "strings" + "sync" + "testing" + "time" + + "github.com/stretchr/testify/require" +) + +const testURL = "https://octopus.example/api" + +type roundTripFunc func(req *http.Request) (*http.Response, error) + +func (f roundTripFunc) RoundTrip(req *http.Request) (*http.Response, error) { + return f(req) +} + +// fakeServer returns the responses in order and records each request that it gets. +// After the last response, it returns the last response again. +type fakeServer struct { + mutex sync.Mutex + responses []func() *http.Response + requests []*http.Request + bodies []string +} + +func (s *fakeServer) RoundTrip(req *http.Request) (*http.Response, error) { + s.mutex.Lock() + defer s.mutex.Unlock() + + body := "" + if req.Body != nil { + content, err := io.ReadAll(req.Body) + if err != nil { + return nil, err + } + body = string(content) + } + + s.requests = append(s.requests, req) + s.bodies = append(s.bodies, body) + + index := min(len(s.requests)-1, len(s.responses)-1) + return s.responses[index](), nil +} + +func (s *fakeServer) requestCount() int { + s.mutex.Lock() + defer s.mutex.Unlock() + return len(s.requests) +} + +func newResponse(statusCode int, headers map[string]string) func() *http.Response { + return func() *http.Response { + header := http.Header{} + for name, value := range headers { + header.Set(name, value) + } + + return &http.Response{ + StatusCode: statusCode, + Header: header, + Body: io.NopCloser(strings.NewReader("")), + } + } +} + +func testOptions() Options { + return Options{ + MaxRetries: 3, + MaxWait: time.Second, + UseAdvisoryHeaders: true, + } +} + +func newGetRequest(t *testing.T) *http.Request { + req, err := http.NewRequest(http.MethodGet, testURL, nil) + require.NoError(t, err) + return req +} + +func TestDefaultOptions(t *testing.T) { + options := DefaultOptions() + + require.Equal(t, 3, options.MaxRetries) + require.Equal(t, 60*time.Second, options.MaxWait) + require.True(t, options.UseAdvisoryHeaders) +} + +func TestNewTransportWithNilBaseUsesDefaultTransport(t *testing.T) { + transport := NewTransport(nil, testOptions()) + + require.Equal(t, http.DefaultTransport, transport.base) +} + +func TestRoundTripReturnsSuccessfulResponse(t *testing.T) { + server := &fakeServer{responses: []func() *http.Response{newResponse(http.StatusOK, nil)}} + transport := NewTransport(server, testOptions()) + + resp, err := transport.RoundTrip(newGetRequest(t)) + + require.NoError(t, err) + require.Equal(t, http.StatusOK, resp.StatusCode) + require.Equal(t, 1, server.requestCount()) +} + +func TestRoundTripDoesNotRetryOtherErrorResponses(t *testing.T) { + server := &fakeServer{responses: []func() *http.Response{newResponse(http.StatusServiceUnavailable, nil)}} + transport := NewTransport(server, testOptions()) + + resp, err := transport.RoundTrip(newGetRequest(t)) + + require.NoError(t, err) + require.Equal(t, http.StatusServiceUnavailable, resp.StatusCode) + require.Equal(t, 1, server.requestCount()) +} + +func TestRoundTripRetriesAfterTooManyRequests(t *testing.T) { + server := &fakeServer{responses: []func() *http.Response{ + newResponse(http.StatusTooManyRequests, map[string]string{"Retry-After": "0"}), + newResponse(http.StatusOK, nil), + }} + transport := NewTransport(server, testOptions()) + + resp, err := transport.RoundTrip(newGetRequest(t)) + + require.NoError(t, err) + require.Equal(t, http.StatusOK, resp.StatusCode) + require.Equal(t, 2, server.requestCount()) +} + +func TestRoundTripStopsAfterMaxRetries(t *testing.T) { + server := &fakeServer{responses: []func() *http.Response{ + newResponse(http.StatusTooManyRequests, map[string]string{"Retry-After": "0"}), + }} + transport := NewTransport(server, testOptions()) + + resp, err := transport.RoundTrip(newGetRequest(t)) + + require.NoError(t, err) + require.Equal(t, http.StatusTooManyRequests, resp.StatusCode) + require.Equal(t, 4, server.requestCount()) +} + +func TestRoundTripDoesNotRetryWhenMaxRetriesIsZero(t *testing.T) { + server := &fakeServer{responses: []func() *http.Response{ + newResponse(http.StatusTooManyRequests, map[string]string{"Retry-After": "0"}), + }} + options := testOptions() + options.MaxRetries = 0 + transport := NewTransport(server, options) + + resp, err := transport.RoundTrip(newGetRequest(t)) + + require.NoError(t, err) + require.Equal(t, http.StatusTooManyRequests, resp.StatusCode) + require.Equal(t, 1, server.requestCount()) +} + +func TestRoundTripDoesNotRetryWhenRetryAfterIsLongerThanMaxWait(t *testing.T) { + server := &fakeServer{responses: []func() *http.Response{ + newResponse(http.StatusTooManyRequests, map[string]string{"Retry-After": "120"}), + newResponse(http.StatusOK, nil), + }} + transport := NewTransport(server, testOptions()) + + start := time.Now() + resp, err := transport.RoundTrip(newGetRequest(t)) + + require.NoError(t, err) + require.Equal(t, http.StatusTooManyRequests, resp.StatusCode) + require.Equal(t, 1, server.requestCount()) + require.Less(t, time.Since(start), time.Second) +} + +func TestRoundTripWaitsForRetryAfter(t *testing.T) { + server := &fakeServer{responses: []func() *http.Response{ + newResponse(http.StatusTooManyRequests, map[string]string{"Retry-After": "1"}), + newResponse(http.StatusOK, nil), + }} + options := testOptions() + options.MaxWait = 2 * time.Second + transport := NewTransport(server, options) + + start := time.Now() + resp, err := transport.RoundTrip(newGetRequest(t)) + + require.NoError(t, err) + require.Equal(t, http.StatusOK, resp.StatusCode) + require.GreaterOrEqual(t, time.Since(start), time.Second) +} + +func TestRoundTripUsesBackoffCappedAtMaxWaitWhenRetryAfterIsMissing(t *testing.T) { + server := &fakeServer{responses: []func() *http.Response{ + newResponse(http.StatusTooManyRequests, nil), + newResponse(http.StatusOK, nil), + }} + options := testOptions() + options.MaxWait = 50 * time.Millisecond + transport := NewTransport(server, options) + + start := time.Now() + resp, err := transport.RoundTrip(newGetRequest(t)) + + require.NoError(t, err) + require.Equal(t, http.StatusOK, resp.StatusCode) + require.Equal(t, 2, server.requestCount()) + require.GreaterOrEqual(t, time.Since(start), 50*time.Millisecond) + require.Less(t, time.Since(start), 500*time.Millisecond) +} + +func TestRoundTripSendsTheSameBodyOnRetry(t *testing.T) { + server := &fakeServer{responses: []func() *http.Response{ + newResponse(http.StatusTooManyRequests, map[string]string{"Retry-After": "0"}), + newResponse(http.StatusOK, nil), + }} + transport := NewTransport(server, testOptions()) + + req, err := http.NewRequest(http.MethodPost, testURL, bytes.NewReader([]byte(`{"Name":"test"}`))) + require.NoError(t, err) + + resp, err := transport.RoundTrip(req) + + require.NoError(t, err) + require.Equal(t, http.StatusOK, resp.StatusCode) + require.Equal(t, []string{`{"Name":"test"}`, `{"Name":"test"}`}, server.bodies) + require.Equal(t, http.MethodPost, server.requests[1].Method) +} + +func TestRoundTripDoesNotRetryWhenBodyCannotBeReadAgain(t *testing.T) { + server := &fakeServer{responses: []func() *http.Response{ + newResponse(http.StatusTooManyRequests, map[string]string{"Retry-After": "0"}), + newResponse(http.StatusOK, nil), + }} + transport := NewTransport(server, testOptions()) + + req, err := http.NewRequest(http.MethodPost, testURL, io.NopCloser(strings.NewReader("package content"))) + require.NoError(t, err) + require.Nil(t, req.GetBody) + + resp, err := transport.RoundTrip(req) + + require.NoError(t, err) + require.Equal(t, http.StatusTooManyRequests, resp.StatusCode) + require.Equal(t, 1, server.requestCount()) +} + +func TestRoundTripReturnsErrorWhenContextIsCancelledDuringRetryWait(t *testing.T) { + server := &fakeServer{responses: []func() *http.Response{ + newResponse(http.StatusTooManyRequests, map[string]string{"Retry-After": "30"}), + newResponse(http.StatusOK, nil), + }} + options := testOptions() + options.MaxWait = time.Minute + transport := NewTransport(server, options) + + ctx, cancel := context.WithTimeout(context.Background(), 50*time.Millisecond) + defer cancel() + req, err := http.NewRequestWithContext(ctx, http.MethodGet, testURL, nil) + require.NoError(t, err) + + start := time.Now() + resp, err := transport.RoundTrip(req) + + require.ErrorIs(t, err, context.DeadlineExceeded) + require.Nil(t, resp) + require.Equal(t, 1, server.requestCount()) + require.Less(t, time.Since(start), time.Second) +} + +func TestRoundTripReturnsErrorFromBaseTransport(t *testing.T) { + baseErr := io.ErrUnexpectedEOF + transport := NewTransport(roundTripFunc(func(req *http.Request) (*http.Response, error) { + return nil, baseErr + }), testOptions()) + + resp, err := transport.RoundTrip(newGetRequest(t)) + + require.ErrorIs(t, err, baseErr) + require.Nil(t, resp) +} + +func TestRoundTripWaitsWhenNoTokensRemain(t *testing.T) { + server := &fakeServer{responses: []func() *http.Response{ + newResponse(http.StatusOK, map[string]string{PolicyHeader: "l=200;rpm=600", StateHeader: "r=0;t=18"}), + newResponse(http.StatusOK, nil), + }} + transport := NewTransport(server, testOptions()) + + _, err := transport.RoundTrip(newGetRequest(t)) + require.NoError(t, err) + + start := time.Now() + _, err = transport.RoundTrip(newGetRequest(t)) + + require.NoError(t, err) + // 600 requests per minute gives one token each 100 milliseconds. + require.GreaterOrEqual(t, time.Since(start), 90*time.Millisecond) +} + +func TestRoundTripDoesNotWaitWhenTokensRemain(t *testing.T) { + server := &fakeServer{responses: []func() *http.Response{ + newResponse(http.StatusOK, map[string]string{PolicyHeader: "l=200;rpm=1", StateHeader: "r=7;t=18"}), + }} + transport := NewTransport(server, testOptions()) + + _, err := transport.RoundTrip(newGetRequest(t)) + require.NoError(t, err) + + start := time.Now() + _, err = transport.RoundTrip(newGetRequest(t)) + + require.NoError(t, err) + require.Less(t, time.Since(start), 50*time.Millisecond) +} + +func TestUpdateThrottleClearsWaitWhenTokensAreAvailableAgain(t *testing.T) { + transport := NewTransport(nil, testOptions()) + + transport.updateThrottle(newResponse(http.StatusOK, map[string]string{PolicyHeader: "l=200;rpm=1", StateHeader: "r=0;t=60"})()) + require.Greater(t, transport.throttleDelay(), time.Duration(0)) + + transport.updateThrottle(newResponse(http.StatusOK, map[string]string{PolicyHeader: "l=200;rpm=1", StateHeader: "r=5;t=55"})()) + require.LessOrEqual(t, transport.throttleDelay(), time.Duration(0)) +} + +func TestUpdateThrottleIgnoresResponsesWithoutAdvisoryHeaders(t *testing.T) { + transport := NewTransport(nil, testOptions()) + + transport.updateThrottle(newResponse(http.StatusOK, map[string]string{PolicyHeader: "l=200;rpm=1", StateHeader: "r=0;t=60"})()) + transport.updateThrottle(newResponse(http.StatusOK, nil)()) + + require.Greater(t, transport.throttleDelay(), time.Duration(0)) +} + +func TestRoundTripCapsThrottleWaitAtMaxWait(t *testing.T) { + server := &fakeServer{responses: []func() *http.Response{ + newResponse(http.StatusOK, map[string]string{PolicyHeader: "l=200;rpm=1", StateHeader: "r=0;t=60"}), + newResponse(http.StatusOK, nil), + }} + options := testOptions() + options.MaxWait = 50 * time.Millisecond + transport := NewTransport(server, options) + + _, err := transport.RoundTrip(newGetRequest(t)) + require.NoError(t, err) + + start := time.Now() + _, err = transport.RoundTrip(newGetRequest(t)) + + require.NoError(t, err) + require.GreaterOrEqual(t, time.Since(start), 50*time.Millisecond) + require.Less(t, time.Since(start), 500*time.Millisecond) +} + +func TestRoundTripDoesNotThrottleWhenAdvisoryHeadersAreNotUsed(t *testing.T) { + server := &fakeServer{responses: []func() *http.Response{ + newResponse(http.StatusOK, map[string]string{PolicyHeader: "l=200;rpm=1", StateHeader: "r=0;t=60"}), + }} + options := testOptions() + options.UseAdvisoryHeaders = false + transport := NewTransport(server, options) + + _, err := transport.RoundTrip(newGetRequest(t)) + require.NoError(t, err) + + start := time.Now() + _, err = transport.RoundTrip(newGetRequest(t)) + + require.NoError(t, err) + require.Less(t, time.Since(start), 50*time.Millisecond) +} + +func TestRoundTripReturnsErrorWhenContextIsCancelledDuringThrottleWait(t *testing.T) { + server := &fakeServer{responses: []func() *http.Response{ + newResponse(http.StatusOK, map[string]string{PolicyHeader: "l=200;rpm=1", StateHeader: "r=0;t=60"}), + }} + options := testOptions() + options.MaxWait = time.Minute + transport := NewTransport(server, options) + + _, err := transport.RoundTrip(newGetRequest(t)) + require.NoError(t, err) + + ctx, cancel := context.WithTimeout(context.Background(), 50*time.Millisecond) + defer cancel() + req, err := http.NewRequestWithContext(ctx, http.MethodGet, testURL, nil) + require.NoError(t, err) + + resp, err := transport.RoundTrip(req) + + require.ErrorIs(t, err, context.DeadlineExceeded) + require.Nil(t, resp) + require.Equal(t, 1, server.requestCount()) +} + +func TestParseHeader(t *testing.T) { + testCases := []struct { + name string + value string + expected map[string]int + }{ + {"Policy", "l=200;rpm=600", map[string]int{"l": 200, "rpm": 600}}, + {"State", "r=7;t=18", map[string]int{"r": 7, "t": 18}}, + {"Spaces", " r = 7 ; t = 18 ", map[string]int{"r": 7, "t": 18}}, + {"Empty", "", map[string]int{}}, + {"MissingValue", "r;t=18", map[string]int{"t": 18}}, + {"NotANumber", "r=abc;t=18", map[string]int{"t": 18}}, + } + + for _, testCase := range testCases { + t.Run(testCase.name, func(t *testing.T) { + require.Equal(t, testCase.expected, parseHeader(testCase.value)) + }) + } +} + +func TestRetryAfter(t *testing.T) { + testCases := []struct { + name string + value string + expectedDelay time.Duration + expectedFound bool + }{ + {"Seconds", "5", 5 * time.Second, true}, + {"Zero", "0", 0, true}, + {"Missing", "", 0, false}, + {"Negative", "-1", 0, false}, + {"HttpDate", "Wed, 21 Oct 2015 07:28:00 GMT", 0, false}, + } + + for _, testCase := range testCases { + t.Run(testCase.name, func(t *testing.T) { + resp := newResponse(http.StatusTooManyRequests, map[string]string{"Retry-After": testCase.value})() + + delay, found := retryAfter(resp) + + require.Equal(t, testCase.expectedDelay, delay) + require.Equal(t, testCase.expectedFound, found) + }) + } +} + +func TestBackoff(t *testing.T) { + for attempt := range 4 { + maxDelay := initialBackoff << attempt + + delay := backoff(attempt) + + require.GreaterOrEqual(t, delay, maxDelay/2) + require.Less(t, delay, maxDelay) + } +} + +func TestBackoffDoesNotOverflowForLargeAttempts(t *testing.T) { + maxDelay := initialBackoff << maxBackoffShift + + delay := backoff(1000) + + require.GreaterOrEqual(t, delay, maxDelay/2) + require.Less(t, delay, maxDelay) +} From 992005947dabade2d1ebc1c70d0cd54010fcfa0c Mon Sep 17 00:00:00 2001 From: Eoin Motherway <25342760+YuKitsune@users.noreply.github.com> Date: Thu, 15 Oct 2026 13:59:44 +1000 Subject: [PATCH 2/9] test: match the root path with a trailing slash in client options tests Co-Authored-By: Claude Opus 5.5 --- pkg/client/client_options_test.go | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/pkg/client/client_options_test.go b/pkg/client/client_options_test.go index dfabecd2..02697aed 100644 --- a/pkg/client/client_options_test.go +++ b/pkg/client/client_options_test.go @@ -17,14 +17,14 @@ import ( const testRateLimitAPIKey = "API-ABCDEFGHIJKLMNOPQRSTUVWXYZ" // newRateLimitedHttpClient returns an http.Client that returns 429 for the first request to the root resource. -// After that, it returns the root resource for /api and an empty list for all other paths. +// After that, it returns the root resource for /api or /api/ and an empty list for all other paths. func newRateLimitedHttpClient() (*http.Client, func() int) { var mutex sync.Mutex rootRequestCount := 0 httpClient := &http.Client{ Transport: roundTripFunc(func(request *http.Request) (*http.Response, error) { - if request.URL.Path == "/api" { + if strings.TrimSuffix(request.URL.Path, "/") == "/api" { mutex.Lock() rootRequestCount++ count := rootRequestCount From ca8f5481d010bf60c0caa71388eb40a9db011e8e Mon Sep 17 00:00:00 2001 From: Eoin Motherway <25342760+YuKitsune@users.noreply.github.com> Date: Thu, 15 Oct 2026 14:25:26 +1000 Subject: [PATCH 3/9] refactor: replace ClientOptions with ratelimit.NewClient under pkg/client Co-Authored-By: Claude Opus 5.5 --- README.md | 18 +---- .../create_rate_limited_client.go | 16 ++-- pkg/client/octopusdeploy.go | 23 ------ ...ent_options_test.go => rate_limit_test.go} | 51 ++----------- pkg/client/ratelimit/client.go | 16 ++++ pkg/client/ratelimit/client_test.go | 73 +++++++++++++++++++ pkg/{ => client}/ratelimit/transport.go | 9 +++ pkg/{ => client}/ratelimit/transport_test.go | 34 +++++++++ 8 files changed, 147 insertions(+), 93 deletions(-) rename pkg/client/{client_options_test.go => rate_limit_test.go} (54%) create mode 100644 pkg/client/ratelimit/client.go create mode 100644 pkg/client/ratelimit/client_test.go rename pkg/{ => client}/ratelimit/transport.go (94%) rename pkg/{ => client}/ratelimit/transport_test.go (93%) diff --git a/README.md b/README.md index b59da3f0..5fefdab7 100644 --- a/README.md +++ b/README.md @@ -63,25 +63,17 @@ Operations like `Add`, `DeleteByID`, `GetByID`, and `Update` are supported by mo Octopus Server can [limit the rate of requests](https://octopus.com/docs/administration/managing-infrastructure/rate-limiting) from each client. When a client sends too many requests, the server returns `429 Too Many Requests` and does not process the request. -By default, the client does not handle rate limits. To make the client obey the rate limits, use `NewClientWithOptions` and set `RateLimit`: +By default, the client does not handle rate limits. To make the client obey the rate limits, create an `http.Client` with `ratelimit.NewClient` and give it to the client: ```go import ( "github.com/OctopusDeploy/go-octopusdeploy/v2/pkg/client" - "github.com/OctopusDeploy/go-octopusdeploy/v2/pkg/ratelimit" + "github.com/OctopusDeploy/go-octopusdeploy/v2/pkg/client/ratelimit" ) -credentials, err := client.NewApiKey(apiKey) -if err != nil { - _ = fmt.Errorf("error creating API key credentials: %v", err) - return -} +rateLimitedClient := ratelimit.NewClient(nil, ratelimit.DefaultOptions()) -rateLimitOptions := ratelimit.DefaultOptions() - -octopusClient, err := client.NewClientWithOptions(nil, apiURL, credentials, spaceID, "", &client.ClientOptions{ - RateLimit: &rateLimitOptions, -}) +octopusClient, err := client.NewClient(rateLimitedClient, apiURL, apiKey, spaceID) if err != nil { _ = fmt.Errorf("error creating API client: %v", err) return @@ -103,8 +95,6 @@ You can change these settings in `ratelimit.Options`: The client retries a request only if it can send the request body again. For example, the client does not retry a package upload. -If you supply an `http.Client`, the client uses a copy of it and does not change it. To use rate limiting with an `http.Client` of your own in other code, wrap its transport with `ratelimit.NewTransport`. - Numerous code samples that showcase the API and this client are available in the [examples](/examples) directory. There are also many [integration](/test) and unit tests available to examine that demonstrate the capabilities of this API client. ## Testing diff --git a/examples/rate_limiting/create_rate_limited_client.go b/examples/rate_limiting/create_rate_limited_client.go index 5a8a655c..e3b403b5 100644 --- a/examples/rate_limiting/create_rate_limited_client.go +++ b/examples/rate_limiting/create_rate_limited_client.go @@ -6,7 +6,7 @@ import ( "time" "github.com/OctopusDeploy/go-octopusdeploy/v2/pkg/client" - "github.com/OctopusDeploy/go-octopusdeploy/v2/pkg/ratelimit" + "github.com/OctopusDeploy/go-octopusdeploy/v2/pkg/client/ratelimit" "github.com/OctopusDeploy/go-octopusdeploy/v2/pkg/spaces" ) @@ -25,12 +25,6 @@ func CreateRateLimitedClientExample() { return } - credentials, err := client.NewApiKey(apiKey) - if err != nil { - _ = fmt.Errorf("error creating API key credentials: %v", err) - return - } - // start from the recommended settings: 3 retries, a maximum wait of 60 seconds, // and throttling with the advisory rate limit headers rateLimitOptions := ratelimit.DefaultOptions() @@ -39,11 +33,11 @@ func CreateRateLimitedClientExample() { rateLimitOptions.MaxRetries = 5 rateLimitOptions.MaxWait = 30 * time.Second - options := &client.ClientOptions{ - RateLimit: &rateLimitOptions, - } + // the first parameter for ratelimit.NewClient can accept a http.Client if you wish to + // override the default; ratelimit.NewClient returns a copy and does not change it + httpClient := ratelimit.NewClient(nil, rateLimitOptions) - octopusClient, err := client.NewClientWithOptions(nil, apiURL, credentials, spaceID, "", options) + octopusClient, err := client.NewClient(httpClient, apiURL, apiKey, spaceID) if err != nil { _ = fmt.Errorf("error creating API client: %v", err) return diff --git a/pkg/client/octopusdeploy.go b/pkg/client/octopusdeploy.go index b4baa5c8..629c7275 100644 --- a/pkg/client/octopusdeploy.go +++ b/pkg/client/octopusdeploy.go @@ -49,7 +49,6 @@ import ( "github.com/OctopusDeploy/go-octopusdeploy/v2/pkg/projects" "github.com/OctopusDeploy/go-octopusdeploy/v2/pkg/projectvariables" "github.com/OctopusDeploy/go-octopusdeploy/v2/pkg/proxies" - "github.com/OctopusDeploy/go-octopusdeploy/v2/pkg/ratelimit" "github.com/OctopusDeploy/go-octopusdeploy/v2/pkg/releases" "github.com/OctopusDeploy/go-octopusdeploy/v2/pkg/reporting" "github.com/OctopusDeploy/go-octopusdeploy/v2/pkg/runbooks" @@ -209,22 +208,6 @@ func NewClientForTool(httpClient *http.Client, apiURL *url.URL, apiKey string, s // NewClientWithCredentials returns a new Octopus API client with the specified credentials and a tool reference in the useragent string. // If a nil client is provided, a new http.Client will be used. func NewClientWithCredentials(httpClient *http.Client, apiURL *url.URL, apiCredentials ICredential, spaceID string, requestingTool string) (*Client, error) { - return NewClientWithOptions(httpClient, apiURL, apiCredentials, spaceID, requestingTool, nil) -} - -// ClientOptions contains optional settings for a Client. -type ClientOptions struct { - // RateLimit makes the client obey the rate limits of Octopus Server. If RateLimit is nil, the client does not - // retry 429 responses and does not read the advisory rate limit headers. - RateLimit *ratelimit.Options -} - -// NewClientWithOptions returns a new Octopus API client with the specified credentials, a tool reference in the -// useragent string, and the specified options. If a nil client is provided, a new http.Client will be used. -// If a nil options is provided, the default behaviour is used. -// -// If options.RateLimit is set, the client uses a copy of httpClient. The client does not change httpClient. -func NewClientWithOptions(httpClient *http.Client, apiURL *url.URL, apiCredentials ICredential, spaceID string, requestingTool string, options *ClientOptions) (*Client, error) { if apiURL == nil { return nil, internal.CreateInvalidParameterError("NewClient", "apiURL") } @@ -244,12 +227,6 @@ func NewClientWithOptions(httpClient *http.Client, apiURL *url.URL, apiCredentia httpClient = &http.Client{} } - if options != nil && options.RateLimit != nil { - rateLimitedClient := *httpClient - rateLimitedClient.Transport = ratelimit.NewTransport(httpClient.Transport, *options.RateLimit) - httpClient = &rateLimitedClient - } - // fetch root resource and process paths base, root, err := getRoot(httpClient, baseURLWithAPI, apiCredentials, requestingTool) if err != nil { diff --git a/pkg/client/client_options_test.go b/pkg/client/rate_limit_test.go similarity index 54% rename from pkg/client/client_options_test.go rename to pkg/client/rate_limit_test.go index 02697aed..d947c444 100644 --- a/pkg/client/client_options_test.go +++ b/pkg/client/rate_limit_test.go @@ -9,8 +9,8 @@ import ( "testing" "time" + "github.com/OctopusDeploy/go-octopusdeploy/v2/pkg/client/ratelimit" "github.com/OctopusDeploy/go-octopusdeploy/v2/pkg/constants" - "github.com/OctopusDeploy/go-octopusdeploy/v2/pkg/ratelimit" "github.com/stretchr/testify/require" ) @@ -58,64 +58,25 @@ func newRateLimitedHttpClient() (*http.Client, func() int) { } } -func newTestRateLimitOptions() *ClientOptions { - return &ClientOptions{ - RateLimit: &ratelimit.Options{ - MaxRetries: 3, - MaxWait: time.Second, - }, - } -} - -func TestNewClientWithOptionsRetriesTooManyRequests(t *testing.T) { +func TestNewClientWithRateLimitedHttpClientRetriesTooManyRequests(t *testing.T) { httpClient, rootRequestCount := newRateLimitedHttpClient() apiURL, err := url.Parse("https://octopus.example") require.NoError(t, err) - apiKey, err := NewApiKey(testRateLimitAPIKey) - require.NoError(t, err) + rateLimitedHttpClient := ratelimit.NewClient(httpClient, ratelimit.Options{MaxRetries: 3, MaxWait: time.Second}) - octopusClient, err := NewClientWithOptions(httpClient, apiURL, apiKey, "", "test", newTestRateLimitOptions()) + octopusClient, err := NewClient(rateLimitedHttpClient, apiURL, testRateLimitAPIKey, "") require.NoError(t, err) require.NotNil(t, octopusClient) require.Equal(t, 2, rootRequestCount()) } -func TestNewClientWithOptionsDoesNotChangeTheHttpClient(t *testing.T) { - httpClient, _ := newRateLimitedHttpClient() - apiURL, err := url.Parse("https://octopus.example") - require.NoError(t, err) - apiKey, err := NewApiKey(testRateLimitAPIKey) - require.NoError(t, err) - - _, err = NewClientWithOptions(httpClient, apiURL, apiKey, "", "test", newTestRateLimitOptions()) - - require.NoError(t, err) - require.IsType(t, roundTripFunc(nil), httpClient.Transport) -} - -func TestNewClientWithOptionsDoesNotRetryWhenRateLimitIsNil(t *testing.T) { +func TestNewClientWithoutRateLimitedHttpClientReturnsTooManyRequests(t *testing.T) { httpClient, rootRequestCount := newRateLimitedHttpClient() apiURL, err := url.Parse("https://octopus.example") require.NoError(t, err) - apiKey, err := NewApiKey(testRateLimitAPIKey) - require.NoError(t, err) - - octopusClient, err := NewClientWithOptions(httpClient, apiURL, apiKey, "", "test", &ClientOptions{}) - - require.Error(t, err) - require.Nil(t, octopusClient) - require.Equal(t, 1, rootRequestCount()) -} - -func TestNewClientWithOptionsDoesNotRetryWhenOptionsAreNil(t *testing.T) { - httpClient, rootRequestCount := newRateLimitedHttpClient() - apiURL, err := url.Parse("https://octopus.example") - require.NoError(t, err) - apiKey, err := NewApiKey(testRateLimitAPIKey) - require.NoError(t, err) - octopusClient, err := NewClientWithOptions(httpClient, apiURL, apiKey, "", "test", nil) + octopusClient, err := NewClient(httpClient, apiURL, testRateLimitAPIKey, "") require.Error(t, err) require.Nil(t, octopusClient) diff --git a/pkg/client/ratelimit/client.go b/pkg/client/ratelimit/client.go new file mode 100644 index 00000000..55c4a1dc --- /dev/null +++ b/pkg/client/ratelimit/client.go @@ -0,0 +1,16 @@ +package ratelimit + +import "net/http" + +// NewClient returns a copy of base that obeys the rate limits of Octopus Server. +// The copy sends requests through a Transport that wraps the transport of base. +// If base is nil, NewClient uses a new http.Client. NewClient does not change base. +func NewClient(base *http.Client, options Options) *http.Client { + if base == nil { + base = &http.Client{} + } + + client := *base + client.Transport = NewTransport(base.Transport, options) + return &client +} diff --git a/pkg/client/ratelimit/client_test.go b/pkg/client/ratelimit/client_test.go new file mode 100644 index 00000000..18cffebd --- /dev/null +++ b/pkg/client/ratelimit/client_test.go @@ -0,0 +1,73 @@ +package ratelimit + +import ( + "net/http" + "net/http/cookiejar" + "testing" + "time" + + "github.com/stretchr/testify/require" +) + +func TestNewClientWithNilBaseUsesDefaultTransport(t *testing.T) { + client := NewClient(nil, testOptions()) + + require.NotNil(t, client) + require.IsType(t, &Transport{}, client.Transport) + require.Equal(t, http.DefaultTransport, client.Transport.(*Transport).base) +} + +func TestNewClientWrapsTheTransportOfBase(t *testing.T) { + server := &fakeServer{responses: []func() *http.Response{newResponse(http.StatusOK, nil)}} + base := &http.Client{Transport: server} + + client := NewClient(base, testOptions()) + + require.IsType(t, &Transport{}, client.Transport) + require.Equal(t, server, client.Transport.(*Transport).base) +} + +func TestNewClientDoesNotChangeBase(t *testing.T) { + server := &fakeServer{responses: []func() *http.Response{newResponse(http.StatusOK, nil)}} + base := &http.Client{Transport: server} + + client := NewClient(base, testOptions()) + + require.NotSame(t, base, client) + require.Equal(t, server, base.Transport) +} + +func TestNewClientKeepsTheSettingsOfBase(t *testing.T) { + jar, err := cookiejar.New(nil) + require.NoError(t, err) + checkRedirect := func(req *http.Request, via []*http.Request) error { + return http.ErrUseLastResponse + } + base := &http.Client{ + Timeout: 42 * time.Second, + Jar: jar, + CheckRedirect: checkRedirect, + } + + client := NewClient(base, testOptions()) + + require.Equal(t, base.Timeout, client.Timeout) + require.Equal(t, base.Jar, client.Jar) + require.NotNil(t, client.CheckRedirect) + require.ErrorIs(t, client.CheckRedirect(nil, nil), http.ErrUseLastResponse) +} + +func TestNewClientRetriesTooManyRequests(t *testing.T) { + server := &fakeServer{responses: []func() *http.Response{ + newResponse(http.StatusTooManyRequests, map[string]string{"Retry-After": "0"}), + newResponse(http.StatusOK, nil), + }} + + client := NewClient(&http.Client{Transport: server}, testOptions()) + resp, err := client.Get(testURL) + + require.NoError(t, err) + defer resp.Body.Close() + require.Equal(t, http.StatusOK, resp.StatusCode) + require.Equal(t, 2, server.requestCount()) +} diff --git a/pkg/ratelimit/transport.go b/pkg/client/ratelimit/transport.go similarity index 94% rename from pkg/ratelimit/transport.go rename to pkg/client/ratelimit/transport.go index 0aba4b8b..b9942e62 100644 --- a/pkg/ratelimit/transport.go +++ b/pkg/client/ratelimit/transport.go @@ -60,6 +60,9 @@ func DefaultOptions() Options { // // When UseAdvisoryHeaders is true, the Transport also reads the advisory headers on each response. If no tokens remain, // the Transport waits for one token before it sends the next request. +// +// Use NewTransport to create a Transport. A Transport is safe for concurrent use by multiple goroutines. +// Do not copy a Transport after its first use. type Transport struct { base http.RoundTripper options Options @@ -68,6 +71,8 @@ type Transport struct { nextRequestTime time.Time } +var _ http.RoundTripper = (*Transport)(nil) + // NewTransport returns a Transport that sends requests with base. If base is nil, the Transport uses http.DefaultTransport. func NewTransport(base http.RoundTripper, options Options) *Transport { if base == nil { @@ -85,6 +90,10 @@ func (t *Transport) RoundTrip(req *http.Request) (*http.Response, error) { for attempt := 0; ; attempt++ { if t.options.UseAdvisoryHeaders { if err := wait(req.Context(), t.throttleDelay()); err != nil { + // The base transport did not get the request, so the body must be closed here. + if attempt == 0 && req.Body != nil { + _ = req.Body.Close() + } return nil, err } } diff --git a/pkg/ratelimit/transport_test.go b/pkg/client/ratelimit/transport_test.go similarity index 93% rename from pkg/ratelimit/transport_test.go rename to pkg/client/ratelimit/transport_test.go index 221e88c7..61d8c4d4 100644 --- a/pkg/ratelimit/transport_test.go +++ b/pkg/client/ratelimit/transport_test.go @@ -400,6 +400,40 @@ func TestRoundTripReturnsErrorWhenContextIsCancelledDuringThrottleWait(t *testin require.Equal(t, 1, server.requestCount()) } +type closeRecorder struct { + io.Reader + closed bool +} + +func (c *closeRecorder) Close() error { + c.closed = true + return nil +} + +func TestRoundTripClosesBodyWhenContextIsCancelledBeforeFirstAttempt(t *testing.T) { + server := &fakeServer{responses: []func() *http.Response{ + newResponse(http.StatusOK, map[string]string{PolicyHeader: "l=200;rpm=1", StateHeader: "r=0;t=60"}), + }} + options := testOptions() + options.MaxWait = time.Minute + transport := NewTransport(server, options) + + _, err := transport.RoundTrip(newGetRequest(t)) + require.NoError(t, err) + + ctx, cancel := context.WithCancel(context.Background()) + cancel() + body := &closeRecorder{Reader: strings.NewReader(`{"Name":"test"}`)} + req, err := http.NewRequestWithContext(ctx, http.MethodPost, testURL, body) + require.NoError(t, err) + + _, err = transport.RoundTrip(req) + + require.ErrorIs(t, err, context.Canceled) + require.True(t, body.closed) + require.Equal(t, 1, server.requestCount()) +} + func TestParseHeader(t *testing.T) { testCases := []struct { name string From b067116bfacc98bcd561df20c4b743bac312ebeb Mon Sep 17 00:00:00 2001 From: Eoin Motherway <25342760+YuKitsune@users.noreply.github.com> Date: Thu, 15 Oct 2026 14:36:00 +1000 Subject: [PATCH 4/9] refactor: rename ratelimit.NewClient to ratelimit.NewHttpClient Co-Authored-By: Claude Opus 5.5 --- README.md | 4 ++-- .../create_rate_limited_client.go | 6 +++--- pkg/client/rate_limit_test.go | 2 +- .../ratelimit/{client.go => http_client.go} | 6 +++--- .../{client_test.go => http_client_test.go} | 20 +++++++++---------- 5 files changed, 19 insertions(+), 19 deletions(-) rename pkg/client/ratelimit/{client.go => http_client.go} (50%) rename pkg/client/ratelimit/{client_test.go => http_client_test.go} (73%) diff --git a/README.md b/README.md index 5fefdab7..9a06530a 100644 --- a/README.md +++ b/README.md @@ -63,7 +63,7 @@ Operations like `Add`, `DeleteByID`, `GetByID`, and `Update` are supported by mo Octopus Server can [limit the rate of requests](https://octopus.com/docs/administration/managing-infrastructure/rate-limiting) from each client. When a client sends too many requests, the server returns `429 Too Many Requests` and does not process the request. -By default, the client does not handle rate limits. To make the client obey the rate limits, create an `http.Client` with `ratelimit.NewClient` and give it to the client: +By default, the client does not handle rate limits. To make the client obey the rate limits, create an `http.Client` with `ratelimit.NewHttpClient` and give it to the client: ```go import ( @@ -71,7 +71,7 @@ import ( "github.com/OctopusDeploy/go-octopusdeploy/v2/pkg/client/ratelimit" ) -rateLimitedClient := ratelimit.NewClient(nil, ratelimit.DefaultOptions()) +rateLimitedClient := ratelimit.NewHttpClient(nil, ratelimit.DefaultOptions()) octopusClient, err := client.NewClient(rateLimitedClient, apiURL, apiKey, spaceID) if err != nil { diff --git a/examples/rate_limiting/create_rate_limited_client.go b/examples/rate_limiting/create_rate_limited_client.go index e3b403b5..6b689ec2 100644 --- a/examples/rate_limiting/create_rate_limited_client.go +++ b/examples/rate_limiting/create_rate_limited_client.go @@ -33,9 +33,9 @@ func CreateRateLimitedClientExample() { rateLimitOptions.MaxRetries = 5 rateLimitOptions.MaxWait = 30 * time.Second - // the first parameter for ratelimit.NewClient can accept a http.Client if you wish to - // override the default; ratelimit.NewClient returns a copy and does not change it - httpClient := ratelimit.NewClient(nil, rateLimitOptions) + // the first parameter for ratelimit.NewHttpClient can accept a http.Client if you wish to + // override the default; ratelimit.NewHttpClient returns a copy and does not change it + httpClient := ratelimit.NewHttpClient(nil, rateLimitOptions) octopusClient, err := client.NewClient(httpClient, apiURL, apiKey, spaceID) if err != nil { diff --git a/pkg/client/rate_limit_test.go b/pkg/client/rate_limit_test.go index d947c444..a929b6f4 100644 --- a/pkg/client/rate_limit_test.go +++ b/pkg/client/rate_limit_test.go @@ -62,7 +62,7 @@ func TestNewClientWithRateLimitedHttpClientRetriesTooManyRequests(t *testing.T) httpClient, rootRequestCount := newRateLimitedHttpClient() apiURL, err := url.Parse("https://octopus.example") require.NoError(t, err) - rateLimitedHttpClient := ratelimit.NewClient(httpClient, ratelimit.Options{MaxRetries: 3, MaxWait: time.Second}) + rateLimitedHttpClient := ratelimit.NewHttpClient(httpClient, ratelimit.Options{MaxRetries: 3, MaxWait: time.Second}) octopusClient, err := NewClient(rateLimitedHttpClient, apiURL, testRateLimitAPIKey, "") diff --git a/pkg/client/ratelimit/client.go b/pkg/client/ratelimit/http_client.go similarity index 50% rename from pkg/client/ratelimit/client.go rename to pkg/client/ratelimit/http_client.go index 55c4a1dc..5b4e7b0f 100644 --- a/pkg/client/ratelimit/client.go +++ b/pkg/client/ratelimit/http_client.go @@ -2,10 +2,10 @@ package ratelimit import "net/http" -// NewClient returns a copy of base that obeys the rate limits of Octopus Server. +// NewHttpClient returns a copy of base that obeys the rate limits of Octopus Server. // The copy sends requests through a Transport that wraps the transport of base. -// If base is nil, NewClient uses a new http.Client. NewClient does not change base. -func NewClient(base *http.Client, options Options) *http.Client { +// If base is nil, NewHttpClient uses a new http.Client. NewHttpClient does not change base. +func NewHttpClient(base *http.Client, options Options) *http.Client { if base == nil { base = &http.Client{} } diff --git a/pkg/client/ratelimit/client_test.go b/pkg/client/ratelimit/http_client_test.go similarity index 73% rename from pkg/client/ratelimit/client_test.go rename to pkg/client/ratelimit/http_client_test.go index 18cffebd..6c51151e 100644 --- a/pkg/client/ratelimit/client_test.go +++ b/pkg/client/ratelimit/http_client_test.go @@ -9,35 +9,35 @@ import ( "github.com/stretchr/testify/require" ) -func TestNewClientWithNilBaseUsesDefaultTransport(t *testing.T) { - client := NewClient(nil, testOptions()) +func TestNewHttpClientWithNilBaseUsesDefaultTransport(t *testing.T) { + client := NewHttpClient(nil, testOptions()) require.NotNil(t, client) require.IsType(t, &Transport{}, client.Transport) require.Equal(t, http.DefaultTransport, client.Transport.(*Transport).base) } -func TestNewClientWrapsTheTransportOfBase(t *testing.T) { +func TestNewHttpClientWrapsTheTransportOfBase(t *testing.T) { server := &fakeServer{responses: []func() *http.Response{newResponse(http.StatusOK, nil)}} base := &http.Client{Transport: server} - client := NewClient(base, testOptions()) + client := NewHttpClient(base, testOptions()) require.IsType(t, &Transport{}, client.Transport) require.Equal(t, server, client.Transport.(*Transport).base) } -func TestNewClientDoesNotChangeBase(t *testing.T) { +func TestNewHttpClientDoesNotChangeBase(t *testing.T) { server := &fakeServer{responses: []func() *http.Response{newResponse(http.StatusOK, nil)}} base := &http.Client{Transport: server} - client := NewClient(base, testOptions()) + client := NewHttpClient(base, testOptions()) require.NotSame(t, base, client) require.Equal(t, server, base.Transport) } -func TestNewClientKeepsTheSettingsOfBase(t *testing.T) { +func TestNewHttpClientKeepsTheSettingsOfBase(t *testing.T) { jar, err := cookiejar.New(nil) require.NoError(t, err) checkRedirect := func(req *http.Request, via []*http.Request) error { @@ -49,7 +49,7 @@ func TestNewClientKeepsTheSettingsOfBase(t *testing.T) { CheckRedirect: checkRedirect, } - client := NewClient(base, testOptions()) + client := NewHttpClient(base, testOptions()) require.Equal(t, base.Timeout, client.Timeout) require.Equal(t, base.Jar, client.Jar) @@ -57,13 +57,13 @@ func TestNewClientKeepsTheSettingsOfBase(t *testing.T) { require.ErrorIs(t, client.CheckRedirect(nil, nil), http.ErrUseLastResponse) } -func TestNewClientRetriesTooManyRequests(t *testing.T) { +func TestNewHttpClientRetriesTooManyRequests(t *testing.T) { server := &fakeServer{responses: []func() *http.Response{ newResponse(http.StatusTooManyRequests, map[string]string{"Retry-After": "0"}), newResponse(http.StatusOK, nil), }} - client := NewClient(&http.Client{Transport: server}, testOptions()) + client := NewHttpClient(&http.Client{Transport: server}, testOptions()) resp, err := client.Get(testURL) require.NoError(t, err) From 98683767c53734b4000919b231fa64cd4061664d Mon Sep 17 00:00:00 2001 From: Eoin Motherway <25342760+YuKitsune@users.noreply.github.com> Date: Thu, 15 Oct 2026 14:48:46 +1000 Subject: [PATCH 5/9] fix: address review feedback for the rate limit transport Default MaxWait when it is 0, pass CloseIdleConnections to the base transport, and remove upper-bound timing checks from the tests. Co-Authored-By: Claude Opus 5.5 --- README.md | 2 +- pkg/client/ratelimit/http_client.go | 3 + pkg/client/ratelimit/http_client_test.go | 9 ++ pkg/client/ratelimit/transport.go | 32 +++++- pkg/client/ratelimit/transport_test.go | 120 ++++++++++++++++++++--- 5 files changed, 144 insertions(+), 22 deletions(-) diff --git a/README.md b/README.md index 9a06530a..f21ce458 100644 --- a/README.md +++ b/README.md @@ -90,7 +90,7 @@ You can change these settings in `ratelimit.Options`: | Setting | Default | Description | | -------------------- | ---------- | --------------------------------------------------------------------------------------------------------------------------------------------------- | | `MaxRetries` | `3` | The maximum number of times to send a request again after a `429` response. Set to `0` to turn off retries. | -| `MaxWait` | `60s` | The maximum time to wait before a request. If `Retry-After` is longer than this time, the client does not retry and returns the `429` response. | +| `MaxWait` | `60s` | The maximum time to wait before a request. If `Retry-After` is longer than this time, the client does not retry and returns the `429` response. If `MaxWait` is `0` or less, the default of `60s` is used. | | `UseAdvisoryHeaders` | `true` | Wait before a request when the advisory headers show that no requests remain. | The client retries a request only if it can send the request body again. For example, the client does not retry a package upload. diff --git a/pkg/client/ratelimit/http_client.go b/pkg/client/ratelimit/http_client.go index 5b4e7b0f..85bc932b 100644 --- a/pkg/client/ratelimit/http_client.go +++ b/pkg/client/ratelimit/http_client.go @@ -5,6 +5,9 @@ import "net/http" // NewHttpClient returns a copy of base that obeys the rate limits of Octopus Server. // The copy sends requests through a Transport that wraps the transport of base. // If base is nil, NewHttpClient uses a new http.Client. NewHttpClient does not change base. +// +// The Timeout of base includes the time that the Transport waits before a retry or a throttled request. +// If Timeout is less than MaxWait, a request can fail with a timeout while the Transport waits. func NewHttpClient(base *http.Client, options Options) *http.Client { if base == nil { base = &http.Client{} diff --git a/pkg/client/ratelimit/http_client_test.go b/pkg/client/ratelimit/http_client_test.go index 6c51151e..d88aca80 100644 --- a/pkg/client/ratelimit/http_client_test.go +++ b/pkg/client/ratelimit/http_client_test.go @@ -57,6 +57,15 @@ func TestNewHttpClientKeepsTheSettingsOfBase(t *testing.T) { require.ErrorIs(t, client.CheckRedirect(nil, nil), http.ErrUseLastResponse) } +func TestNewHttpClientCloseIdleConnectionsReachesTheBaseTransport(t *testing.T) { + base := &idleConnectionCloser{} + + client := NewHttpClient(&http.Client{Transport: base}, testOptions()) + client.CloseIdleConnections() + + require.Equal(t, 1, base.closeCount) +} + func TestNewHttpClientRetriesTooManyRequests(t *testing.T) { server := &fakeServer{responses: []func() *http.Response{ newResponse(http.StatusTooManyRequests, map[string]string{"Retry-After": "0"}), diff --git a/pkg/client/ratelimit/transport.go b/pkg/client/ratelimit/transport.go index b9942e62..6324cd22 100644 --- a/pkg/client/ratelimit/transport.go +++ b/pkg/client/ratelimit/transport.go @@ -35,6 +35,7 @@ type Options struct { // MaxWait is the maximum time that the Transport waits before it sends a request. // If the server tells the Transport to wait longer before a retry, the Transport does not retry. It returns the 429 response. + // If MaxWait is 0 or less, the Transport uses the default of 60 seconds from DefaultOptions. MaxWait time.Duration // UseAdvisoryHeaders makes the Transport wait before it sends a request when the advisory headers @@ -74,11 +75,16 @@ type Transport struct { var _ http.RoundTripper = (*Transport)(nil) // NewTransport returns a Transport that sends requests with base. If base is nil, the Transport uses http.DefaultTransport. +// If options.MaxWait is 0 or less, the Transport uses the default of 60 seconds from DefaultOptions. func NewTransport(base http.RoundTripper, options Options) *Transport { if base == nil { base = http.DefaultTransport } + if options.MaxWait <= 0 { + options.MaxWait = DefaultOptions().MaxWait + } + return &Transport{ base: base, options: options, @@ -116,11 +122,8 @@ func (t *Transport) RoundTrip(req *http.Request) (*http.Response, error) { return resp, nil } - delay, found := retryAfter(resp) - if !found { - delay = min(backoff(attempt), t.options.MaxWait) - } - if delay > t.options.MaxWait { + delay, ok := t.retryDelay(resp, attempt) + if !ok { return resp, nil } @@ -131,6 +134,25 @@ func (t *Transport) RoundTrip(req *http.Request) (*http.Response, error) { } } +// CloseIdleConnections closes the idle connections of the base transport, if the base transport supports it. +// http.Client.CloseIdleConnections calls this method. +func (t *Transport) CloseIdleConnections() { + if closer, ok := t.base.(interface{ CloseIdleConnections() }); ok { + closer.CloseIdleConnections() + } +} + +// retryDelay returns the time to wait before a retry. It uses the Retry-After header if the server sends it. +// If not, it uses exponential backoff with jitter, up to MaxWait. It returns false if Retry-After is longer than MaxWait. +func (t *Transport) retryDelay(resp *http.Response, attempt int) (time.Duration, bool) { + delay, found := retryAfter(resp) + if !found { + delay = min(backoff(attempt), t.options.MaxWait) + } + + return delay, delay <= t.options.MaxWait +} + // throttleDelay returns the time to wait before the next request, as the advisory headers tell. func (t *Transport) throttleDelay() time.Duration { t.mutex.Lock() diff --git a/pkg/client/ratelimit/transport_test.go b/pkg/client/ratelimit/transport_test.go index 61d8c4d4..23815248 100644 --- a/pkg/client/ratelimit/transport_test.go +++ b/pkg/client/ratelimit/transport_test.go @@ -99,6 +99,30 @@ func TestNewTransportWithNilBaseUsesDefaultTransport(t *testing.T) { require.Equal(t, http.DefaultTransport, transport.base) } +func TestNewTransportUsesDefaultMaxWaitWhenMaxWaitIsNotPositive(t *testing.T) { + testCases := []struct { + name string + maxWait time.Duration + }{ + {"Zero", 0}, + {"Negative", -time.Second}, + } + + for _, testCase := range testCases { + t.Run(testCase.name, func(t *testing.T) { + transport := NewTransport(nil, Options{MaxRetries: 3, MaxWait: testCase.maxWait}) + + require.Equal(t, DefaultOptions().MaxWait, transport.options.MaxWait) + }) + } +} + +func TestNewTransportKeepsPositiveMaxWait(t *testing.T) { + transport := NewTransport(nil, Options{MaxRetries: 3, MaxWait: 5 * time.Second}) + + require.Equal(t, 5*time.Second, transport.options.MaxWait) +} + func TestRoundTripReturnsSuccessfulResponse(t *testing.T) { server := &fakeServer{responses: []func() *http.Response{newResponse(http.StatusOK, nil)}} transport := NewTransport(server, testOptions()) @@ -170,13 +194,11 @@ func TestRoundTripDoesNotRetryWhenRetryAfterIsLongerThanMaxWait(t *testing.T) { }} transport := NewTransport(server, testOptions()) - start := time.Now() resp, err := transport.RoundTrip(newGetRequest(t)) require.NoError(t, err) require.Equal(t, http.StatusTooManyRequests, resp.StatusCode) require.Equal(t, 1, server.requestCount()) - require.Less(t, time.Since(start), time.Second) } func TestRoundTripWaitsForRetryAfter(t *testing.T) { @@ -212,7 +234,52 @@ func TestRoundTripUsesBackoffCappedAtMaxWaitWhenRetryAfterIsMissing(t *testing.T require.Equal(t, http.StatusOK, resp.StatusCode) require.Equal(t, 2, server.requestCount()) require.GreaterOrEqual(t, time.Since(start), 50*time.Millisecond) - require.Less(t, time.Since(start), 500*time.Millisecond) +} + +func TestRetryDelayCapsBackoffAtMaxWait(t *testing.T) { + options := testOptions() + options.MaxWait = 50 * time.Millisecond + transport := NewTransport(nil, options) + + // The backoff for attempt 0 is at least 500 milliseconds, thus MaxWait applies. + delay, ok := transport.retryDelay(newResponse(http.StatusTooManyRequests, nil)(), 0) + + require.True(t, ok) + require.Equal(t, 50*time.Millisecond, delay) +} + +func TestRetryDelayUsesBackoffWhenRetryAfterIsMissing(t *testing.T) { + options := testOptions() + options.MaxWait = time.Minute + transport := NewTransport(nil, options) + + delay, ok := transport.retryDelay(newResponse(http.StatusTooManyRequests, nil)(), 0) + + require.True(t, ok) + require.GreaterOrEqual(t, delay, initialBackoff/2) + require.Less(t, delay, initialBackoff) +} + +func TestRetryDelayUsesRetryAfter(t *testing.T) { + options := testOptions() + options.MaxWait = time.Minute + transport := NewTransport(nil, options) + + delay, ok := transport.retryDelay(newResponse(http.StatusTooManyRequests, map[string]string{"Retry-After": "5"})(), 0) + + require.True(t, ok) + require.Equal(t, 5*time.Second, delay) +} + +func TestRetryDelayRejectsRetryAfterLongerThanMaxWait(t *testing.T) { + options := testOptions() + options.MaxWait = time.Minute + transport := NewTransport(nil, options) + + delay, ok := transport.retryDelay(newResponse(http.StatusTooManyRequests, map[string]string{"Retry-After": "120"})(), 0) + + require.False(t, ok) + require.Equal(t, 120*time.Second, delay) } func TestRoundTripSendsTheSameBodyOnRetry(t *testing.T) { @@ -265,13 +332,11 @@ func TestRoundTripReturnsErrorWhenContextIsCancelledDuringRetryWait(t *testing.T req, err := http.NewRequestWithContext(ctx, http.MethodGet, testURL, nil) require.NoError(t, err) - start := time.Now() resp, err := transport.RoundTrip(req) require.ErrorIs(t, err, context.DeadlineExceeded) require.Nil(t, resp) require.Equal(t, 1, server.requestCount()) - require.Less(t, time.Since(start), time.Second) } func TestRoundTripReturnsErrorFromBaseTransport(t *testing.T) { @@ -311,13 +376,9 @@ func TestRoundTripDoesNotWaitWhenTokensRemain(t *testing.T) { transport := NewTransport(server, testOptions()) _, err := transport.RoundTrip(newGetRequest(t)) - require.NoError(t, err) - - start := time.Now() - _, err = transport.RoundTrip(newGetRequest(t)) require.NoError(t, err) - require.Less(t, time.Since(start), 50*time.Millisecond) + require.LessOrEqual(t, transport.throttleDelay(), time.Duration(0)) } func TestUpdateThrottleClearsWaitWhenTokensAreAvailableAgain(t *testing.T) { @@ -351,12 +412,14 @@ func TestRoundTripCapsThrottleWaitAtMaxWait(t *testing.T) { _, err := transport.RoundTrip(newGetRequest(t)) require.NoError(t, err) + // 1 request per minute gives one token each minute, thus MaxWait applies. + require.Equal(t, 50*time.Millisecond, transport.throttleDelay()) + start := time.Now() _, err = transport.RoundTrip(newGetRequest(t)) require.NoError(t, err) require.GreaterOrEqual(t, time.Since(start), 50*time.Millisecond) - require.Less(t, time.Since(start), 500*time.Millisecond) } func TestRoundTripDoesNotThrottleWhenAdvisoryHeadersAreNotUsed(t *testing.T) { @@ -368,13 +431,9 @@ func TestRoundTripDoesNotThrottleWhenAdvisoryHeadersAreNotUsed(t *testing.T) { transport := NewTransport(server, options) _, err := transport.RoundTrip(newGetRequest(t)) - require.NoError(t, err) - - start := time.Now() - _, err = transport.RoundTrip(newGetRequest(t)) require.NoError(t, err) - require.Less(t, time.Since(start), 50*time.Millisecond) + require.LessOrEqual(t, transport.throttleDelay(), time.Duration(0)) } func TestRoundTripReturnsErrorWhenContextIsCancelledDuringThrottleWait(t *testing.T) { @@ -400,6 +459,35 @@ func TestRoundTripReturnsErrorWhenContextIsCancelledDuringThrottleWait(t *testin require.Equal(t, 1, server.requestCount()) } +// idleConnectionCloser is a base transport that records the calls to CloseIdleConnections. +type idleConnectionCloser struct { + closeCount int +} + +func (c *idleConnectionCloser) RoundTrip(req *http.Request) (*http.Response, error) { + return newResponse(http.StatusOK, nil)(), nil +} + +func (c *idleConnectionCloser) CloseIdleConnections() { + c.closeCount++ +} + +func TestCloseIdleConnectionsCallsTheBaseTransport(t *testing.T) { + base := &idleConnectionCloser{} + transport := NewTransport(base, testOptions()) + + transport.CloseIdleConnections() + + require.Equal(t, 1, base.closeCount) +} + +func TestCloseIdleConnectionsDoesNothingWhenTheBaseTransportDoesNotSupportIt(t *testing.T) { + server := &fakeServer{responses: []func() *http.Response{newResponse(http.StatusOK, nil)}} + transport := NewTransport(server, testOptions()) + + require.NotPanics(t, transport.CloseIdleConnections) +} + type closeRecorder struct { io.Reader closed bool From c382947b4b3684b529c9e2af6780ad4623f80092 Mon Sep 17 00:00:00 2001 From: Eoin Motherway <25342760+YuKitsune@users.noreply.github.com> Date: Thu, 15 Oct 2026 15:08:23 +1000 Subject: [PATCH 6/9] feat: add Base method to the rate limit transport Co-Authored-By: Claude Opus 5.5 --- pkg/client/ratelimit/http_client_test.go | 4 ++-- pkg/client/ratelimit/transport.go | 5 +++++ pkg/client/ratelimit/transport_test.go | 10 +++++++++- 3 files changed, 16 insertions(+), 3 deletions(-) diff --git a/pkg/client/ratelimit/http_client_test.go b/pkg/client/ratelimit/http_client_test.go index d88aca80..80de7621 100644 --- a/pkg/client/ratelimit/http_client_test.go +++ b/pkg/client/ratelimit/http_client_test.go @@ -14,7 +14,7 @@ func TestNewHttpClientWithNilBaseUsesDefaultTransport(t *testing.T) { require.NotNil(t, client) require.IsType(t, &Transport{}, client.Transport) - require.Equal(t, http.DefaultTransport, client.Transport.(*Transport).base) + require.Equal(t, http.DefaultTransport, client.Transport.(*Transport).Base()) } func TestNewHttpClientWrapsTheTransportOfBase(t *testing.T) { @@ -24,7 +24,7 @@ func TestNewHttpClientWrapsTheTransportOfBase(t *testing.T) { client := NewHttpClient(base, testOptions()) require.IsType(t, &Transport{}, client.Transport) - require.Equal(t, server, client.Transport.(*Transport).base) + require.Equal(t, server, client.Transport.(*Transport).Base()) } func TestNewHttpClientDoesNotChangeBase(t *testing.T) { diff --git a/pkg/client/ratelimit/transport.go b/pkg/client/ratelimit/transport.go index 6324cd22..415132d8 100644 --- a/pkg/client/ratelimit/transport.go +++ b/pkg/client/ratelimit/transport.go @@ -134,6 +134,11 @@ func (t *Transport) RoundTrip(req *http.Request) (*http.Response, error) { } } +// Base returns the http.RoundTripper that the Transport uses to send requests. +func (t *Transport) Base() http.RoundTripper { + return t.base +} + // CloseIdleConnections closes the idle connections of the base transport, if the base transport supports it. // http.Client.CloseIdleConnections calls this method. func (t *Transport) CloseIdleConnections() { diff --git a/pkg/client/ratelimit/transport_test.go b/pkg/client/ratelimit/transport_test.go index 23815248..b768ecab 100644 --- a/pkg/client/ratelimit/transport_test.go +++ b/pkg/client/ratelimit/transport_test.go @@ -96,7 +96,15 @@ func TestDefaultOptions(t *testing.T) { func TestNewTransportWithNilBaseUsesDefaultTransport(t *testing.T) { transport := NewTransport(nil, testOptions()) - require.Equal(t, http.DefaultTransport, transport.base) + require.Equal(t, http.DefaultTransport, transport.Base()) +} + +func TestBaseReturnsTheBaseTransport(t *testing.T) { + server := &fakeServer{responses: []func() *http.Response{newResponse(http.StatusOK, nil)}} + + transport := NewTransport(server, testOptions()) + + require.Same(t, server, transport.Base()) } func TestNewTransportUsesDefaultMaxWaitWhenMaxWaitIsNotPositive(t *testing.T) { From 40b04b430c2ee9462bc6f3c1b36f5f275f7ab1a2 Mon Sep 17 00:00:00 2001 From: Eoin Motherway <25342760+YuKitsune@users.noreply.github.com> Date: Thu, 15 Oct 2026 15:33:26 +1000 Subject: [PATCH 7/9] format --- pkg/client/rate_limit_test.go | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/pkg/client/rate_limit_test.go b/pkg/client/rate_limit_test.go index a929b6f4..dc32d40b 100644 --- a/pkg/client/rate_limit_test.go +++ b/pkg/client/rate_limit_test.go @@ -9,9 +9,10 @@ import ( "testing" "time" + "github.com/stretchr/testify/require" + "github.com/OctopusDeploy/go-octopusdeploy/v2/pkg/client/ratelimit" "github.com/OctopusDeploy/go-octopusdeploy/v2/pkg/constants" - "github.com/stretchr/testify/require" ) const testRateLimitAPIKey = "API-ABCDEFGHIJKLMNOPQRSTUVWXYZ" From 27f2fc05196c87b679f1e22b58d20af56e5cd08b Mon Sep 17 00:00:00 2001 From: Eoin Motherway <25342760+YuKitsune@users.noreply.github.com> Date: Fri, 16 Oct 2026 09:07:06 +1000 Subject: [PATCH 8/9] feat: pace requests with GCRA and match the C# and TypeScript clients Port the GCRA pacer and the retry delay from the C# and TypeScript clients, with their test cases. Rename the options to match, and default to 5 retries. Co-Authored-By: Claude Opus 5.5 --- README.md | 15 +- .../create_rate_limited_client.go | 8 +- pkg/client/rate_limit_test.go | 2 +- pkg/client/ratelimit/http_client.go | 4 +- pkg/client/ratelimit/pacer.go | 187 ++++++++ pkg/client/ratelimit/pacer_test.go | 382 ++++++++++++++++ pkg/client/ratelimit/retry.go | 78 ++++ pkg/client/ratelimit/retry_test.go | 194 ++++++++ pkg/client/ratelimit/transport.go | 168 +++---- pkg/client/ratelimit/transport_test.go | 425 +++++++----------- 10 files changed, 1065 insertions(+), 398 deletions(-) create mode 100644 pkg/client/ratelimit/pacer.go create mode 100644 pkg/client/ratelimit/pacer_test.go create mode 100644 pkg/client/ratelimit/retry.go create mode 100644 pkg/client/ratelimit/retry_test.go diff --git a/README.md b/README.md index f21ce458..9dd85522 100644 --- a/README.md +++ b/README.md @@ -82,16 +82,17 @@ if err != nil { With rate limiting on, the client does these things: -- When the server returns `429 Too Many Requests`, the client waits and sends the request again. It waits for the time in the `Retry-After` header. If the server does not send this header, it uses exponential backoff. -- When the `Octopus-RateLimit` advisory header shows that no requests remain, the client waits before it sends the next request. This prevents most `429` responses. +- When the server returns `429 Too Many Requests`, the client waits and sends the request again. It waits for the time in the `Retry-After` header, or for `DefaultDelay` if the server does not send this header. The delay doubles with each retry, and has random jitter. +- The client paces its requests with the `Octopus-RateLimit-Policy` and `Octopus-RateLimit` advisory headers, so that it stays in the rate limit. This prevents most `429` responses. The client uses GCRA (the Generic Cell Rate Algorithm), the same as the Octopus C# and TypeScript clients. You can change these settings in `ratelimit.Options`: -| Setting | Default | Description | -| -------------------- | ---------- | --------------------------------------------------------------------------------------------------------------------------------------------------- | -| `MaxRetries` | `3` | The maximum number of times to send a request again after a `429` response. Set to `0` to turn off retries. | -| `MaxWait` | `60s` | The maximum time to wait before a request. If `Retry-After` is longer than this time, the client does not retry and returns the `429` response. If `MaxWait` is `0` or less, the default of `60s` is used. | -| `UseAdvisoryHeaders` | `true` | Wait before a request when the advisory headers show that no requests remain. | +| Setting | Default | Description | +| --------------------- | ------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| `RetryCount` | `5` | The maximum number of times to send a request again after a `429` response. Set to `0` to turn off retries. | +| `DefaultDelay` | `2s` | The time to wait before a retry when the `429` response has no `Retry-After` header. If `DefaultDelay` is `0` or less, the default of `2s` is used. | +| `MaxDelay` | `30s` | The maximum time to wait before a retry. If `Retry-After` is longer than this time, the client does not retry and returns the `429` response. If `MaxDelay` is `0` or less, the default of `30s` is used. | +| `UseRateLimitHeaders` | `true` | Pace the requests with the advisory rate limit headers. | The client retries a request only if it can send the request body again. For example, the client does not retry a package upload. diff --git a/examples/rate_limiting/create_rate_limited_client.go b/examples/rate_limiting/create_rate_limited_client.go index 6b689ec2..ce10e7f5 100644 --- a/examples/rate_limiting/create_rate_limited_client.go +++ b/examples/rate_limiting/create_rate_limited_client.go @@ -25,13 +25,13 @@ func CreateRateLimitedClientExample() { return } - // start from the recommended settings: 3 retries, a maximum wait of 60 seconds, - // and throttling with the advisory rate limit headers + // start from the recommended settings: 5 retries, a default delay of 2 seconds, + // a maximum delay of 30 seconds, and pacing with the rate limit headers rateLimitOptions := ratelimit.DefaultOptions() // change the settings if necessary - rateLimitOptions.MaxRetries = 5 - rateLimitOptions.MaxWait = 30 * time.Second + rateLimitOptions.RetryCount = 10 + rateLimitOptions.MaxDelay = 60 * time.Second // the first parameter for ratelimit.NewHttpClient can accept a http.Client if you wish to // override the default; ratelimit.NewHttpClient returns a copy and does not change it diff --git a/pkg/client/rate_limit_test.go b/pkg/client/rate_limit_test.go index dc32d40b..50a03354 100644 --- a/pkg/client/rate_limit_test.go +++ b/pkg/client/rate_limit_test.go @@ -63,7 +63,7 @@ func TestNewClientWithRateLimitedHttpClientRetriesTooManyRequests(t *testing.T) httpClient, rootRequestCount := newRateLimitedHttpClient() apiURL, err := url.Parse("https://octopus.example") require.NoError(t, err) - rateLimitedHttpClient := ratelimit.NewHttpClient(httpClient, ratelimit.Options{MaxRetries: 3, MaxWait: time.Second}) + rateLimitedHttpClient := ratelimit.NewHttpClient(httpClient, ratelimit.Options{RetryCount: 3, MaxDelay: time.Second}) octopusClient, err := NewClient(rateLimitedHttpClient, apiURL, testRateLimitAPIKey, "") diff --git a/pkg/client/ratelimit/http_client.go b/pkg/client/ratelimit/http_client.go index 85bc932b..70bd7db6 100644 --- a/pkg/client/ratelimit/http_client.go +++ b/pkg/client/ratelimit/http_client.go @@ -6,8 +6,8 @@ import "net/http" // The copy sends requests through a Transport that wraps the transport of base. // If base is nil, NewHttpClient uses a new http.Client. NewHttpClient does not change base. // -// The Timeout of base includes the time that the Transport waits before a retry or a throttled request. -// If Timeout is less than MaxWait, a request can fail with a timeout while the Transport waits. +// The Timeout of base includes the time that the Transport waits before a retry or a paced request. +// If Timeout is less than MaxDelay, a request can fail with a timeout while the Transport waits. func NewHttpClient(base *http.Client, options Options) *http.Client { if base == nil { base = &http.Client{} diff --git a/pkg/client/ratelimit/pacer.go b/pkg/client/ratelimit/pacer.go new file mode 100644 index 00000000..53ed1b31 --- /dev/null +++ b/pkg/client/ratelimit/pacer.go @@ -0,0 +1,187 @@ +package ratelimit + +import ( + "regexp" + "strconv" + "strings" + "sync" + "time" +) + +// reservedRequests is the number of requests of the allowance that the pacer does not spend. The server tells +// the remaining requests as a whole number and its bucket refills on a timer, thus the arithmetic of the server +// and the pacer can be different by a fraction of a request. When the pacer stops one request before the limit, +// that difference causes a small wait and not a 429 response. +const reservedRequests = 1 + +// numberPattern matches the numbers that the advisory headers can contain. strconv.ParseFloat also accepts +// values such as "0x10", "Inf" and "NaN", which the server does not send. +var numberPattern = regexp.MustCompile(`^[-+]?(\d+(\.\d*)?|\.\d+)([eE][-+]?\d+)?$`) + +// pacer keeps requests in the rate limit that the server advertises in the PolicyHeader and RateLimitHeader +// headers. Thus the client spreads its requests out and does not send them until the server rejects them. +// +// The pacer uses GCRA (the Generic Cell Rate Algorithm), which has the same behaviour as the token bucket of the +// server. The pacer keeps one value, the theoretical arrival time (TAT). The TAT is the time when the requests +// that the pacer already let go are paid for. A request can go when the TAT is in the burst tolerance from now, +// and each request moves the TAT one emission interval later. Thus the client can send a burst of requests and +// then continues at the steady rate of the policy. +// +// Each response sets the TAT again from the remaining requests that the server tells. Thus the pacer corrects +// itself when something else spends the same allowance. The pacer only moves the TAT later, never earlier, +// thus an old response that arrives after a newer one cannot make the client go faster. +// +// The pacer is the same as the RateLimitPacer of the C# and TypeScript clients. +// +// A pacer is safe for concurrent use by multiple goroutines. +type pacer struct { + // now returns a monotonic time. Tests replace it. + now func() time.Duration + + mutex sync.Mutex + havePolicy bool + burstLimit int + requestsPerMinute float64 + emissionInterval time.Duration + burstTolerance time.Duration + theoreticalArrivalTime time.Duration +} + +// newPacer returns a pacer that uses the monotonic clock of the time package. +func newPacer() *pacer { + start := time.Now() + return newPacerWithClock(func() time.Duration { + return time.Since(start) + }) +} + +// newPacerWithClock returns a pacer that uses the clock now. +func newPacerWithClock(now func() time.Duration) *pacer { + return &pacer{now: now} +} + +// reserveSlot takes the next slot in the schedule and returns the time to wait before the request is sent. +// The slot is taken also when the caller does not wait. Thus requests that start at the same time get +// consecutive slots, and they do not all go at the same time. +func (p *pacer) reserveSlot() time.Duration { + p.mutex.Lock() + defer p.mutex.Unlock() + + // Until the server tells the policy, there is no limit to pace against. + if !p.havePolicy { + return 0 + } + + now := p.now() + earliestSendTime := p.theoreticalArrivalTime - p.burstTolerance + + p.theoreticalArrivalTime = max(p.theoreticalArrivalTime, now) + p.emissionInterval + + return max(earliestSendTime-now, 0) +} + +// observeResponse reads the advisory headers of a response and sets the schedule again from them. +// Both values can be empty: an older server, or an endpoint that the server does not rate limit. +// Then the pacer keeps the policy that it has, and gives back the slot of the request (see refundSlot). +// +// Call observeResponse for each response, also for error responses. The server writes these headers before +// the endpoint runs, thus a 429 or a 500 response from a rate limited endpoint also tells the remaining requests. +func (p *pacer) observeResponse(policyHeaderValue string, rateLimitHeaderValue string) { + p.mutex.Lock() + defer p.mutex.Unlock() + + limit, haveLimit := getParameter(policyHeaderValue, "l") + rate, haveRate := getParameter(policyHeaderValue, "rpm") + + // Use only a policy that is valid, and only when it changes. The policy header is the same on almost all + // responses, and if the pacer applies it again, it loses the schedule that it keeps. + if haveLimit && haveRate && limit >= 1 && rate > 0 && + (!p.havePolicy || limit != float64(p.burstLimit) || rate != p.requestsPerMinute) { + p.burstLimit = int(limit) + p.requestsPerMinute = rate + p.emissionInterval = time.Duration(float64(time.Minute) / rate) + + // One request for the request that is sent, and one request that the pacer keeps back. + // The remaining requests of the bucket are the burst. + allowedBurst := p.burstLimit - 1 - reservedRequests + p.burstTolerance = 0 + if allowedBurst > 0 { + p.burstTolerance = p.emissionInterval * time.Duration(allowedBurst) + } + + // The old schedule uses the units of the old policy, thus it is not correct now. + // The remaining requests in this response set the schedule again below. + p.theoreticalArrivalTime = p.now() + + p.havePolicy = true + } + + if !p.havePolicy { + return + } + + remaining, haveRemaining := getParameter(rateLimitHeaderValue, "r") + if !haveRemaining { + p.refundSlot() + return + } + + remaining = min(max(remaining, 0), float64(p.burstLimit)) + + // The allowance that the server tells is spent, as a time in the schedule. The pacer only moves the + // schedule later, never earlier: responses can arrive out of order, and an older response that arrives + // last tells a bucket that is fuller than it is. + spent := time.Duration(float64(p.emissionInterval) * (float64(p.burstLimit) - remaining)) + p.theoreticalArrivalTime = max(p.theoreticalArrivalTime, p.now()+spent) +} + +// refundSlot gives back the slot that a request took, because the response tells that the request did not cost +// the limiter anything. A response without a remaining count is from an endpoint that the server does not rate +// limit, or the request did not get to the limiter. +// +// Without a refund, the pacer spends the schedule on requests that the server did not count. Then a client that +// sends requests to limited and unlimited endpoints paces all of its requests against a limit that only applies +// to some of them. +// +// A refund that is too large is safe: the next response that has a remaining count moves the schedule back. +// A schedule that is too small has no correction, because only time moves it earlier. +// +// The caller must hold the mutex. +func (p *pacer) refundSlot() { + // Only give back time that the schedule holds. A schedule that is already behind the clock does not pace + // anything, and if the pacer moves it further into the past, it gives credit for a burst that is not earned. + now := p.now() + if p.theoreticalArrivalTime > now { + p.theoreticalArrivalTime = max(p.theoreticalArrivalTime-p.emissionInterval, now) + } +} + +// getParameter reads one name=value parameter from a header such as "l=200;rpm=600". The name is not case-sensitive. +// It returns false if the header does not have the parameter, or if the value is not a number. +// It ignores parameters that it does not know, thus the server can add parameters without a change to the client. +func getParameter(headerValue string, name string) (float64, bool) { + for parameter := range strings.SplitSeq(headerValue, ";") { + parameterName, rawValue, found := strings.Cut(parameter, "=") + if !found { + continue + } + + if !strings.EqualFold(strings.TrimSpace(parameterName), name) { + continue + } + + rawValue = strings.TrimSpace(rawValue) + if !numberPattern.MatchString(rawValue) { + return 0, false + } + + value, err := strconv.ParseFloat(rawValue, 64) + if err != nil { + return 0, false + } + + return value, true + } + + return 0, false +} diff --git a/pkg/client/ratelimit/pacer_test.go b/pkg/client/ratelimit/pacer_test.go new file mode 100644 index 00000000..dc7c32ba --- /dev/null +++ b/pkg/client/ratelimit/pacer_test.go @@ -0,0 +1,382 @@ +package ratelimit + +import ( + "testing" + "time" + + "github.com/stretchr/testify/require" +) + +// A policy of 60 requests per minute makes one request cost exactly 1 second, which keeps the arithmetic in these +// tests easy to read. A burst limit of 10 gives 8 requests of burst room: one for the request that is sent, and one +// that the pacer keeps back. +const ( + testPolicy = "l=10;rpm=60" + testEmissionInterval = time.Second + testBurstRoom = 8 +) + +// newTestPacer returns a pacer with a clock that only moves when the test calls advance. Thus the arithmetic is exact. +func newTestPacer() (*pacer, func(time.Duration)) { + var now time.Duration + p := newPacerWithClock(func() time.Duration { + return now + }) + + return p, func(duration time.Duration) { + now += duration + } +} + +func TestPacerDoesNotPaceUntilTheServerTellsTheLimit(t *testing.T) { + p, _ := newTestPacer() + + // An older server or a policy without a limit gets here, and neither must make the client slower. + require.Equal(t, time.Duration(0), p.reserveSlot()) + require.Equal(t, time.Duration(0), p.reserveSlot()) +} + +func TestPacerDoesNotPaceWhenAResponseHasNoRateLimitHeaders(t *testing.T) { + p, _ := newTestPacer() + + // A server that has no policy for this caller sends no headers, and the pacer must not make up a limit. + p.observeResponse("", "") + + require.Equal(t, time.Duration(0), p.reserveSlot()) +} + +func TestPacerDoesNotWaitWhileRequestsRemain(t *testing.T) { + testCases := []struct { + name string + rateLimit string + }{ + {"FullBucket", "r=10;t=0"}, + {"HalfBucket", "r=5;t=5"}, + } + + for _, testCase := range testCases { + t.Run(testCase.name, func(t *testing.T) { + p, _ := newTestPacer() + + p.observeResponse(testPolicy, testCase.rateLimit) + + require.Equal(t, time.Duration(0), p.reserveSlot()) + }) + } +} + +func TestPacerWaitsWhenOnlyTheReservedRequestRemains(t *testing.T) { + p, _ := newTestPacer() + + // If the client spends the last request, the server has nothing left for the rounding difference between + // its bucket and the pacer, thus the pacer waits for the bucket to refill one request first. + p.observeResponse(testPolicy, "r=1;t=9") + + require.Equal(t, testEmissionInterval, p.reserveSlot()) +} + +func TestPacerWaitsForTheBucketToRefillWhenNoRequestsRemain(t *testing.T) { + p, _ := newTestPacer() + + p.observeResponse(testPolicy, "r=0;t=10") + + // The pacer also waits for the request that it keeps back. + require.Equal(t, 2*testEmissionInterval, p.reserveSlot()) +} + +func TestPacerSendsTheBurstThenPacesAtTheSteadyRate(t *testing.T) { + p, _ := newTestPacer() + p.observeResponse(testPolicy, "r=10;t=0") + + // All of the requests in the burst go immediately, at the same time. + for i := range testBurstRoom + 1 { + require.Equal(t, time.Duration(0), p.reserveSlot(), "request %d is in the burst", i+1) + } + + // After the burst, each request waits for the bucket to refill for it. The requests wait one emission + // interval after each other, and they do not all get the same wait. + require.Equal(t, testEmissionInterval, p.reserveSlot()) + require.Equal(t, 2*testEmissionInterval, p.reserveSlot()) + require.Equal(t, 3*testEmissionInterval, p.reserveSlot()) +} + +func TestPacerCountsTimeThatPassedAgainstTheWait(t *testing.T) { + p, advance := newTestPacer() + p.observeResponse(testPolicy, "r=0;t=10") + + require.Equal(t, 2*testEmissionInterval, p.reserveSlot()) + + // The caller waited, thus the bucket refilled for the next request. + advance(2 * testEmissionInterval) + require.Equal(t, testEmissionInterval, p.reserveSlot()) + advance(3 * testEmissionInterval) + require.Equal(t, time.Duration(0), p.reserveSlot()) +} + +func TestPacerSlowsDownWhenTheServerTellsFewerRequestsThanExpected(t *testing.T) { + testCases := []struct { + name string + rateLimit string + expectedDelay time.Duration + }{ + {"OneRemains", "r=1;t=9", testEmissionInterval}, + {"NoneRemain", "r=0;t=10", 2 * testEmissionInterval}, + } + + for _, testCase := range testCases { + t.Run(testCase.name, func(t *testing.T) { + p, _ := newTestPacer() + p.observeResponse(testPolicy, "r=10;t=0") + require.Equal(t, time.Duration(0), p.reserveSlot()) + + // Something else spends the same allowance, for example the web portal or a script of the same user. + p.observeResponse(testPolicy, testCase.rateLimit) + + require.Equal(t, testCase.expectedDelay, p.reserveSlot()) + }) + } +} + +func TestPacerDoesNotSpeedUpForAResponseThatArrivedOutOfOrder(t *testing.T) { + testCases := []struct { + name string + newer string + older string + expectedDelay time.Duration + }{ + {"NoneRemain", "r=0;t=10", "r=10;t=0", 2 * testEmissionInterval}, + {"OneRemains", "r=1;t=9", "r=9;t=1", testEmissionInterval}, + } + + for _, testCase := range testCases { + t.Run(testCase.name, func(t *testing.T) { + p, _ := newTestPacer() + p.observeResponse(testPolicy, testCase.newer) + + // A response that left the server before the newer one, but arrived after it, tells a bucket that is + // fuller than it is. If the pacer used it, the client would spend requests that are already spent. + p.observeResponse(testPolicy, testCase.older) + + require.Equal(t, testCase.expectedDelay, p.reserveSlot()) + }) + } +} + +func TestPacerHandlesAPolicyWithNoRoomToBurst(t *testing.T) { + p, _ := newTestPacer() + + // A burst limit of 1 gives a negative burst room after the request that is sent and the request that the + // pacer keeps back. The burst room must be 0, so that the pacer does not pace into the past. + p.observeResponse("l=1;rpm=60", "r=1;t=0") + + require.Equal(t, time.Duration(0), p.reserveSlot()) + require.Equal(t, testEmissionInterval, p.reserveSlot()) +} + +func TestPacerWaitsWithABurstOfOneWhenNoRequestsRemain(t *testing.T) { + p, _ := newTestPacer() + + p.observeResponse("l=1;rpm=60", "r=1;t=0") + require.Equal(t, time.Duration(0), p.reserveSlot()) + + p.observeResponse("l=1;rpm=60", "r=0;t=1") + require.Equal(t, testEmissionInterval, p.reserveSlot()) +} + +func TestPacerStartsAgainWhenThePolicyChanges(t *testing.T) { + testCases := []struct { + name string + oldPolicy string + oldRateLimit string + expectedDelay time.Duration + newPolicy string + newRateLimit string + }{ + {"MoreRequests", testPolicy, "r=0;t=10", 2 * testEmissionInterval, "l=100;rpm=600", "r=100;t=0"}, + {"FasterRate", "l=10;rpm=6", "r=1;t=90", 10 * time.Second, testPolicy, "r=10;t=0"}, + } + + for _, testCase := range testCases { + t.Run(testCase.name, func(t *testing.T) { + p, _ := newTestPacer() + p.observeResponse(testCase.oldPolicy, testCase.oldRateLimit) + require.Equal(t, testCase.expectedDelay, p.reserveSlot()) + + // An administrator changes the policy. The old schedule uses the units of the old policy, thus it is + // not correct now. The remaining requests in this response set the schedule again. + p.observeResponse(testCase.newPolicy, testCase.newRateLimit) + + require.Equal(t, time.Duration(0), p.reserveSlot()) + }) + } +} + +func TestPacerDoesNotPaceWhenThePolicyIsNotValid(t *testing.T) { + p, _ := newTestPacer() + + p.observeResponse("l=nonsense;rpm=0", "r=0;t=10") + + // The pacer has no limit to pace against. It is better to send the request and let the limiter decide + // than to make up a limit and keep back requests that the server would accept. + require.Equal(t, time.Duration(0), p.reserveSlot()) +} + +func TestPacerKeepsThePolicyWhenALaterPolicyHeaderIsNotValid(t *testing.T) { + p, _ := newTestPacer() + p.observeResponse(testPolicy, "r=10;t=0") + + p.observeResponse("", "r=0;t=10") + + require.Equal(t, 2*testEmissionInterval, p.reserveSlot()) +} + +func TestPacerGivesTheSlotBackWhenAResponseHasNoRemainingCount(t *testing.T) { + p, _ := newTestPacer() + p.observeResponse(testPolicy, "r=10;t=0") + + // Spend the burst, so that the next request must wait for the schedule. + for range testBurstRoom + 2 { + p.reserveSlot() + } + require.Equal(t, 2*testEmissionInterval, p.reserveSlot()) + + // Two of those requests went to endpoints that the server does not rate limit. They did not cost + // anything, thus the pacer gives back the schedule that they took. + p.observeResponse("", "") + p.observeResponse(testPolicy, "") + + require.Equal(t, testEmissionInterval, p.reserveSlot()) +} + +func TestPacerDoesNotGiveCreditForAFutureBurstFromRefunds(t *testing.T) { + testCases := []struct { + name string + advance time.Duration + }{ + // The schedule is level with the clock. + {"ScheduleLevelWithTheClock", 0}, + // The schedule fell behind the clock. + {"ScheduleBehindTheClock", 10 * testEmissionInterval}, + } + + for _, testCase := range testCases { + t.Run(testCase.name, func(t *testing.T) { + p, advance := newTestPacer() + p.observeResponse(testPolicy, "r=10;t=0") + p.reserveSlot() + + // When the schedule is not ahead of the clock, there is nothing to give back, however many unlimited + // responses arrive. If not, a quiet period would give the client a burst that it did not earn. + advance(testCase.advance) + for range 20 { + p.observeResponse("", "") + } + + // The burst is the burst that the policy permits, and no longer. + for i := range testBurstRoom + 1 { + require.Equal(t, time.Duration(0), p.reserveSlot(), "request %d is in the burst", i+1) + } + require.Equal(t, testEmissionInterval, p.reserveSlot()) + }) + } +} + +func TestPacerMovesTheScheduleBackAfterARefundThatIsTooLarge(t *testing.T) { + p, _ := newTestPacer() + p.observeResponse(testPolicy, "r=0;t=10") + + // A request that the pacer refunds can be a request that got to the limiter, for example a cancelled request. + // A refund that is too large is safe, because the next response with a remaining count sets the schedule + // again. If these three refunds stayed, the request below would go immediately. + p.observeResponse("", "") + p.observeResponse("", "") + p.observeResponse("", "") + p.observeResponse(testPolicy, "r=0;t=10") + + require.Equal(t, 2*testEmissionInterval, p.reserveSlot()) +} + +func TestPacerStopsPacingWhenTheServerStopsTheRateLimit(t *testing.T) { + p, _ := newTestPacer() + p.observeResponse(testPolicy, "r=0;t=10") + require.Equal(t, 2*testEmissionInterval, p.reserveSlot()) + + // An administrator turns off the policy, thus the responses have no headers. The refunds remove the schedule, + // so that the client does not pace against a limit that is gone. + for range 11 { + p.observeResponse("", "") + } + + require.Equal(t, time.Duration(0), p.reserveSlot()) +} + +func TestPacerPacesAgainstAFractionalRequestRate(t *testing.T) { + p, _ := newTestPacer() + + // Policies use requests per minute, thus a slow policy has a fraction. + p.observeResponse("l=2;rpm=0.5", "r=0;t=240") + + require.Equal(t, 2*2*time.Minute, p.reserveSlot()) +} + +func TestGetParameterReadsTheValue(t *testing.T) { + testCases := []struct { + name string + header string + param string + expected float64 + }{ + {"BurstLimit", "l=200;rpm=600", "l", 200}, + {"RequestsPerMinute", "l=200;rpm=600", "rpm", 600}, + {"SecondsToRefill", "r=7;t=18", "t", 18}, + {"Fraction", "l=10;rpm=0.5", "rpm", 0.5}, + {"SpaceAfterSeparator", "l=200; rpm=600", "rpm", 600}, + {"SpacesAroundParameters", " l = 200 ; rpm = 600 ", "rpm", 600}, + {"NameIsNotCaseSensitive", "L=200", "l", 200}, + } + + for _, testCase := range testCases { + t.Run(testCase.name, func(t *testing.T) { + value, found := getParameter(testCase.header, testCase.param) + + require.True(t, found) + require.Equal(t, testCase.expected, value) + }) + } +} + +func TestGetParameterReturnsFalseWhenTheValueIsNotThere(t *testing.T) { + testCases := []struct { + name string + header string + param string + }{ + {"EmptyHeader", "", "l"}, + {"NoSuchParameter", "l=200;rpm=600", "r"}, + {"EmptyName", "l=200", ""}, + {"NoSeparator", "nonsense", "l"}, + {"NameOnly", "l", "l"}, + {"EmptyValue", "l=", "l"}, + {"NotANumber", "l=nonsense", "l"}, + // strconv.ParseFloat reads these, but they are not what the server means. + {"Hexadecimal", "l=0x10", "l"}, + {"Infinity", "l=Inf", "l"}, + {"NotANumberLiteral", "l=NaN", "l"}, + } + + for _, testCase := range testCases { + t.Run(testCase.name, func(t *testing.T) { + _, found := getParameter(testCase.header, testCase.param) + + require.False(t, found) + }) + } +} + +func TestGetParameterIgnoresParametersThatItDoesNotKnow(t *testing.T) { + // So that the server can add parameters to these headers without a change to the client. + value, found := getParameter("l=200;something=new;rpm=600", "rpm") + + require.True(t, found) + require.Equal(t, float64(600), value) +} diff --git a/pkg/client/ratelimit/retry.go b/pkg/client/ratelimit/retry.go new file mode 100644 index 00000000..0ae9f02a --- /dev/null +++ b/pkg/client/ratelimit/retry.go @@ -0,0 +1,78 @@ +package ratelimit + +import ( + "math" + "regexp" + "strconv" + "strings" + "time" +) + +// jitterFraction is the maximum random jitter that is added to a retry delay, as a fraction of the delay. +// The limiter tells all of the requests that it rejects in a burst to wait the same time. Without jitter, they +// all come back at the same time, and the requests that it rejects again stay together for all of their retries. +const jitterFraction = 0.25 + +// retryAfterPattern matches a Retry-After value that is a whole number of seconds. +var retryAfterPattern = regexp.MustCompile(`^\s*-?\d+\s*$`) + +// retryDelay returns the time to wait before a retry, from the Retry-After header value that the server sent and +// the number of retries that were already made. It returns false if the client must not retry, because the server +// tells it to wait longer than options.MaxDelay. +// +// attempt is 0 for the first retry. The delay doubles with each attempt. Retry-After tells the time until the server +// can accept one more request, not the time until it can accept all of the requests that it rejected. When the +// server rejects a burst of requests, a retry of all of them after the same delay lets only some of them through. +// The backoff gives the limiter time to refill for all of the requests. +// +// random is a value in the range [0, 1) that sets the jitter. +// +// The retry delay is the same as the getRateLimitRetryDelay function of the TypeScript client. +func retryDelay(retryAfterHeaderValue string, options Options, attempt int, random float64) (time.Duration, bool) { + baseDelay, found := parseRetryAfterHeader(retryAfterHeaderValue) + if !found { + baseDelay = options.DefaultDelay + } + + // A Retry-After of 0 or less tells the client that it can retry immediately. + baseDelay = max(baseDelay, 0) + + // The server tells the client to wait longer than it is permitted to wait, thus do not retry. + if baseDelay > options.MaxDelay { + return 0, false + } + + delay := math.Min(float64(baseDelay)*math.Pow(2, float64(attempt)), float64(options.MaxDelay)) + + // The jitter only makes the delay longer, never shorter: the server told the earliest time to retry. + jitter := math.Min(delay*jitterFraction, float64(options.MaxDelay)-delay) + return time.Duration(delay + random*jitter), true +} + +// parseRetryAfterHeader returns the delay in a Retry-After header value. It returns false if the value is empty or +// is not a whole number of seconds. Then the caller uses the default delay. +// +// Retry-After can also be an HTTP date, but parseRetryAfterHeader does not read dates, the same as the C# and +// TypeScript clients. A delay from a date uses the clock of the client, which can be wrong by minutes. If the clock +// is wrong in one direction, the client retries without a delay. If it is wrong in the other direction, the delay +// is longer than MaxDelay and the client does not retry. Octopus Server always sends a number of seconds. +func parseRetryAfterHeader(headerValue string) (time.Duration, bool) { + if !retryAfterPattern.MatchString(headerValue) { + return 0, false + } + + // The pattern only matches whole numbers, thus ParseInt can only fail when the value is out of range. + // Then ParseInt returns the largest or smallest int64, which the checks below handle. + seconds, _ := strconv.ParseInt(strings.TrimSpace(headerValue), 10, 64) + + // Prevent an overflow. A delay that is too long is longer than MaxDelay, and a negative delay is 0. + maxSeconds := int64(math.MaxInt64 / time.Second) + if seconds > maxSeconds { + return time.Duration(math.MaxInt64), true + } + if seconds < -maxSeconds { + return 0, true + } + + return time.Duration(seconds) * time.Second, true +} diff --git a/pkg/client/ratelimit/retry_test.go b/pkg/client/ratelimit/retry_test.go new file mode 100644 index 00000000..4e8c0df4 --- /dev/null +++ b/pkg/client/ratelimit/retry_test.go @@ -0,0 +1,194 @@ +package ratelimit + +import ( + "context" + "testing" + "time" + + "github.com/stretchr/testify/require" +) + +// noJitter makes retryDelay add no jitter, so that the arithmetic is easy to read. Jitter has its own tests. +const noJitter = 0 + +// maxJitter makes retryDelay add almost all of the jitter that it can add. +const maxJitter = 0.999 + +func retryOptions() Options { + return Options{ + RetryCount: 5, + DefaultDelay: 2 * time.Second, + MaxDelay: 30 * time.Second, + } +} + +func TestRetryDelayWaitsForTheSecondsInRetryAfter(t *testing.T) { + delay, ok := retryDelay("5", retryOptions(), 0, noJitter) + + require.True(t, ok) + require.Equal(t, 5*time.Second, delay) +} + +func TestRetryDelayWaitsTheDefaultDelay(t *testing.T) { + testCases := []struct { + name string + retryAfter string + }{ + {"MissingRetryAfter", ""}, + {"RetryAfterIsNotANumber", "soon please"}, + // A malformed delay waits the default delay, and does not retry immediately. + {"Fraction", "5.5"}, + {"PlusSign", "+5"}, + {"FractionWithoutWholeNumber", ".5"}, + // retryDelay does not read Retry-After as a date. A client clock that is wrong by some minutes would + // give a delay of 0 and send too many requests, or a delay longer than MaxDelay and stop the retries. + {"FutureDate", "Sun, 30 Aug 2026 10:00:07 GMT"}, + {"PastDate", "Sun, 30 Aug 2026 09:59:50 GMT"}, + } + + for _, testCase := range testCases { + t.Run(testCase.name, func(t *testing.T) { + delay, ok := retryDelay(testCase.retryAfter, retryOptions(), 0, noJitter) + + require.True(t, ok) + require.Equal(t, 2*time.Second, delay) + }) + } +} + +func TestRetryDelayRetriesImmediatelyWhenRetryAfterIsZeroOrNegative(t *testing.T) { + testCases := []struct { + name string + retryAfter string + }{ + {"Zero", "0"}, + {"Negative", "-3"}, + {"VeryNegative", "-99999999999999999999"}, + } + + for _, testCase := range testCases { + t.Run(testCase.name, func(t *testing.T) { + delay, ok := retryDelay(testCase.retryAfter, retryOptions(), 0, noJitter) + + require.True(t, ok) + require.Equal(t, time.Duration(0), delay) + }) + } +} + +func TestRetryDelayGivesUpWhenTheServerTellsTheClientToWaitLongerThanMaxDelay(t *testing.T) { + _, ok := retryDelay("31", retryOptions(), 0, noJitter) + require.False(t, ok) + + delay, ok := retryDelay("30", retryOptions(), 0, noJitter) + require.True(t, ok) + require.Equal(t, 30*time.Second, delay) +} + +func TestRetryDelayGivesUpWhenRetryAfterIsTooLargeForADuration(t *testing.T) { + testCases := []struct { + name string + retryAfter string + }{ + {"LargerThanADuration", "9999999999999"}, + {"LargerThanAnInt64", "99999999999999999999"}, + } + + for _, testCase := range testCases { + t.Run(testCase.name, func(t *testing.T) { + _, ok := retryDelay(testCase.retryAfter, retryOptions(), 0, noJitter) + + require.False(t, ok) + }) + } +} + +func TestRetryDelayDoublesTheWaitForEachRetry(t *testing.T) { + // The server tells all of the requests in a burst that it rejects to wait the same time. A retry after that + // time only lets through the requests that the limiter refills in that time, thus the client backs off to + // give the limiter time to refill for all of the requests that it rejected. + expectedDelays := []time.Duration{2 * time.Second, 4 * time.Second, 8 * time.Second, 16 * time.Second} + + for attempt, expectedDelay := range expectedDelays { + delay, ok := retryDelay("2", retryOptions(), attempt, noJitter) + + require.True(t, ok) + require.Equal(t, expectedDelay, delay, "attempt %d", attempt) + } +} + +func TestRetryDelayDoesNotBackOffPastMaxDelay(t *testing.T) { + delay, ok := retryDelay("2", retryOptions(), 10, noJitter) + + require.True(t, ok) + require.Equal(t, 30*time.Second, delay) +} + +func TestRetryDelayDoesNotOverflowForLargeAttempts(t *testing.T) { + delay, ok := retryDelay("2", retryOptions(), 1000, noJitter) + + require.True(t, ok) + require.Equal(t, 30*time.Second, delay) +} + +func TestRetryDelayBacksOffFromTheDefaultDelayWhenRetryAfterIsMissing(t *testing.T) { + delay, ok := retryDelay("", retryOptions(), 2, noJitter) + + require.True(t, ok) + require.Equal(t, 8*time.Second, delay) +} + +func TestRetryDelayRetriesImmediatelyAtEachAttemptWhenRetryAfterIsZero(t *testing.T) { + delay, ok := retryDelay("0", retryOptions(), 3, noJitter) + + require.True(t, ok) + require.Equal(t, time.Duration(0), delay) +} + +func TestRetryDelayAddsJitterAfterTheWaitNeverBeforeIt(t *testing.T) { + // The server told the earliest time to retry, thus jitter only makes the delay longer. + delay, ok := retryDelay("2", retryOptions(), 0, maxJitter) + + require.True(t, ok) + require.Greater(t, delay, 2*time.Second) + require.LessOrEqual(t, delay, 2500*time.Millisecond) +} + +func TestRetryDelayKeepsTheJitterInMaxDelay(t *testing.T) { + delay, ok := retryDelay("2", retryOptions(), 10, maxJitter) + require.True(t, ok) + require.Equal(t, 30*time.Second, delay) + + delay, ok = retryDelay("28", retryOptions(), 0, maxJitter) + require.True(t, ok) + require.LessOrEqual(t, delay, 30*time.Second) +} + +func TestDefaultOptions(t *testing.T) { + // These are the settings that NewHttpClient users get when they use DefaultOptions: five retries, two seconds + // when the server does not tell the delay, never more than thirty seconds, and pacing with the rate limit headers. + require.Equal(t, Options{ + RetryCount: 5, + DefaultDelay: 2 * time.Second, + MaxDelay: 30 * time.Second, + UseRateLimitHeaders: true, + }, DefaultOptions()) +} + +func TestWaitReturnsWhenTheDelayIsOver(t *testing.T) { + require.NoError(t, wait(context.Background(), 0)) +} + +func TestWaitReturnsTheErrorWhenTheContextIsAlreadyCancelled(t *testing.T) { + ctx, cancel := context.WithCancel(context.Background()) + cancel() + + require.ErrorIs(t, wait(ctx, 0), context.Canceled) +} + +func TestWaitReturnsTheErrorWhenTheContextIsCancelledDuringTheWait(t *testing.T) { + ctx, cancel := context.WithCancel(context.Background()) + time.AfterFunc(10*time.Millisecond, cancel) + + require.ErrorIs(t, wait(ctx, time.Minute), context.Canceled) +} diff --git a/pkg/client/ratelimit/transport.go b/pkg/client/ratelimit/transport.go index 415132d8..9584a7b6 100644 --- a/pkg/client/ratelimit/transport.go +++ b/pkg/client/ratelimit/transport.go @@ -8,9 +8,6 @@ import ( "io" "math/rand/v2" "net/http" - "strconv" - "strings" - "sync" "time" ) @@ -19,36 +16,41 @@ const ( // For example: "l=200;rpm=600". PolicyHeader = "Octopus-RateLimit-Policy" - // StateHeader is the advisory header that tells the remaining tokens and the seconds until all tokens are available again. - // For example: "r=7;t=18". - StateHeader = "Octopus-RateLimit" + // RateLimitHeader is the advisory header that tells the remaining requests and the seconds until all of the + // requests are available again. For example: "r=7;t=18". + RateLimitHeader = "Octopus-RateLimit" retryAfterHeader = "Retry-After" - initialBackoff = time.Second - maxBackoffShift = 10 ) // Options are the settings for a Transport. type Options struct { - // MaxRetries is the maximum number of times that the Transport sends a request again after a 429 response. - MaxRetries int + // RetryCount is the maximum number of times that the Transport sends a request again after a 429 response. + // If RetryCount is 0 or less, the Transport does not retry, and the caller gets the 429 response. + RetryCount int - // MaxWait is the maximum time that the Transport waits before it sends a request. - // If the server tells the Transport to wait longer before a retry, the Transport does not retry. It returns the 429 response. - // If MaxWait is 0 or less, the Transport uses the default of 60 seconds from DefaultOptions. - MaxWait time.Duration + // DefaultDelay is the time to wait before a retry when the 429 response has no Retry-After header. + // If DefaultDelay is 0 or less, the Transport uses the default of 2 seconds from DefaultOptions. + DefaultDelay time.Duration - // UseAdvisoryHeaders makes the Transport wait before it sends a request when the advisory headers - // show that no tokens remain. This prevents most 429 responses. - UseAdvisoryHeaders bool + // MaxDelay is the maximum time to wait before a retry. If the server tells the Transport to wait longer, + // the Transport does not retry, and the caller gets the 429 response. + // If MaxDelay is 0 or less, the Transport uses the default of 30 seconds from DefaultOptions. + MaxDelay time.Duration + + // UseRateLimitHeaders makes the Transport pace its requests with the Octopus-RateLimit-Policy and + // Octopus-RateLimit headers that the server sends. Then the Transport stays in the rate limit and + // prevents most 429 responses. + UseRateLimitHeaders bool } // DefaultOptions returns the recommended settings for a Transport. func DefaultOptions() Options { return Options{ - MaxRetries: 3, - MaxWait: 60 * time.Second, - UseAdvisoryHeaders: true, + RetryCount: 5, + DefaultDelay: 2 * time.Second, + MaxDelay: 30 * time.Second, + UseRateLimitHeaders: true, } } @@ -57,10 +59,11 @@ func DefaultOptions() Options { // When the server returns 429 (Too Many Requests), the Transport waits and sends the request again. // The server does not process a request that it rejects, thus a retry is safe for all HTTP methods. // The Transport retries a request only if it can read the request body again (the body is empty, or -// http.Request.GetBody is set). +// http.Request.GetBody is set). The delay doubles with each retry, and has random jitter. // -// When UseAdvisoryHeaders is true, the Transport also reads the advisory headers on each response. If no tokens remain, -// the Transport waits for one token before it sends the next request. +// When UseRateLimitHeaders is true, the Transport also paces each request, retries included, with the advisory +// headers that the server sends. The pacer uses GCRA (the Generic Cell Rate Algorithm), the same as the C# and +// TypeScript clients. // // Use NewTransport to create a Transport. A Transport is safe for concurrent use by multiple goroutines. // Do not copy a Transport after its first use. @@ -68,34 +71,45 @@ type Transport struct { base http.RoundTripper options Options - mutex sync.Mutex - nextRequestTime time.Time + // pacer is nil when UseRateLimitHeaders is false. + pacer *pacer } var _ http.RoundTripper = (*Transport)(nil) // NewTransport returns a Transport that sends requests with base. If base is nil, the Transport uses http.DefaultTransport. -// If options.MaxWait is 0 or less, the Transport uses the default of 60 seconds from DefaultOptions. +// If options.DefaultDelay or options.MaxDelay is 0 or less, the Transport uses the value from DefaultOptions. func NewTransport(base http.RoundTripper, options Options) *Transport { if base == nil { base = http.DefaultTransport } - if options.MaxWait <= 0 { - options.MaxWait = DefaultOptions().MaxWait + if options.DefaultDelay <= 0 { + options.DefaultDelay = DefaultOptions().DefaultDelay + } + + if options.MaxDelay <= 0 { + options.MaxDelay = DefaultOptions().MaxDelay } - return &Transport{ + transport := &Transport{ base: base, options: options, } + + if options.UseRateLimitHeaders { + transport.pacer = newPacer() + } + + return transport } // RoundTrip implements http.RoundTripper. func (t *Transport) RoundTrip(req *http.Request) (*http.Response, error) { for attempt := 0; ; attempt++ { - if t.options.UseAdvisoryHeaders { - if err := wait(req.Context(), t.throttleDelay()); err != nil { + // The pacer also paces retries: a request that the limiter rejected must not go back to it immediately. + if t.pacer != nil { + if err := wait(req.Context(), t.pacer.reserveSlot()); err != nil { // The base transport did not get the request, so the body must be closed here. if attempt == 0 && req.Body != nil { _ = req.Body.Close() @@ -114,15 +128,15 @@ func (t *Transport) RoundTrip(req *http.Request) (*http.Response, error) { return nil, err } - if t.options.UseAdvisoryHeaders { - t.updateThrottle(resp) + if t.pacer != nil { + t.pacer.observeResponse(resp.Header.Get(PolicyHeader), resp.Header.Get(RateLimitHeader)) } - if resp.StatusCode != http.StatusTooManyRequests || attempt >= t.options.MaxRetries || !canRetry(req) { + if resp.StatusCode != http.StatusTooManyRequests || attempt >= t.options.RetryCount || !canRetry(req) { return resp, nil } - delay, ok := t.retryDelay(resp, attempt) + delay, ok := retryDelay(resp.Header.Get(retryAfterHeader), t.options, attempt, rand.Float64()) if !ok { return resp, nil } @@ -147,48 +161,6 @@ func (t *Transport) CloseIdleConnections() { } } -// retryDelay returns the time to wait before a retry. It uses the Retry-After header if the server sends it. -// If not, it uses exponential backoff with jitter, up to MaxWait. It returns false if Retry-After is longer than MaxWait. -func (t *Transport) retryDelay(resp *http.Response, attempt int) (time.Duration, bool) { - delay, found := retryAfter(resp) - if !found { - delay = min(backoff(attempt), t.options.MaxWait) - } - - return delay, delay <= t.options.MaxWait -} - -// throttleDelay returns the time to wait before the next request, as the advisory headers tell. -func (t *Transport) throttleDelay() time.Duration { - t.mutex.Lock() - nextRequestTime := t.nextRequestTime - t.mutex.Unlock() - - return min(time.Until(nextRequestTime), t.options.MaxWait) -} - -// updateThrottle reads the advisory headers on resp. If no tokens remain, it sets the time of the next request -// to the time when one token is available again. -func (t *Transport) updateThrottle(resp *http.Response) { - state := parseHeader(resp.Header.Get(StateHeader)) - remaining, ok := state["r"] - if !ok { - return - } - - nextRequestTime := time.Time{} - if remaining <= 0 { - policy := parseHeader(resp.Header.Get(PolicyHeader)) - if requestsPerMinute := policy["rpm"]; requestsPerMinute > 0 { - nextRequestTime = time.Now().Add(time.Minute / time.Duration(requestsPerMinute)) - } - } - - t.mutex.Lock() - t.nextRequestTime = nextRequestTime - t.mutex.Unlock() -} - // canRetry returns true if the body of req can be read again. func canRetry(req *http.Request) bool { return req.Body == nil || req.Body == http.NoBody || req.GetBody != nil @@ -212,45 +184,13 @@ func requestForAttempt(req *http.Request, attempt int) (*http.Request, error) { return retryRequest, nil } -// retryAfter returns the time in the Retry-After header of resp. It returns false if the header is missing or not valid. -func retryAfter(resp *http.Response) (time.Duration, bool) { - seconds, err := strconv.Atoi(strings.TrimSpace(resp.Header.Get(retryAfterHeader))) - if err != nil || seconds < 0 { - return 0, false - } - - return time.Duration(seconds) * time.Second, true -} - -// backoff returns an exponential backoff with jitter for the attempt. -func backoff(attempt int) time.Duration { - delay := initialBackoff << min(attempt, maxBackoffShift) - return delay/2 + rand.N(delay/2) -} - -// parseHeader parses a header value such as "l=200;rpm=600" into a map of names to integer values. -// It ignores the parts that it cannot parse. -func parseHeader(value string) map[string]int { - values := map[string]int{} - for part := range strings.SplitSeq(value, ";") { - name, number, found := strings.Cut(part, "=") - if !found { - continue - } - - parsedNumber, err := strconv.Atoi(strings.TrimSpace(number)) - if err != nil { - continue - } - - values[strings.TrimSpace(name)] = parsedNumber +// wait stops for the time in delay, or until ctx is cancelled. It returns the error of ctx if ctx is already +// cancelled, also when delay is 0, thus the Transport does not send a request that nobody waits for. +func wait(ctx context.Context, delay time.Duration) error { + if err := ctx.Err(); err != nil { + return err } - return values -} - -// wait stops for the time in delay, or until ctx is cancelled. -func wait(ctx context.Context, delay time.Duration) error { if delay <= 0 { return nil } diff --git a/pkg/client/ratelimit/transport_test.go b/pkg/client/ratelimit/transport_test.go index b768ecab..8749055d 100644 --- a/pkg/client/ratelimit/transport_test.go +++ b/pkg/client/ratelimit/transport_test.go @@ -71,11 +71,22 @@ func newResponse(statusCode int, headers map[string]string) func() *http.Respons } } +// tooManyRequests returns a 429 response with the Retry-After value. If retryAfter is empty, the response has no Retry-After header. +func tooManyRequests(retryAfter string) func() *http.Response { + if retryAfter == "" { + return newResponse(http.StatusTooManyRequests, nil) + } + + return newResponse(http.StatusTooManyRequests, map[string]string{retryAfterHeader: retryAfter}) +} + +// testOptions are the settings for the transport tests. The delays are short, so that the tests are fast. func testOptions() Options { return Options{ - MaxRetries: 3, - MaxWait: time.Second, - UseAdvisoryHeaders: true, + RetryCount: 3, + DefaultDelay: 50 * time.Millisecond, + MaxDelay: time.Second, + UseRateLimitHeaders: true, } } @@ -85,14 +96,6 @@ func newGetRequest(t *testing.T) *http.Request { return req } -func TestDefaultOptions(t *testing.T) { - options := DefaultOptions() - - require.Equal(t, 3, options.MaxRetries) - require.Equal(t, 60*time.Second, options.MaxWait) - require.True(t, options.UseAdvisoryHeaders) -} - func TestNewTransportWithNilBaseUsesDefaultTransport(t *testing.T) { transport := NewTransport(nil, testOptions()) @@ -107,10 +110,10 @@ func TestBaseReturnsTheBaseTransport(t *testing.T) { require.Same(t, server, transport.Base()) } -func TestNewTransportUsesDefaultMaxWaitWhenMaxWaitIsNotPositive(t *testing.T) { +func TestNewTransportUsesTheDefaultDelaysWhenTheDelaysAreNotPositive(t *testing.T) { testCases := []struct { - name string - maxWait time.Duration + name string + delay time.Duration }{ {"Zero", 0}, {"Negative", -time.Second}, @@ -118,17 +121,29 @@ func TestNewTransportUsesDefaultMaxWaitWhenMaxWaitIsNotPositive(t *testing.T) { for _, testCase := range testCases { t.Run(testCase.name, func(t *testing.T) { - transport := NewTransport(nil, Options{MaxRetries: 3, MaxWait: testCase.maxWait}) + transport := NewTransport(nil, Options{RetryCount: 3, DefaultDelay: testCase.delay, MaxDelay: testCase.delay}) - require.Equal(t, DefaultOptions().MaxWait, transport.options.MaxWait) + require.Equal(t, DefaultOptions().DefaultDelay, transport.options.DefaultDelay) + require.Equal(t, DefaultOptions().MaxDelay, transport.options.MaxDelay) }) } } -func TestNewTransportKeepsPositiveMaxWait(t *testing.T) { - transport := NewTransport(nil, Options{MaxRetries: 3, MaxWait: 5 * time.Second}) +func TestNewTransportKeepsPositiveDelays(t *testing.T) { + transport := NewTransport(nil, Options{RetryCount: 3, DefaultDelay: time.Second, MaxDelay: 5 * time.Second}) - require.Equal(t, 5*time.Second, transport.options.MaxWait) + require.Equal(t, time.Second, transport.options.DefaultDelay) + require.Equal(t, 5*time.Second, transport.options.MaxDelay) +} + +func TestNewTransportPacesOnlyWhenUseRateLimitHeadersIsTrue(t *testing.T) { + options := testOptions() + + options.UseRateLimitHeaders = true + require.NotNil(t, NewTransport(nil, options).pacer) + + options.UseRateLimitHeaders = false + require.Nil(t, NewTransport(nil, options).pacer) } func TestRoundTripReturnsSuccessfulResponse(t *testing.T) { @@ -153,9 +168,11 @@ func TestRoundTripDoesNotRetryOtherErrorResponses(t *testing.T) { require.Equal(t, 1, server.requestCount()) } -func TestRoundTripRetriesAfterTooManyRequests(t *testing.T) { +func TestRoundTripRetriesUntilTheServerAcceptsTheRequest(t *testing.T) { + // The server rejects the first two requests, then accepts the request. server := &fakeServer{responses: []func() *http.Response{ - newResponse(http.StatusTooManyRequests, map[string]string{"Retry-After": "0"}), + tooManyRequests("0"), + tooManyRequests("0"), newResponse(http.StatusOK, nil), }} transport := NewTransport(server, testOptions()) @@ -164,58 +181,16 @@ func TestRoundTripRetriesAfterTooManyRequests(t *testing.T) { require.NoError(t, err) require.Equal(t, http.StatusOK, resp.StatusCode) - require.Equal(t, 2, server.requestCount()) -} - -func TestRoundTripStopsAfterMaxRetries(t *testing.T) { - server := &fakeServer{responses: []func() *http.Response{ - newResponse(http.StatusTooManyRequests, map[string]string{"Retry-After": "0"}), - }} - transport := NewTransport(server, testOptions()) - - resp, err := transport.RoundTrip(newGetRequest(t)) - - require.NoError(t, err) - require.Equal(t, http.StatusTooManyRequests, resp.StatusCode) - require.Equal(t, 4, server.requestCount()) -} - -func TestRoundTripDoesNotRetryWhenMaxRetriesIsZero(t *testing.T) { - server := &fakeServer{responses: []func() *http.Response{ - newResponse(http.StatusTooManyRequests, map[string]string{"Retry-After": "0"}), - }} - options := testOptions() - options.MaxRetries = 0 - transport := NewTransport(server, options) - - resp, err := transport.RoundTrip(newGetRequest(t)) - - require.NoError(t, err) - require.Equal(t, http.StatusTooManyRequests, resp.StatusCode) - require.Equal(t, 1, server.requestCount()) -} - -func TestRoundTripDoesNotRetryWhenRetryAfterIsLongerThanMaxWait(t *testing.T) { - server := &fakeServer{responses: []func() *http.Response{ - newResponse(http.StatusTooManyRequests, map[string]string{"Retry-After": "120"}), - newResponse(http.StatusOK, nil), - }} - transport := NewTransport(server, testOptions()) - - resp, err := transport.RoundTrip(newGetRequest(t)) - - require.NoError(t, err) - require.Equal(t, http.StatusTooManyRequests, resp.StatusCode) - require.Equal(t, 1, server.requestCount()) + require.Equal(t, 3, server.requestCount()) } func TestRoundTripWaitsForRetryAfter(t *testing.T) { server := &fakeServer{responses: []func() *http.Response{ - newResponse(http.StatusTooManyRequests, map[string]string{"Retry-After": "1"}), + tooManyRequests("1"), newResponse(http.StatusOK, nil), }} options := testOptions() - options.MaxWait = 2 * time.Second + options.MaxDelay = 2 * time.Second transport := NewTransport(server, options) start := time.Now() @@ -223,17 +198,16 @@ func TestRoundTripWaitsForRetryAfter(t *testing.T) { require.NoError(t, err) require.Equal(t, http.StatusOK, resp.StatusCode) + require.Equal(t, 2, server.requestCount()) require.GreaterOrEqual(t, time.Since(start), time.Second) } -func TestRoundTripUsesBackoffCappedAtMaxWaitWhenRetryAfterIsMissing(t *testing.T) { +func TestRoundTripWaitsTheDefaultDelayWhenThereIsNoRetryAfterHeader(t *testing.T) { server := &fakeServer{responses: []func() *http.Response{ - newResponse(http.StatusTooManyRequests, nil), + tooManyRequests(""), newResponse(http.StatusOK, nil), }} - options := testOptions() - options.MaxWait = 50 * time.Millisecond - transport := NewTransport(server, options) + transport := NewTransport(server, testOptions()) start := time.Now() resp, err := transport.RoundTrip(newGetRequest(t)) @@ -241,58 +215,66 @@ func TestRoundTripUsesBackoffCappedAtMaxWaitWhenRetryAfterIsMissing(t *testing.T require.NoError(t, err) require.Equal(t, http.StatusOK, resp.StatusCode) require.Equal(t, 2, server.requestCount()) - require.GreaterOrEqual(t, time.Since(start), 50*time.Millisecond) + require.GreaterOrEqual(t, time.Since(start), testOptions().DefaultDelay) } -func TestRetryDelayCapsBackoffAtMaxWait(t *testing.T) { - options := testOptions() - options.MaxWait = 50 * time.Millisecond - transport := NewTransport(nil, options) - - // The backoff for attempt 0 is at least 500 milliseconds, thus MaxWait applies. - delay, ok := transport.retryDelay(newResponse(http.StatusTooManyRequests, nil)(), 0) - - require.True(t, ok) - require.Equal(t, 50*time.Millisecond, delay) -} - -func TestRetryDelayUsesBackoffWhenRetryAfterIsMissing(t *testing.T) { - options := testOptions() - options.MaxWait = time.Minute - transport := NewTransport(nil, options) +func TestRoundTripGivesUpAfterTheConfiguredNumberOfRetries(t *testing.T) { + server := &fakeServer{responses: []func() *http.Response{tooManyRequests("0")}} + transport := NewTransport(server, testOptions()) - delay, ok := transport.retryDelay(newResponse(http.StatusTooManyRequests, nil)(), 0) + resp, err := transport.RoundTrip(newGetRequest(t)) - require.True(t, ok) - require.GreaterOrEqual(t, delay, initialBackoff/2) - require.Less(t, delay, initialBackoff) + require.NoError(t, err) + require.Equal(t, http.StatusTooManyRequests, resp.StatusCode) + // The first request, and RetryCount (3) retries. + require.Equal(t, 4, server.requestCount()) } -func TestRetryDelayUsesRetryAfter(t *testing.T) { - options := testOptions() - options.MaxWait = time.Minute - transport := NewTransport(nil, options) - - delay, ok := transport.retryDelay(newResponse(http.StatusTooManyRequests, map[string]string{"Retry-After": "5"})(), 0) +func TestRoundTripDoesNotRetryWhenRetryCountIsZeroOrLess(t *testing.T) { + testCases := []struct { + name string + retryCount int + }{ + {"Zero", 0}, + {"Negative", -1}, + } - require.True(t, ok) - require.Equal(t, 5*time.Second, delay) + for _, testCase := range testCases { + t.Run(testCase.name, func(t *testing.T) { + server := &fakeServer{responses: []func() *http.Response{ + tooManyRequests("0"), + newResponse(http.StatusOK, nil), + }} + options := testOptions() + options.RetryCount = testCase.retryCount + transport := NewTransport(server, options) + + resp, err := transport.RoundTrip(newGetRequest(t)) + + require.NoError(t, err) + require.Equal(t, http.StatusTooManyRequests, resp.StatusCode) + require.Equal(t, 1, server.requestCount()) + }) + } } -func TestRetryDelayRejectsRetryAfterLongerThanMaxWait(t *testing.T) { - options := testOptions() - options.MaxWait = time.Minute - transport := NewTransport(nil, options) +func TestRoundTripDoesNotRetryWhenRetryAfterIsLongerThanMaxDelay(t *testing.T) { + server := &fakeServer{responses: []func() *http.Response{ + tooManyRequests("120"), + newResponse(http.StatusOK, nil), + }} + transport := NewTransport(server, testOptions()) - delay, ok := transport.retryDelay(newResponse(http.StatusTooManyRequests, map[string]string{"Retry-After": "120"})(), 0) + resp, err := transport.RoundTrip(newGetRequest(t)) - require.False(t, ok) - require.Equal(t, 120*time.Second, delay) + require.NoError(t, err) + require.Equal(t, http.StatusTooManyRequests, resp.StatusCode) + require.Equal(t, 1, server.requestCount()) } func TestRoundTripSendsTheSameBodyOnRetry(t *testing.T) { server := &fakeServer{responses: []func() *http.Response{ - newResponse(http.StatusTooManyRequests, map[string]string{"Retry-After": "0"}), + tooManyRequests("0"), newResponse(http.StatusOK, nil), }} transport := NewTransport(server, testOptions()) @@ -310,7 +292,7 @@ func TestRoundTripSendsTheSameBodyOnRetry(t *testing.T) { func TestRoundTripDoesNotRetryWhenBodyCannotBeReadAgain(t *testing.T) { server := &fakeServer{responses: []func() *http.Response{ - newResponse(http.StatusTooManyRequests, map[string]string{"Retry-After": "0"}), + tooManyRequests("0"), newResponse(http.StatusOK, nil), }} transport := NewTransport(server, testOptions()) @@ -328,11 +310,11 @@ func TestRoundTripDoesNotRetryWhenBodyCannotBeReadAgain(t *testing.T) { func TestRoundTripReturnsErrorWhenContextIsCancelledDuringRetryWait(t *testing.T) { server := &fakeServer{responses: []func() *http.Response{ - newResponse(http.StatusTooManyRequests, map[string]string{"Retry-After": "30"}), + tooManyRequests("30"), newResponse(http.StatusOK, nil), }} options := testOptions() - options.MaxWait = time.Minute + options.MaxDelay = time.Minute transport := NewTransport(server, options) ctx, cancel := context.WithTimeout(context.Background(), 50*time.Millisecond) @@ -359,98 +341,75 @@ func TestRoundTripReturnsErrorFromBaseTransport(t *testing.T) { require.Nil(t, resp) } -func TestRoundTripWaitsWhenNoTokensRemain(t *testing.T) { - server := &fakeServer{responses: []func() *http.Response{ - newResponse(http.StatusOK, map[string]string{PolicyHeader: "l=200;rpm=600", StateHeader: "r=0;t=18"}), - newResponse(http.StatusOK, nil), - }} - transport := NewTransport(server, testOptions()) +func TestRoundTripGivesTheRateLimitHeadersToThePacer(t *testing.T) { + testCases := []struct { + name string + statusCode int + }{ + {"SuccessfulResponse", http.StatusOK}, + // The server writes these headers before the endpoint runs, thus error responses also have them. + {"ErrorResponse", http.StatusInternalServerError}, + } - _, err := transport.RoundTrip(newGetRequest(t)) - require.NoError(t, err) + for _, testCase := range testCases { + t.Run(testCase.name, func(t *testing.T) { + server := &fakeServer{responses: []func() *http.Response{ + newResponse(testCase.statusCode, map[string]string{PolicyHeader: testPolicy, RateLimitHeader: "r=0;t=10"}), + }} + transport := NewTransport(server, testOptions()) + testPacer, _ := newTestPacer() + transport.pacer = testPacer - start := time.Now() - _, err = transport.RoundTrip(newGetRequest(t)) + _, err := transport.RoundTrip(newGetRequest(t)) - require.NoError(t, err) - // 600 requests per minute gives one token each 100 milliseconds. - require.GreaterOrEqual(t, time.Since(start), 90*time.Millisecond) + require.NoError(t, err) + require.Equal(t, 2*testEmissionInterval, testPacer.reserveSlot()) + }) + } } -func TestRoundTripDoesNotWaitWhenTokensRemain(t *testing.T) { +func TestRoundTripWaitsForThePacer(t *testing.T) { + // 600 requests per minute gives one request each 100 milliseconds. No requests remain, thus the + // next request waits for two requests: the request that it sends, and the request that the pacer keeps back. server := &fakeServer{responses: []func() *http.Response{ - newResponse(http.StatusOK, map[string]string{PolicyHeader: "l=200;rpm=1", StateHeader: "r=7;t=18"}), + newResponse(http.StatusOK, map[string]string{PolicyHeader: "l=10;rpm=600", RateLimitHeader: "r=0;t=1"}), }} transport := NewTransport(server, testOptions()) _, err := transport.RoundTrip(newGetRequest(t)) - require.NoError(t, err) - require.LessOrEqual(t, transport.throttleDelay(), time.Duration(0)) -} - -func TestUpdateThrottleClearsWaitWhenTokensAreAvailableAgain(t *testing.T) { - transport := NewTransport(nil, testOptions()) - - transport.updateThrottle(newResponse(http.StatusOK, map[string]string{PolicyHeader: "l=200;rpm=1", StateHeader: "r=0;t=60"})()) - require.Greater(t, transport.throttleDelay(), time.Duration(0)) - - transport.updateThrottle(newResponse(http.StatusOK, map[string]string{PolicyHeader: "l=200;rpm=1", StateHeader: "r=5;t=55"})()) - require.LessOrEqual(t, transport.throttleDelay(), time.Duration(0)) -} - -func TestUpdateThrottleIgnoresResponsesWithoutAdvisoryHeaders(t *testing.T) { - transport := NewTransport(nil, testOptions()) - - transport.updateThrottle(newResponse(http.StatusOK, map[string]string{PolicyHeader: "l=200;rpm=1", StateHeader: "r=0;t=60"})()) - transport.updateThrottle(newResponse(http.StatusOK, nil)()) - - require.Greater(t, transport.throttleDelay(), time.Duration(0)) -} - -func TestRoundTripCapsThrottleWaitAtMaxWait(t *testing.T) { - server := &fakeServer{responses: []func() *http.Response{ - newResponse(http.StatusOK, map[string]string{PolicyHeader: "l=200;rpm=1", StateHeader: "r=0;t=60"}), - newResponse(http.StatusOK, nil), - }} - options := testOptions() - options.MaxWait = 50 * time.Millisecond - transport := NewTransport(server, options) - - _, err := transport.RoundTrip(newGetRequest(t)) - require.NoError(t, err) - - // 1 request per minute gives one token each minute, thus MaxWait applies. - require.Equal(t, 50*time.Millisecond, transport.throttleDelay()) start := time.Now() _, err = transport.RoundTrip(newGetRequest(t)) require.NoError(t, err) - require.GreaterOrEqual(t, time.Since(start), 50*time.Millisecond) + require.GreaterOrEqual(t, time.Since(start), 150*time.Millisecond) } -func TestRoundTripDoesNotThrottleWhenAdvisoryHeadersAreNotUsed(t *testing.T) { +func TestRoundTripPacesRetries(t *testing.T) { + // The 429 response tells the client to retry immediately, but no requests remain, thus the pacer makes the + // retry wait. A request that the limiter rejected must not go back to it immediately. server := &fakeServer{responses: []func() *http.Response{ - newResponse(http.StatusOK, map[string]string{PolicyHeader: "l=200;rpm=1", StateHeader: "r=0;t=60"}), + newResponse(http.StatusTooManyRequests, map[string]string{retryAfterHeader: "0", PolicyHeader: "l=10;rpm=600", RateLimitHeader: "r=0;t=1"}), + newResponse(http.StatusOK, nil), }} - options := testOptions() - options.UseAdvisoryHeaders = false - transport := NewTransport(server, options) + transport := NewTransport(server, testOptions()) - _, err := transport.RoundTrip(newGetRequest(t)) + start := time.Now() + resp, err := transport.RoundTrip(newGetRequest(t)) require.NoError(t, err) - require.LessOrEqual(t, transport.throttleDelay(), time.Duration(0)) + require.Equal(t, http.StatusOK, resp.StatusCode) + require.Equal(t, 2, server.requestCount()) + require.GreaterOrEqual(t, time.Since(start), 150*time.Millisecond) } -func TestRoundTripReturnsErrorWhenContextIsCancelledDuringThrottleWait(t *testing.T) { +func TestRoundTripReturnsErrorWhenContextIsCancelledDuringPacerWait(t *testing.T) { + // 1 request per minute and no requests remain, thus the next request must wait for two minutes. server := &fakeServer{responses: []func() *http.Response{ - newResponse(http.StatusOK, map[string]string{PolicyHeader: "l=200;rpm=1", StateHeader: "r=0;t=60"}), + newResponse(http.StatusOK, map[string]string{PolicyHeader: "l=10;rpm=1", RateLimitHeader: "r=0;t=600"}), }} - options := testOptions() - options.MaxWait = time.Minute - transport := NewTransport(server, options) + transport := NewTransport(server, testOptions()) _, err := transport.RoundTrip(newGetRequest(t)) require.NoError(t, err) @@ -467,35 +426,6 @@ func TestRoundTripReturnsErrorWhenContextIsCancelledDuringThrottleWait(t *testin require.Equal(t, 1, server.requestCount()) } -// idleConnectionCloser is a base transport that records the calls to CloseIdleConnections. -type idleConnectionCloser struct { - closeCount int -} - -func (c *idleConnectionCloser) RoundTrip(req *http.Request) (*http.Response, error) { - return newResponse(http.StatusOK, nil)(), nil -} - -func (c *idleConnectionCloser) CloseIdleConnections() { - c.closeCount++ -} - -func TestCloseIdleConnectionsCallsTheBaseTransport(t *testing.T) { - base := &idleConnectionCloser{} - transport := NewTransport(base, testOptions()) - - transport.CloseIdleConnections() - - require.Equal(t, 1, base.closeCount) -} - -func TestCloseIdleConnectionsDoesNothingWhenTheBaseTransportDoesNotSupportIt(t *testing.T) { - server := &fakeServer{responses: []func() *http.Response{newResponse(http.StatusOK, nil)}} - transport := NewTransport(server, testOptions()) - - require.NotPanics(t, transport.CloseIdleConnections) -} - type closeRecorder struct { io.Reader closed bool @@ -507,15 +437,8 @@ func (c *closeRecorder) Close() error { } func TestRoundTripClosesBodyWhenContextIsCancelledBeforeFirstAttempt(t *testing.T) { - server := &fakeServer{responses: []func() *http.Response{ - newResponse(http.StatusOK, map[string]string{PolicyHeader: "l=200;rpm=1", StateHeader: "r=0;t=60"}), - }} - options := testOptions() - options.MaxWait = time.Minute - transport := NewTransport(server, options) - - _, err := transport.RoundTrip(newGetRequest(t)) - require.NoError(t, err) + server := &fakeServer{responses: []func() *http.Response{newResponse(http.StatusOK, nil)}} + transport := NewTransport(server, testOptions()) ctx, cancel := context.WithCancel(context.Background()) cancel() @@ -527,72 +450,34 @@ func TestRoundTripClosesBodyWhenContextIsCancelledBeforeFirstAttempt(t *testing. require.ErrorIs(t, err, context.Canceled) require.True(t, body.closed) - require.Equal(t, 1, server.requestCount()) + require.Equal(t, 0, server.requestCount()) } -func TestParseHeader(t *testing.T) { - testCases := []struct { - name string - value string - expected map[string]int - }{ - {"Policy", "l=200;rpm=600", map[string]int{"l": 200, "rpm": 600}}, - {"State", "r=7;t=18", map[string]int{"r": 7, "t": 18}}, - {"Spaces", " r = 7 ; t = 18 ", map[string]int{"r": 7, "t": 18}}, - {"Empty", "", map[string]int{}}, - {"MissingValue", "r;t=18", map[string]int{"t": 18}}, - {"NotANumber", "r=abc;t=18", map[string]int{"t": 18}}, - } - - for _, testCase := range testCases { - t.Run(testCase.name, func(t *testing.T) { - require.Equal(t, testCase.expected, parseHeader(testCase.value)) - }) - } +// idleConnectionCloser is a base transport that records the calls to CloseIdleConnections. +type idleConnectionCloser struct { + closeCount int } -func TestRetryAfter(t *testing.T) { - testCases := []struct { - name string - value string - expectedDelay time.Duration - expectedFound bool - }{ - {"Seconds", "5", 5 * time.Second, true}, - {"Zero", "0", 0, true}, - {"Missing", "", 0, false}, - {"Negative", "-1", 0, false}, - {"HttpDate", "Wed, 21 Oct 2015 07:28:00 GMT", 0, false}, - } - - for _, testCase := range testCases { - t.Run(testCase.name, func(t *testing.T) { - resp := newResponse(http.StatusTooManyRequests, map[string]string{"Retry-After": testCase.value})() - - delay, found := retryAfter(resp) +func (c *idleConnectionCloser) RoundTrip(req *http.Request) (*http.Response, error) { + return newResponse(http.StatusOK, nil)(), nil +} - require.Equal(t, testCase.expectedDelay, delay) - require.Equal(t, testCase.expectedFound, found) - }) - } +func (c *idleConnectionCloser) CloseIdleConnections() { + c.closeCount++ } -func TestBackoff(t *testing.T) { - for attempt := range 4 { - maxDelay := initialBackoff << attempt +func TestCloseIdleConnectionsCallsTheBaseTransport(t *testing.T) { + base := &idleConnectionCloser{} + transport := NewTransport(base, testOptions()) - delay := backoff(attempt) + transport.CloseIdleConnections() - require.GreaterOrEqual(t, delay, maxDelay/2) - require.Less(t, delay, maxDelay) - } + require.Equal(t, 1, base.closeCount) } -func TestBackoffDoesNotOverflowForLargeAttempts(t *testing.T) { - maxDelay := initialBackoff << maxBackoffShift - - delay := backoff(1000) +func TestCloseIdleConnectionsDoesNothingWhenTheBaseTransportDoesNotSupportIt(t *testing.T) { + server := &fakeServer{responses: []func() *http.Response{newResponse(http.StatusOK, nil)}} + transport := NewTransport(server, testOptions()) - require.GreaterOrEqual(t, delay, maxDelay/2) - require.Less(t, delay, maxDelay) + require.NotPanics(t, transport.CloseIdleConnections) } From f712fe74d4546069ae410d99e1c3ee612d9925fe Mon Sep 17 00:00:00 2001 From: Eoin Motherway <25342760+YuKitsune@users.noreply.github.com> Date: Fri, 16 Oct 2026 10:51:37 +1000 Subject: [PATCH 9/9] Address feedback --- pkg/client/rate_limit_test.go | 12 ----- pkg/client/ratelimit/http_client_test.go | 46 ------------------ pkg/client/ratelimit/pacer.go | 2 +- pkg/client/ratelimit/retry_test.go | 18 ------- pkg/client/ratelimit/transport.go | 10 ++-- pkg/client/ratelimit/transport_test.go | 60 +++++++++++------------- 6 files changed, 34 insertions(+), 114 deletions(-) diff --git a/pkg/client/rate_limit_test.go b/pkg/client/rate_limit_test.go index 50a03354..122aad39 100644 --- a/pkg/client/rate_limit_test.go +++ b/pkg/client/rate_limit_test.go @@ -71,15 +71,3 @@ func TestNewClientWithRateLimitedHttpClientRetriesTooManyRequests(t *testing.T) require.NotNil(t, octopusClient) require.Equal(t, 2, rootRequestCount()) } - -func TestNewClientWithoutRateLimitedHttpClientReturnsTooManyRequests(t *testing.T) { - httpClient, rootRequestCount := newRateLimitedHttpClient() - apiURL, err := url.Parse("https://octopus.example") - require.NoError(t, err) - - octopusClient, err := NewClient(httpClient, apiURL, testRateLimitAPIKey, "") - - require.Error(t, err) - require.Nil(t, octopusClient) - require.Equal(t, 1, rootRequestCount()) -} diff --git a/pkg/client/ratelimit/http_client_test.go b/pkg/client/ratelimit/http_client_test.go index 80de7621..c4f60bb9 100644 --- a/pkg/client/ratelimit/http_client_test.go +++ b/pkg/client/ratelimit/http_client_test.go @@ -2,9 +2,7 @@ package ratelimit import ( "net/http" - "net/http/cookiejar" "testing" - "time" "github.com/stretchr/testify/require" ) @@ -36,47 +34,3 @@ func TestNewHttpClientDoesNotChangeBase(t *testing.T) { require.NotSame(t, base, client) require.Equal(t, server, base.Transport) } - -func TestNewHttpClientKeepsTheSettingsOfBase(t *testing.T) { - jar, err := cookiejar.New(nil) - require.NoError(t, err) - checkRedirect := func(req *http.Request, via []*http.Request) error { - return http.ErrUseLastResponse - } - base := &http.Client{ - Timeout: 42 * time.Second, - Jar: jar, - CheckRedirect: checkRedirect, - } - - client := NewHttpClient(base, testOptions()) - - require.Equal(t, base.Timeout, client.Timeout) - require.Equal(t, base.Jar, client.Jar) - require.NotNil(t, client.CheckRedirect) - require.ErrorIs(t, client.CheckRedirect(nil, nil), http.ErrUseLastResponse) -} - -func TestNewHttpClientCloseIdleConnectionsReachesTheBaseTransport(t *testing.T) { - base := &idleConnectionCloser{} - - client := NewHttpClient(&http.Client{Transport: base}, testOptions()) - client.CloseIdleConnections() - - require.Equal(t, 1, base.closeCount) -} - -func TestNewHttpClientRetriesTooManyRequests(t *testing.T) { - server := &fakeServer{responses: []func() *http.Response{ - newResponse(http.StatusTooManyRequests, map[string]string{"Retry-After": "0"}), - newResponse(http.StatusOK, nil), - }} - - client := NewHttpClient(&http.Client{Transport: server}, testOptions()) - resp, err := client.Get(testURL) - - require.NoError(t, err) - defer resp.Body.Close() - require.Equal(t, http.StatusOK, resp.StatusCode) - require.Equal(t, 2, server.requestCount()) -} diff --git a/pkg/client/ratelimit/pacer.go b/pkg/client/ratelimit/pacer.go index 53ed1b31..47461f77 100644 --- a/pkg/client/ratelimit/pacer.go +++ b/pkg/client/ratelimit/pacer.go @@ -18,7 +18,7 @@ const reservedRequests = 1 // values such as "0x10", "Inf" and "NaN", which the server does not send. var numberPattern = regexp.MustCompile(`^[-+]?(\d+(\.\d*)?|\.\d+)([eE][-+]?\d+)?$`) -// pacer keeps requests in the rate limit that the server advertises in the PolicyHeader and RateLimitHeader +// pacer keeps requests in the rate limit that the server advertises in the policyHeader and rateLimitHeader // headers. Thus the client spreads its requests out and does not send them until the server rejects them. // // The pacer uses GCRA (the Generic Cell Rate Algorithm), which has the same behaviour as the token bucket of the diff --git a/pkg/client/ratelimit/retry_test.go b/pkg/client/ratelimit/retry_test.go index 4e8c0df4..6baf7c7b 100644 --- a/pkg/client/ratelimit/retry_test.go +++ b/pkg/client/ratelimit/retry_test.go @@ -138,13 +138,6 @@ func TestRetryDelayBacksOffFromTheDefaultDelayWhenRetryAfterIsMissing(t *testing require.Equal(t, 8*time.Second, delay) } -func TestRetryDelayRetriesImmediatelyAtEachAttemptWhenRetryAfterIsZero(t *testing.T) { - delay, ok := retryDelay("0", retryOptions(), 3, noJitter) - - require.True(t, ok) - require.Equal(t, time.Duration(0), delay) -} - func TestRetryDelayAddsJitterAfterTheWaitNeverBeforeIt(t *testing.T) { // The server told the earliest time to retry, thus jitter only makes the delay longer. delay, ok := retryDelay("2", retryOptions(), 0, maxJitter) @@ -164,17 +157,6 @@ func TestRetryDelayKeepsTheJitterInMaxDelay(t *testing.T) { require.LessOrEqual(t, delay, 30*time.Second) } -func TestDefaultOptions(t *testing.T) { - // These are the settings that NewHttpClient users get when they use DefaultOptions: five retries, two seconds - // when the server does not tell the delay, never more than thirty seconds, and pacing with the rate limit headers. - require.Equal(t, Options{ - RetryCount: 5, - DefaultDelay: 2 * time.Second, - MaxDelay: 30 * time.Second, - UseRateLimitHeaders: true, - }, DefaultOptions()) -} - func TestWaitReturnsWhenTheDelayIsOver(t *testing.T) { require.NoError(t, wait(context.Background(), 0)) } diff --git a/pkg/client/ratelimit/transport.go b/pkg/client/ratelimit/transport.go index 9584a7b6..6e4a4e5a 100644 --- a/pkg/client/ratelimit/transport.go +++ b/pkg/client/ratelimit/transport.go @@ -12,13 +12,13 @@ import ( ) const ( - // PolicyHeader is the advisory header that tells the burst limit and the requests per minute. + // policyHeader is the advisory header that tells the burst limit and the requests per minute. // For example: "l=200;rpm=600". - PolicyHeader = "Octopus-RateLimit-Policy" + policyHeader = "Octopus-RateLimit-Policy" - // RateLimitHeader is the advisory header that tells the remaining requests and the seconds until all of the + // rateLimitHeader is the advisory header that tells the remaining requests and the seconds until all of the // requests are available again. For example: "r=7;t=18". - RateLimitHeader = "Octopus-RateLimit" + rateLimitHeader = "Octopus-RateLimit" retryAfterHeader = "Retry-After" ) @@ -129,7 +129,7 @@ func (t *Transport) RoundTrip(req *http.Request) (*http.Response, error) { } if t.pacer != nil { - t.pacer.observeResponse(resp.Header.Get(PolicyHeader), resp.Header.Get(RateLimitHeader)) + t.pacer.observeResponse(resp.Header.Get(policyHeader), resp.Header.Get(rateLimitHeader)) } if resp.StatusCode != http.StatusTooManyRequests || attempt >= t.options.RetryCount || !canRetry(req) { diff --git a/pkg/client/ratelimit/transport_test.go b/pkg/client/ratelimit/transport_test.go index 8749055d..b010a800 100644 --- a/pkg/client/ratelimit/transport_test.go +++ b/pkg/client/ratelimit/transport_test.go @@ -136,16 +136,6 @@ func TestNewTransportKeepsPositiveDelays(t *testing.T) { require.Equal(t, 5*time.Second, transport.options.MaxDelay) } -func TestNewTransportPacesOnlyWhenUseRateLimitHeadersIsTrue(t *testing.T) { - options := testOptions() - - options.UseRateLimitHeaders = true - require.NotNil(t, NewTransport(nil, options).pacer) - - options.UseRateLimitHeaders = false - require.Nil(t, NewTransport(nil, options).pacer) -} - func TestRoundTripReturnsSuccessfulResponse(t *testing.T) { server := &fakeServer{responses: []func() *http.Response{newResponse(http.StatusOK, nil)}} transport := NewTransport(server, testOptions()) @@ -184,24 +174,6 @@ func TestRoundTripRetriesUntilTheServerAcceptsTheRequest(t *testing.T) { require.Equal(t, 3, server.requestCount()) } -func TestRoundTripWaitsForRetryAfter(t *testing.T) { - server := &fakeServer{responses: []func() *http.Response{ - tooManyRequests("1"), - newResponse(http.StatusOK, nil), - }} - options := testOptions() - options.MaxDelay = 2 * time.Second - transport := NewTransport(server, options) - - start := time.Now() - resp, err := transport.RoundTrip(newGetRequest(t)) - - require.NoError(t, err) - require.Equal(t, http.StatusOK, resp.StatusCode) - require.Equal(t, 2, server.requestCount()) - require.GreaterOrEqual(t, time.Since(start), time.Second) -} - func TestRoundTripWaitsTheDefaultDelayWhenThereIsNoRetryAfterHeader(t *testing.T) { server := &fakeServer{responses: []func() *http.Response{ tooManyRequests(""), @@ -354,7 +326,7 @@ func TestRoundTripGivesTheRateLimitHeadersToThePacer(t *testing.T) { for _, testCase := range testCases { t.Run(testCase.name, func(t *testing.T) { server := &fakeServer{responses: []func() *http.Response{ - newResponse(testCase.statusCode, map[string]string{PolicyHeader: testPolicy, RateLimitHeader: "r=0;t=10"}), + newResponse(testCase.statusCode, map[string]string{policyHeader: testPolicy, rateLimitHeader: "r=0;t=10"}), }} transport := NewTransport(server, testOptions()) testPacer, _ := newTestPacer() @@ -372,7 +344,7 @@ func TestRoundTripWaitsForThePacer(t *testing.T) { // 600 requests per minute gives one request each 100 milliseconds. No requests remain, thus the // next request waits for two requests: the request that it sends, and the request that the pacer keeps back. server := &fakeServer{responses: []func() *http.Response{ - newResponse(http.StatusOK, map[string]string{PolicyHeader: "l=10;rpm=600", RateLimitHeader: "r=0;t=1"}), + newResponse(http.StatusOK, map[string]string{policyHeader: "l=10;rpm=600", rateLimitHeader: "r=0;t=1"}), }} transport := NewTransport(server, testOptions()) @@ -386,11 +358,35 @@ func TestRoundTripWaitsForThePacer(t *testing.T) { require.GreaterOrEqual(t, time.Since(start), 150*time.Millisecond) } +func TestRoundTripDoesNotPaceWhenUseRateLimitHeadersIsFalse(t *testing.T) { + // 1 request per minute and no requests remain. With pacing, the next request must wait for two minutes. + server := &fakeServer{responses: []func() *http.Response{ + newResponse(http.StatusOK, map[string]string{policyHeader: "l=10;rpm=1", rateLimitHeader: "r=0;t=600"}), + }} + options := testOptions() + options.UseRateLimitHeaders = false + transport := NewTransport(server, options) + + _, err := transport.RoundTrip(newGetRequest(t)) + require.NoError(t, err) + + ctx, cancel := context.WithTimeout(context.Background(), time.Second) + defer cancel() + req, err := http.NewRequestWithContext(ctx, http.MethodGet, testURL, nil) + require.NoError(t, err) + + resp, err := transport.RoundTrip(req) + + require.NoError(t, err) + require.Equal(t, http.StatusOK, resp.StatusCode) + require.Equal(t, 2, server.requestCount()) +} + func TestRoundTripPacesRetries(t *testing.T) { // The 429 response tells the client to retry immediately, but no requests remain, thus the pacer makes the // retry wait. A request that the limiter rejected must not go back to it immediately. server := &fakeServer{responses: []func() *http.Response{ - newResponse(http.StatusTooManyRequests, map[string]string{retryAfterHeader: "0", PolicyHeader: "l=10;rpm=600", RateLimitHeader: "r=0;t=1"}), + newResponse(http.StatusTooManyRequests, map[string]string{retryAfterHeader: "0", policyHeader: "l=10;rpm=600", rateLimitHeader: "r=0;t=1"}), newResponse(http.StatusOK, nil), }} transport := NewTransport(server, testOptions()) @@ -407,7 +403,7 @@ func TestRoundTripPacesRetries(t *testing.T) { func TestRoundTripReturnsErrorWhenContextIsCancelledDuringPacerWait(t *testing.T) { // 1 request per minute and no requests remain, thus the next request must wait for two minutes. server := &fakeServer{responses: []func() *http.Response{ - newResponse(http.StatusOK, map[string]string{PolicyHeader: "l=10;rpm=1", RateLimitHeader: "r=0;t=600"}), + newResponse(http.StatusOK, map[string]string{policyHeader: "l=10;rpm=1", rateLimitHeader: "r=0;t=600"}), }} transport := NewTransport(server, testOptions())