Skip to content
37 changes: 37 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -59,6 +59,43 @@ 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, create an `http.Client` with `ratelimit.NewHttpClient` and give it to the client:

```go
import (
"github.com/OctopusDeploy/go-octopusdeploy/v2/pkg/client"
"github.com/OctopusDeploy/go-octopusdeploy/v2/pkg/client/ratelimit"
)

rateLimitedClient := ratelimit.NewHttpClient(nil, ratelimit.DefaultOptions())

octopusClient, err := client.NewClient(rateLimitedClient, apiURL, apiKey, spaceID)
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, 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 |
| --------------------- | ------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `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.

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
Expand Down
54 changes: 54 additions & 0 deletions examples/rate_limiting/create_rate_limited_client.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,54 @@
package examples

import (
"fmt"
"net/url"
"time"

"github.com/OctopusDeploy/go-octopusdeploy/v2/pkg/client"
"github.com/OctopusDeploy/go-octopusdeploy/v2/pkg/client/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
}

// 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.RetryCount = 10
rateLimitOptions.MaxDelay = 60 * time.Second

// the first parameter for ratelimit.NewHttpClient can accept a http.Client if you wish to
Comment thread
YuKitsune marked this conversation as resolved.
Resolved
// 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 {
_ = 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())
}
73 changes: 73 additions & 0 deletions pkg/client/rate_limit_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,73 @@
package client

import (
"io"
"net/http"
"net/url"
"strings"
"sync"
"testing"
"time"

"github.com/stretchr/testify/require"

"github.com/OctopusDeploy/go-octopusdeploy/v2/pkg/client/ratelimit"
"github.com/OctopusDeploy/go-octopusdeploy/v2/pkg/constants"
)

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 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 strings.TrimSuffix(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 TestNewClientWithRateLimitedHttpClientRetriesTooManyRequests(t *testing.T) {
httpClient, rootRequestCount := newRateLimitedHttpClient()
apiURL, err := url.Parse("https://octopus.example")
require.NoError(t, err)
rateLimitedHttpClient := ratelimit.NewHttpClient(httpClient, ratelimit.Options{RetryCount: 3, MaxDelay: time.Second})

octopusClient, err := NewClient(rateLimitedHttpClient, apiURL, testRateLimitAPIKey, "")

require.NoError(t, err)
require.NotNil(t, octopusClient)
require.Equal(t, 2, rootRequestCount())
}
19 changes: 19 additions & 0 deletions pkg/client/ratelimit/http_client.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,19 @@
package ratelimit

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 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{}
}

client := *base
client.Transport = NewTransport(base.Transport, options)
return &client
}
36 changes: 36 additions & 0 deletions pkg/client/ratelimit/http_client_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,36 @@
package ratelimit

import (
"net/http"
"testing"

"github.com/stretchr/testify/require"
)

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 TestNewHttpClientWrapsTheTransportOfBase(t *testing.T) {
server := &fakeServer{responses: []func() *http.Response{newResponse(http.StatusOK, nil)}}
base := &http.Client{Transport: server}

client := NewHttpClient(base, testOptions())

require.IsType(t, &Transport{}, client.Transport)
require.Equal(t, server, client.Transport.(*Transport).Base())
}

func TestNewHttpClientDoesNotChangeBase(t *testing.T) {
server := &fakeServer{responses: []func() *http.Response{newResponse(http.StatusOK, nil)}}
base := &http.Client{Transport: server}

client := NewHttpClient(base, testOptions())

require.NotSame(t, base, client)
require.Equal(t, server, base.Transport)
}
Loading
Loading