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
Signed-off-by: Pierre-Emmanuel Jacquier <15922119+pierre-emmanuelJ@users.noreply.github.com>
  • Loading branch information
pierre-emmanuelJ committed Dec 13, 2024
1 parent b80c7c3 commit f20169c
Show file tree
Hide file tree
Showing 4 changed files with 162 additions and 39 deletions.
36 changes: 35 additions & 1 deletion 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 @@ -44,7 +45,9 @@ func (c Client) Wait(ctx context.Context, op *Operation, states ...OperationStat
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 {
Expand All @@ -56,6 +59,15 @@ 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
Expand Down Expand Up @@ -140,6 +152,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)
}
})
}
}
53 changes: 32 additions & 21 deletions v3/client.go
Original file line number Diff line number Diff line change
@@ -1,6 +1,3 @@
// Package v3 provides primitives to interact with the openapi HTTP API.
//
// Code generated by github.com/egoscale/v3/generator version v0.0.1 DO NOT EDIT.
package v3

import (
Expand Down Expand Up @@ -57,14 +54,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 @@ -77,8 +74,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 @@ -114,6 +109,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 @@ -153,13 +156,12 @@ func NewClient(credentials *credentials.Credentials, opts ...ClientOpt) (*Client
}

client := &Client{
apiKey: values.APIKey,
apiSecret: values.APISecret,
serverEndpoint: string(CHGva2),
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 {
Expand Down Expand Up @@ -195,6 +197,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 @@ -259,7 +270,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
50 changes: 33 additions & 17 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 Down Expand Up @@ -92,6 +92,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 +139,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 +180,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 +253,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 f20169c

Please sign in to comment.