Skip to content
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
16 changes: 16 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -184,6 +184,22 @@ Top-level non-resource commands:
| 6 | server error (5xx) |
| 7 | network / transport failure |

## Retries

API requests retry transient network errors, HTTP 429, and HTTP 5xx up to
three times by default (four attempts including the initial request).
Use `--max-retries N` to set the number of retries per request, or
`--max-retries 0` to make a single attempt:

```sh
twoctl order get --order-id abc-123 --max-retries 0
twoctl billing-statement get --all --max-retries 1
```

The setting applies to each page when using `--all`. Retries keep the same
idempotency key and request body. The existing 60-second request timeout
still bounds the attempts and backoff for each request.

## Self-upgrade

`twoctl` checks for new releases at most once every 24 hours and prompts when one is available. The prompt offers:
Expand Down
20 changes: 19 additions & 1 deletion cmd/twoctl/cli/operations.go
Original file line number Diff line number Diff line change
Expand Up @@ -362,7 +362,25 @@ func runOperation(c *cobra.Command, method, path string, op *openapi3.Operation,
if dryRun {
return printDryRun(c, req, body != nil)
}
return executeRequest(c, httpx.New(resolved.APIKey), req)
client, err := operationClient(resolved.APIKey)
if err != nil {
return err
}
return executeRequest(c, client, req)
}

// operationClient applies the same retry policy to ordinary and paginated calls.
func operationClient(apiKey string) (*http.Client, error) {
if flagMaxRetries < 0 {
return nil, &apiError{
code: "invalid_max_retries",
message: "--max-retries must be zero or greater",
exit: ExitUsage,
}
}
return httpx.NewWithOptions(httpx.Options{
APIKey: apiKey, MaxRetries: flagMaxRetries, DisableRetries: flagMaxRetries == 0,
}), nil
}

func resolveForRun(dryRun bool) (*config.Resolved, error) {
Expand Down
1 change: 1 addition & 0 deletions cmd/twoctl/cli/operations_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -34,6 +34,7 @@ func resetGlobals(t *testing.T) {
flagContext = ""
flagURL = ""
flagOutput = "json"
flagMaxRetries = 3
resetCobraFlags(Root())
dir := t.TempDir()
t.Setenv("XDG_CONFIG_HOME", dir)
Expand Down
6 changes: 4 additions & 2 deletions cmd/twoctl/cli/paginate.go
Original file line number Diff line number Diff line change
Expand Up @@ -19,7 +19,6 @@ import (
"github.com/getkin/kin-openapi/openapi3"
"github.com/spf13/cobra"

"github.com/two-inc/twoctl-cli/internal/httpx"
"github.com/two-inc/twoctl-cli/internal/output"
)

Expand Down Expand Up @@ -56,7 +55,10 @@ func runPaginated(c *cobra.Command, method, path string, op *openapi3.Operation,
if err != nil {
return err
}
client := httpx.New(resolved.APIKey)
client, err := operationClient(resolved.APIKey)
if err != nil {
return err
}

var combined []any
cursorPtr := b.stringFlags[cursorFlagName]
Expand Down
101 changes: 101 additions & 0 deletions cmd/twoctl/cli/retries_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,101 @@
package cli

import (
"fmt"
"net/http"
"net/http/httptest"
"strconv"
"testing"
)

func TestMaxRetriesAppliesToEveryAPIPath(t *testing.T) {
for _, paginated := range []bool{false, true} {
for _, retries := range []int{0, 1, -1} {
t.Run(fmt.Sprintf("paginated=%t/retries=%d", paginated, retries), func(t *testing.T) {
checkRetryAttempts(t, paginated, retries)
})
}
}
}

// A negative value here means omit the flag and assert the default policy.
func checkRetryAttempts(t *testing.T, paginated bool, retries int) {
t.Helper()
resetGlobals(t)
calls := 0
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
calls++
w.WriteHeader(http.StatusServiceUnavailable)
fmt.Fprint(w, `{"error":"temporary"}`)
}))
defer srv.Close()
args := []string{"--url", srv.URL, "--api-key", "secret_test_x", "billing-statement", "get"}
if paginated {
args = append(args, "--all")
}
want := 4
if retries >= 0 {
args = append(args, "--max-retries", strconv.Itoa(retries))
want = retries + 1
}
_, _, err := runRoot(t, args...)
ae, ok := err.(*apiError)
if !ok || ae.status != http.StatusServiceUnavailable {
t.Fatalf("expected HTTP 503 apiError, got %v", err)
}
if calls != want {
t.Fatalf("got %d HTTP attempts, want %d", calls, want)
}
}

