Skip to content
Closed
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
70 changes: 35 additions & 35 deletions cmd/admin/org_contacts.go

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

8 changes: 8 additions & 0 deletions cmd/root.go
Original file line number Diff line number Diff line change
Expand Up @@ -36,6 +36,12 @@ var rootCmd = &cobra.Command{
paginate, _ := cmd.Flags().GetBool("paginate")
config.SetPaginate(paginate)

// Rate-limit retry controls
maxRetry, _ := cmd.Flags().GetInt("max-retry")
config.SetMaxRetry(maxRetry)
maxRetryTimer, _ := cmd.Flags().GetInt("max-retry-timer")
config.SetMaxRetryTimer(maxRetryTimer)

// Load app config early (safe local file read, needed by skipAuth-exempt commands like set-org)
cfg, err := appconfig.Load()
if err != nil {
Expand Down Expand Up @@ -172,6 +178,8 @@ func init() {
rootCmd.PersistentFlags().Bool("dry-run", false, "Print write requests without executing them")
rootCmd.PersistentFlags().String("user", "", "Use a specific authenticated user (email)")
rootCmd.PersistentFlags().String("organization", "", "Override organization ID for this command")
rootCmd.PersistentFlags().Int("max-retry", 3, "Max number of 429 retries before giving up (0 = no retries)")
rootCmd.PersistentFlags().Int("max-retry-timer", 60, "Max total seconds to wait across all 429 retries (0 = unlimited)")

// On flag errors (unknown flag, bad value), print usage with valid flags.
// SilenceUsage suppresses Cobra's automatic usage, so we print it ourselves.
Expand Down
9 changes: 6 additions & 3 deletions codegen/generate_cli.py
Original file line number Diff line number Diff line change
Expand Up @@ -222,11 +222,14 @@ def generate_command(ep, group_var, base_url_const, is_calling):
original_name = ep.get('original_name', cmd_name)
has_from = any(p['name'] == 'from' for p in query_params)

# Normalize orgId → orgid for CC commands so auto-populate in root.go works
# consistently (CC APIs use UUID format via the --orgid flag path).
# Normalize orgId → orgid so auto-populate in root.go resolves to UUID format.
# Applies to CC commands (is_calling=False) and to any endpoint under
# /contacts/organizations/ which explicitly requires UUID despite being in a
# non-CC collection (Postman note: "orgId used in path are the org UUIDs").
# For path params, rename both the flag and the param key (path uses {orgid}).
# For query params, only rename the flag; keep the original API key (orgId).
if not is_calling:
needs_uuid_orgid = not is_calling or '/contacts/organizations/' in path
if needs_uuid_orgid:
path = path.replace('{orgId}', '{orgid}')
for p in path_params:
if p['name'] == 'orgId':
Expand Down
55 changes: 45 additions & 10 deletions internal/client/client.go
Original file line number Diff line number Diff line change
Expand Up @@ -7,30 +7,65 @@ import (
"net/http"
urlpkg "net/url"
"os"
"strconv"
"strings"
"time"

"github.com/Cloverhound/webex-cli/internal/config"
)

// ErrDryRun is returned when a write operation is intercepted by --dry-run mode.
var ErrDryRun = errors.New("dry run: no changes made")

// Do executes an HTTP request. On a 401, it attempts to refresh the token and retry once.
// Do executes an HTTP request.
// On a 401 it refreshes the token and retries once.
// On a 429 it reads Retry-After and retries up to 3 times.
func Do(req *Request) ([]byte, int, error) {
body, status, err := doOnce(req)
body, status, headers, err := doOnce(req)

if status == 401 && config.TokenRefresher != nil {
newToken, refreshErr := config.TokenRefresher()
if refreshErr != nil {
return body, status, err
}
config.SetToken(newToken)
return doOnce(req)
body, status, headers, err = doOnce(req)
}

var cumulativeWait time.Duration
for retries := 0; status == 429; retries++ {
maxRetry := config.MaxRetry()
maxTimer := time.Duration(config.MaxRetryTimer()) * time.Second
wait := retryAfterDuration(headers.Get("Retry-After"))

if retries >= maxRetry {
return body, status, fmt.Errorf("rate limited (429): retry limit reached after %d attempts — try again after %s", retries, wait.Round(time.Second))
}
if maxTimer > 0 && cumulativeWait+wait > maxTimer {
return body, status, fmt.Errorf("rate limited (429): retry wait of %s would exceed max-retry-timer of %s — try again after %s", (cumulativeWait + wait).Round(time.Second), maxTimer.Round(time.Second), wait.Round(time.Second))
}

fmt.Fprintf(os.Stderr, "Rate limited (429). Retrying in %s (attempt %d/%d)...\n", wait.Round(time.Second), retries+1, maxRetry)
time.Sleep(wait)
cumulativeWait += wait
body, status, headers, err = doOnce(req)
}

return body, status, err
}

// retryAfterDuration parses the Retry-After header value (seconds integer) and
// returns the duration to wait. Defaults to 5 seconds if the header is absent
// or unparseable.
func retryAfterDuration(header string) time.Duration {
if secs, err := strconv.Atoi(strings.TrimSpace(header)); err == nil && secs > 0 {
return time.Duration(secs) * time.Second
}
return 5 * time.Second
}

// doOnce executes a single HTTP request without retry.
func doOnce(req *Request) ([]byte, int, error) {
func doOnce(req *Request) ([]byte, int, http.Header, error) {
// Build URL
url := req.baseURL + req.path
for k, v := range req.pathParams {
Expand Down Expand Up @@ -58,7 +93,7 @@ func doOnce(req *Request) ([]byte, int, error) {

httpReq, err := http.NewRequest(req.method, url, bodyReader)
if err != nil {
return nil, 0, fmt.Errorf("building request: %w", err)
return nil, 0, nil, fmt.Errorf("building request: %w", err)
}

// Auth
Expand Down Expand Up @@ -99,29 +134,29 @@ func doOnce(req *Request) ([]byte, int, error) {
if req.bodyRaw != "" {
fmt.Fprintf(os.Stderr, "[DRY RUN] Body: %s\n", req.bodyRaw)
}
return nil, 0, ErrDryRun
return nil, 0, nil, ErrDryRun
}

resp, err := http.DefaultClient.Do(httpReq)
if err != nil {
return nil, 0, fmt.Errorf("executing request: %w", err)
return nil, 0, nil, fmt.Errorf("executing request: %w", err)
}
defer resp.Body.Close()

body, err := io.ReadAll(resp.Body)
if err != nil {
return nil, resp.StatusCode, fmt.Errorf("reading response: %w", err)
return nil, resp.StatusCode, resp.Header, fmt.Errorf("reading response: %w", err)
}

if config.Debug() {
fmt.Fprintf(os.Stderr, "DEBUG: Response %d (%d bytes)\n", resp.StatusCode, len(body))
}

if resp.StatusCode >= 400 {
return body, resp.StatusCode, fmt.Errorf("API error %d: %s", resp.StatusCode, truncate(string(body), 500))
return body, resp.StatusCode, resp.Header, fmt.Errorf("API error %d: %s", resp.StatusCode, truncate(string(body), 500))
}

return body, resp.StatusCode, nil
return body, resp.StatusCode, resp.Header, nil
}

func truncate(s string, n int) string {
Expand Down
9 changes: 9 additions & 0 deletions internal/config/config.go
Original file line number Diff line number Diff line change
Expand Up @@ -13,11 +13,20 @@ var (
orgID string // UUID format
orgIDBase64 string // base64 Webex format
TokenRefresher func() (string, error)

maxRetry int // max number of 429 retries (0 = no retries)
maxRetryTimer int // max total seconds allowed across all 429 waits (0 = unlimited)
)

func SetToken(t string) { token = t }
func Token() string { return token }

func SetMaxRetry(n int) { maxRetry = n }
func MaxRetry() int { return maxRetry }

func SetMaxRetryTimer(secs int) { maxRetryTimer = secs }
func MaxRetryTimer() int { return maxRetryTimer }

func SetDebug(d bool) { debug = d }
func Debug() bool { return debug }

Expand Down
Loading
Loading