Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

feat(action): use configurable backoff to wait for action progress #227

Merged
merged 1 commit into from
Feb 6, 2023
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
53 changes: 43 additions & 10 deletions hcloud/action.go
Original file line number Diff line number Diff line change
Expand Up @@ -181,7 +181,24 @@ func (c *ActionClient) AllWithOpts(ctx context.Context, opts ActionListOpts) ([]
return allActions, nil
}

// WatchOverallProgress watches several actions' progress until they complete with success or error.
// WatchOverallProgress watches several actions' progress until they complete
// with success or error. This watching happens in a goroutine and updates are
// provided through the two returned channels:
//
// - The first channel receives percentage updates of the progress, based on
// the number of completed versus total watched actions. The return value
// is an int between 0 and 100.
// - The second channel returned receives errors for actions that did not
// complete successfully, as well as any errors that happened while
// querying the API.
//
// By default the method keeps watching until all actions have finished
// processing. If you want to be able to cancel the method or configure a
// timeout, use the [context.Context]. Once the method has stopped watching,
// both returned channels are closed.
//
// WatchOverallProgress uses the [WithPollBackoffFunc] of the [Client] to wait
// until sending the next request.
func (c *ActionClient) WatchOverallProgress(ctx context.Context, actions []*Action) (<-chan int, <-chan error) {
errCh := make(chan error, len(actions))
progressCh := make(chan int)
Expand All @@ -196,15 +213,15 @@ func (c *ActionClient) WatchOverallProgress(ctx context.Context, actions []*Acti
watchIDs[action.ID] = struct{}{}
}

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

for {
select {
case <-ctx.Done():
errCh <- ctx.Err()
return
case <-ticker.C:
break
case <-time.After(c.client.pollBackoffFunc(retries)):
retries++
}

opts := ActionListOpts{}
Expand Down Expand Up @@ -241,7 +258,24 @@ func (c *ActionClient) WatchOverallProgress(ctx context.Context, actions []*Acti
return progressCh, errCh
}

// WatchProgress watches one action's progress until it completes with success or error.
// WatchProgress watches one action's progress until it completes with success
// or error. This watching happens in a goroutine and updates are provided
// through the two returned channels:
//
// - The first channel receives percentage updates of the progress, based on
// the progress percentage indicated by the API. The return value is an int
// between 0 and 100.
// - The second channel receives any errors that happened while querying the
// API, as well as the error of the action if it did not complete
// successfully, or nil if it did.
//
// By default the method keeps watching until the action has finished
// processing. If you want to be able to cancel the method or configure a
// timeout, use the [context.Context]. Once the method has stopped watching,
// both returned channels are closed.
//
// WatchProgress uses the [WithPollBackoffFunc] of the [Client] to wait until
// sending the next request.
func (c *ActionClient) WatchProgress(ctx context.Context, action *Action) (<-chan int, <-chan error) {
errCh := make(chan error, 1)
progressCh := make(chan int)
Expand All @@ -250,16 +284,15 @@ func (c *ActionClient) WatchProgress(ctx context.Context, action *Action) (<-cha
defer close(errCh)
defer close(progressCh)

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

for {
select {
case <-ctx.Done():
errCh <- ctx.Err()
return
case <-ticker.C:
break
case <-time.After(c.client.pollBackoffFunc(retries)):
retries++
}

a, _, err := c.GetByID(ctx, action.ID)
Expand Down
122 changes: 122 additions & 0 deletions hcloud/action_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,9 @@ package hcloud
import (
"context"
"encoding/json"
"errors"
"net/http"
"reflect"
"testing"
"time"

Expand Down Expand Up @@ -164,6 +166,126 @@ func TestActionClientAll(t *testing.T) {
}
}

func TestActionClientWatchOverallProgress(t *testing.T) {
env := newTestEnv()
defer env.Teardown()

callCount := 0

env.Mux.HandleFunc("/actions", func(w http.ResponseWriter, r *http.Request) {
callCount++
var actions []schema.Action

switch callCount {
case 1:
actions = []schema.Action{
{
ID: 1,
Status: "running",
Progress: 50,
},
{
ID: 2,
Status: "running",
Progress: 50,
},
}
case 2:
actions = []schema.Action{
{
ID: 1,
Status: "running",
Progress: 75,
},
{
ID: 2,
Status: "error",
Progress: 100,
Error: &schema.ActionError{
Code: "action_failed",
Message: "action failed",
},
},
}
case 3:
actions = []schema.Action{
{
ID: 1,
Status: "success",
Progress: 100,
},
}
default:
t.Errorf("unexpected number of calls to the test server: %v", callCount)
}

w.Header().Set("Content-Type", "application/json")
_ = json.NewEncoder(w).Encode(struct {
Actions []schema.Action `json:"actions"`
Meta schema.Meta `json:"meta"`
}{
Actions: actions,
Meta: schema.Meta{
Pagination: &schema.MetaPagination{
Page: 1,
LastPage: 1,
PerPage: len(actions),
TotalEntries: len(actions),
},
},
})
})

actions := []*Action{
{
ID: 1,
Status: ActionStatusRunning,
},
{
ID: 2,
Status: ActionStatusRunning,
},
}

ctx := context.Background()
progressCh, errCh := env.Client.Action.WatchOverallProgress(ctx, actions)
progressUpdates := []int{}
errs := []error{}

moreProgress, moreErrors := true, true

for moreProgress || moreErrors {
var progress int
var err error

select {
case progress, moreProgress = <-progressCh:
if moreProgress {
progressUpdates = append(progressUpdates, progress)
}
case err, moreErrors = <-errCh:
if moreErrors {
errs = append(errs, err)
}
}
}

if len(errs) != 1 {
t.Fatalf("expected to receive one error: %v", errs)
}

err := errs[0]

if e, ok := errors.Unwrap(err).(ActionError); !ok || e.Code != "action_failed" {
t.Fatalf("expected hcloud.Error, but got: %#v", err)
}

expectedProgressUpdates := []int{50}
if !reflect.DeepEqual(progressUpdates, expectedProgressUpdates) {
t.Fatalf("expected progresses %v but received %v", expectedProgressUpdates, progressUpdates)
}
}

func TestActionClientWatchProgress(t *testing.T) {
env := newTestEnv()
defer env.Teardown()
Expand Down
34 changes: 25 additions & 9 deletions hcloud/client.go
Original file line number Diff line number Diff line change
Expand Up @@ -54,8 +54,8 @@ type Client struct {
endpoint string
token string
tokenValid bool
pollInterval time.Duration
backoffFunc BackoffFunc
pollBackoffFunc BackoffFunc
httpClient *http.Client
applicationName string
applicationVersion string
Expand Down Expand Up @@ -102,15 +102,31 @@ func WithToken(token string) ClientOption {
}
}

// WithPollInterval configures a Client to use the specified interval when polling
// from the API.
// WithPollInterval configures a Client to use the specified interval when
// polling from the API.
//
// Deprecated: Setting the poll interval is deprecated, you can now configure
// [WithPollBackoffFunc] with a [ConstantBackoff] to get the same results. To
// migrate your code, replace your usage like this:
//
// // before
// hcloud.WithPollInterval(2 * time.Second)
// // now
// hcloud.WithPollBackoffFunc(hcloud.ConstantBackoff(2 * time.Second))
func WithPollInterval(pollInterval time.Duration) ClientOption {
return WithPollBackoffFunc(ConstantBackoff(pollInterval))
}

// WithPollBackoffFunc configures a Client to use the specified backoff
// function when polling from the API.
func WithPollBackoffFunc(f BackoffFunc) ClientOption {
return func(client *Client) {
client.pollInterval = pollInterval
client.backoffFunc = f
}
}

// WithBackoffFunc configures a Client to use the specified backoff function.
// The backoff function is used for retrying HTTP requests.
func WithBackoffFunc(f BackoffFunc) ClientOption {
return func(client *Client) {
client.backoffFunc = f
Expand Down Expand Up @@ -152,11 +168,11 @@ func WithInstrumentation(registry *prometheus.Registry) ClientOption {
// NewClient creates a new client.
func NewClient(options ...ClientOption) *Client {
client := &Client{
endpoint: Endpoint,
tokenValid: true,
httpClient: &http.Client{},
backoffFunc: ExponentialBackoff(2, 500*time.Millisecond),
pollInterval: 500 * time.Millisecond,
endpoint: Endpoint,
tokenValid: true,
httpClient: &http.Client{},
backoffFunc: ExponentialBackoff(2, 500*time.Millisecond),
pollBackoffFunc: ConstantBackoff(500 * time.Millisecond),
}

for _, option := range options {
Expand Down
1 change: 1 addition & 0 deletions hcloud/client_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -35,6 +35,7 @@ func newTestEnv() testEnv {
WithEndpoint(server.URL),
WithToken("token"),
WithBackoffFunc(func(_ int) time.Duration { return 0 }),
WithPollBackoffFunc(func(r int) time.Duration { return 0 }),
)
return testEnv{
Server: server,
Expand Down