func TestNegativeMaxRetriesDoesNotSendRequest(t *testing.T) {
for _, paginated := range []bool{false, true} {
t.Run(fmt.Sprintf("paginated=%t", paginated), func(t *testing.T) {
resetGlobals(t)
calls := 0
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
calls++
}))
defer srv.Close()
args := []string{"--url", srv.URL, "--api-key", "secret_test_x", "--max-retries", "-1", "billing-statement", "get"}
if paginated {
args = append(args, "--all")
}
_, _, err := runRoot(t, args...)
ae, ok := err.(*apiError)
if !ok || ae.code != "invalid_max_retries" || ae.exit != ExitUsage {
t.Fatalf("expected usage error, got %v", err)
}
if calls != 0 {
t.Fatalf("sent %d requests with invalid retry setting", calls)
}
})
}
}

func TestMaxRetriesAppliesToEveryPage(t *testing.T) {
resetGlobals(t)
calls := map[string]int{}
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
cursor := r.URL.Query().Get("page_cursor")
calls[cursor]++
if calls[cursor] == 1 {
w.WriteHeader(http.StatusServiceUnavailable)
return
}
if cursor == "" {
fmt.Fprint(w, `{"items":[{"id":"a"}],"next_page_cursor":"second"}`)
return
}
fmt.Fprint(w, `{"items":[{"id":"b"}]}`)
}))
defer srv.Close()
_, _, err := runRoot(t, "--url", srv.URL, "--api-key", "secret_test_x",
"billing-statement", "get", "--all", "--max-retries", "1")
if err != nil {
t.Fatal(err)
}
if calls[""] != 2 || calls["second"] != 2 || len(calls) != 2 {
t.Fatalf("expected one retry for each page, got %v", calls)
}
}
12 changes: 7 additions & 5 deletions cmd/twoctl/cli/root.go
Original file line number Diff line number Diff line change
Expand Up @@ -10,11 +10,12 @@ import (

// Global flags. Populated by cobra during Execute().
var (
flagAPIKey string
flagEnv string
flagContext string
flagURL string
flagOutput string
flagAPIKey string
flagEnv string
flagContext string
flagURL string
flagOutput string
flagMaxRetries int
)

// activeEnv returns the context-selecting flag value, preferring --context
Expand Down Expand Up @@ -53,6 +54,7 @@ func init() {
rootCmd.PersistentFlags().StringVar(&flagContext, "context", "", "alias of --env (kubectl-style)")
rootCmd.PersistentFlags().StringVar(&flagURL, "url", "", "raw API base URL (overrides the context's URL)")
rootCmd.PersistentFlags().StringVarP(&flagOutput, "output", "o", "auto", "output format: table, json, yaml (auto = table on TTY, json when piped)")
rootCmd.PersistentFlags().IntVar(&flagMaxRetries, "max-retries", httpx.DefaultMaxRetries, "maximum retries per API request (0 disables retries)")
rootCmd.Version = httpx.Version
}

Expand Down
7 changes: 6 additions & 1 deletion internal/httpx/client.go
Original file line number Diff line number Diff line change
Expand Up @@ -35,6 +35,9 @@ type Options struct {
APIKey string
Timeout time.Duration
MaxRetries int
// DisableRetries takes precedence over MaxRetries, whose zero value
// otherwise selects DefaultMaxRetries.
DisableRetries bool
}

// New returns an *http.Client wired with auth + identity headers, idempotency
Expand All @@ -49,7 +52,9 @@ func NewWithOptions(opt Options) *http.Client {
if opt.Timeout == 0 {
opt.Timeout = DefaultTimeout
}
if opt.MaxRetries == 0 {
if opt.DisableRetries {
opt.MaxRetries = 0
} else if opt.MaxRetries == 0 {
opt.MaxRetries = DefaultMaxRetries
}
return &http.Client{
Expand Down
9 changes: 9 additions & 0 deletions internal/httpx/retry_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -183,3 +183,12 @@ func TestContextCancellationStopsRetry(t *testing.T) {
t.Error("expected context cancellation error")
}
}

func TestDisableRetriesOverridesDefaultAndExplicitCount(t *testing.T) {
for _, retries := range []int{0, 3} {
client := NewWithOptions(Options{DisableRetries: true, MaxRetries: retries})
if got := client.Transport.(*transport).maxRetries; got != 0 {
t.Errorf("DisableRetries with MaxRetries=%d: got %d retries", retries, got)
}
}
}
Loading