Skip to content

Commit

Permalink
v3: Client introduce a wait timeout with different polling strategy (#…
Browse files Browse the repository at this point in the history
…674)

Signed-off-by: Pierre-Emmanuel Jacquier <[email protected]>
  • Loading branch information
pierre-emmanuelJ authored Dec 16, 2024
1 parent b80c7c3 commit 2c4b980
Show file tree
Hide file tree
Showing 5 changed files with 173 additions and 39 deletions.
1 change: 1 addition & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@ Changelog

Unrelease
----------
- v3: Client introduce a wait timeout with different polling strategy #674
- v3 generator: Findable return error on too many found #669

3.1.7
Expand Down
47 changes: 45 additions & 2 deletions v3/api.go
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@ import (
"errors"
"fmt"
"io"
"math"
"net/http"
"net/http/httputil"
"os"
Expand Down Expand Up @@ -40,26 +41,46 @@ func ParseUUID(s string) (UUID, error) {
// Final states are one of: failure, success, timeout.
// If states argument are given, returns an error if the final state not match on of those.
func (c Client) Wait(ctx context.Context, op *Operation, states ...OperationState) (*Operation, error) {
const abortErrorsCount = 5

if op == nil {
return nil, fmt.Errorf("operation is nil")
}

ticker := time.NewTicker(c.pollingInterval)
startTime := time.Now()

ticker := time.NewTicker(pollInterval(0))
defer ticker.Stop()

if op.State != OperationStatePending {
return op, nil
}

var subsequentErrors int
var operation *Operation
polling:
for {
select {
case <-ticker.C:
runTime := time.Since(startTime)

if c.waitTimeout != 0 && runTime > c.waitTimeout {
return nil, fmt.Errorf("operation: %q: max wait timeout reached", op.ID)
}

newInterval := pollInterval(runTime)
ticker.Reset(newInterval)

o, err := c.GetOperation(ctx, op.ID)
if err != nil {
return nil, err
subsequentErrors++
if subsequentErrors >= abortErrorsCount {
return nil, err
}
continue
}
subsequentErrors = 0

if o.State == OperationStatePending {
continue
}
Expand Down Expand Up @@ -140,6 +161,28 @@ func (c Client) Validate(s any) error {
return err
}

// pollInterval returns the wait interval (as a time.Duration) before the next poll, based on the current runtime of a job.
// The polling frequency is:
// - every 3 seconds for the first 30 seconds
// - then increases linearly to reach 1 minute at 15 minutes of runtime
// - after 15 minutes, it stays at 1 minute intervals
func pollInterval(runTime time.Duration) time.Duration {
runTimeSeconds := runTime.Seconds()

// Coefficients for the linear equation y = a * x + b
a := 57.0 / 870.0
b := 3.0 - 30.0*a

minWait := 3.0
maxWait := 60.0

interval := a*runTimeSeconds + b
interval = math.Max(minWait, interval)
interval = math.Min(maxWait, interval)

return time.Duration(interval) * time.Second
}

func prepareJSONBody(body any) (*bytes.Reader, error) {
buf, err := json.Marshal(body)
if err != nil {
Expand Down
62 changes: 62 additions & 0 deletions v3/api_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,62 @@
package v3

import (
"testing"
"time"
)

func TestPollInterval(t *testing.T) {
tests := []struct {
runTime time.Duration
expectedMin time.Duration
expectedMax time.Duration
description string
}{
{
runTime: 10 * time.Second,
expectedMin: 3 * time.Second,
expectedMax: 3 * time.Second,
description: "Polling at 10 seconds should return 3 seconds",
},
{
runTime: 30 * time.Second,
expectedMin: 3 * time.Second,
expectedMax: 3 * time.Second,
description: "Polling at 30 seconds should still return 3 seconds",
},
{
runTime: 60 * time.Second,
expectedMin: 3 * time.Second,
expectedMax: 7 * time.Second, // Expected range after 30s should increase linearly
description: "Polling at 60 seconds should return a value greater than 3 seconds but less than 7 seconds",
},
{
runTime: 300 * time.Second,
expectedMin: 3 * time.Second,
expectedMax: 24 * time.Second, // Interval keeps increasing linearly
description: "Polling at 5 minutes should return a value in the correct range (up to 24 seconds)",
},
{
runTime: 900 * time.Second, // 15 minutes
expectedMin: 60 * time.Second,
expectedMax: 60 * time.Second,
description: "Polling at 15 minutes should return exactly 60 seconds",
},
{
runTime: 1200 * time.Second, // 20 minutes
expectedMin: 60 * time.Second,
expectedMax: 60 * time.Second,
description: "Polling beyond 15 minutes should cap at 60 seconds",
},
}

for _, test := range tests {
t.Run(test.description, func(t *testing.T) {
interval := pollInterval(test.runTime)
if interval < test.expectedMin || interval > test.expectedMax {
t.Errorf("pollInterval(%v) = %v, expected between %v and %v",
test.runTime, interval, test.expectedMin, test.expectedMax)
}
})
}
}
50 changes: 32 additions & 18 deletions v3/client.go

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

52 changes: 33 additions & 19 deletions v3/generator/client/client.tmpl
Original file line number Diff line number Diff line change
Expand Up @@ -35,14 +35,14 @@ func (c Client) GetZoneAPIEndpoint(ctx context.Context, zoneName ZoneName) (Endp

// Client represents an Exoscale API client.
type Client struct {
apiKey string
apiSecret string
userAgent string
serverEndpoint string
httpClient *http.Client
pollingInterval time.Duration
validate *validator.Validate
trace bool
apiKey string
apiSecret string
userAgent string
serverEndpoint string
httpClient *http.Client
waitTimeout time.Duration
validate *validator.Validate
trace bool

// A list of callbacks for modifying requests which are generated before sending over
// the network.
Expand All @@ -55,8 +55,6 @@ type RequestInterceptorFn func(ctx context.Context, req *http.Request) error
// Deprecated: use ClientOptWithUserAgent instead.
var UserAgent = getDefaultUserAgent()

const pollingInterval = 3 * time.Second

// ClientOpt represents a function setting Exoscale API client option.
type ClientOpt func(*Client) error

Expand Down Expand Up @@ -92,6 +90,14 @@ func ClientOptWithEndpoint(endpoint Endpoint) ClientOpt {
}
}

// ClientOptWithWaitTimeout returns a ClientOpt With a given wait timeout.
func ClientOptWithWaitTimeout(t time.Duration) ClientOpt {
return func(c *Client) error {
c.waitTimeout = t
return nil
}
}

// ClientOptWithRequestInterceptors returns a ClientOpt With given RequestInterceptors.
func ClientOptWithRequestInterceptors(f ...RequestInterceptorFn) ClientOpt {
return func(c *Client) error {
Expand Down Expand Up @@ -131,14 +137,13 @@ func NewClient(credentials *credentials.Credentials, opts ...ClientOpt) (*Client
}

client := &Client{
apiKey: values.APIKey,
apiSecret: values.APISecret,
serverEndpoint: string({{ .ServerEndpoint }}),
httpClient: http.DefaultClient,
pollingInterval: pollingInterval,
validate: validator.New(),
userAgent: getDefaultUserAgent(),
}
apiKey: values.APIKey,
apiSecret: values.APISecret,
serverEndpoint: string(CHGva2),
httpClient: http.DefaultClient,
validate: validator.New(),
userAgent: getDefaultUserAgent(),
}

for _, opt := range opts {
if err := opt(client); err != nil {
Expand Down Expand Up @@ -173,6 +178,15 @@ func (c *Client) WithEndpoint(endpoint Endpoint) *Client {
return clone
}

// WithWaitTimeout returns a copy of Client with new wait timeout.
func (c *Client) WithWaitTimeout(t time.Duration) *Client {
clone := cloneClient(c)

clone.waitTimeout = t

return clone
}

// WithUserAgent returns a copy of Client with new User-Agent.
func (c *Client) WithUserAgent(ua string) *Client {
clone := cloneClient(c)
Expand Down Expand Up @@ -237,7 +251,7 @@ func cloneClient(c *Client) *Client {
serverEndpoint: c.serverEndpoint,
httpClient: c.httpClient,
requestInterceptors: c.requestInterceptors,
pollingInterval: c.pollingInterval,
waitTimeout: c.waitTimeout,
trace: c.trace,
validate: c.validate,
}
Expand Down

0 comments on commit 2c4b980

Please sign in to comment.