diff --git a/cmd/admin/org_contacts.go b/cmd/admin/org_contacts.go index e87ff06..67b52bd 100644 --- a/cmd/admin/org_contacts.go +++ b/cmd/admin/org_contacts.go @@ -28,7 +28,7 @@ func init() { cmd.AdminCmd.AddCommand(orgContactsCmd) { // create - var orgId string + var orgid string var bodyRaw string var bodyFile string cmd := &cobra.Command{ @@ -36,8 +36,8 @@ func init() { Short: "Create a Contact", Long: "Creating a new contact for a given organization requires an org admin role.\n\nAt least one of the following body parameters: `phoneNumbers`, `emails`, `sipAddresses` is required to create a new contact for source \"CH\",\n`displayName` is required to create a new contact for source \"Webex4Broadworks\".\n\nUse the optional `groupIds` field to add group IDs in an array within the organisation contact. This will become a group contact.", RunE: func(cmd *cobra.Command, args []string) error { - req := client.NewRequest(config.CallingBaseURL, "POST", "/contacts/organizations/{orgId}/contacts") - req.PathParam("orgId", orgId) + req := client.NewRequest(config.CallingBaseURL, "POST", "/contacts/organizations/{orgid}/contacts") + req.PathParam("orgid", orgid) if bodyFile != "" { if err := req.SetBodyFile(bodyFile); err != nil { return err @@ -52,23 +52,23 @@ func init() { return output.Print(resp, statusCode) }, } - cmd.Flags().StringVar(&orgId, "org-id", "", "Webex Identity assigned organization identifier for the user's organization or the organization he manages.") - cmd.MarkFlagRequired("org-id") + cmd.Flags().StringVar(&orgid, "orgid", "", "Webex Identity assigned organization identifier for the user's organization or the organization he manages.") + cmd.MarkFlagRequired("orgid") cmd.Flags().StringVar(&bodyRaw, "body", "", "Raw JSON body") cmd.Flags().StringVar(&bodyFile, "body-file", "", "Path to JSON body file") orgContactsCmd.AddCommand(cmd) } { // get - var orgId string + var orgid string var contactId string cmd := &cobra.Command{ Use: "get", Short: "Get a Contact", Long: "Shows details for an organization contact by ID.\nSpecify the organization ID in the `orgId` parameter in the URI, and specify the contact ID in the `contactId` parameter in the URI.\n\n**NOTE**:\nThe `orgId` used in the path for this API are the org UUIDs. They follow a xxxx-xxxx-xxxx-xxxx pattern. If you have an orgId in base64 encoded format (starting with Y2.....) you need to base64 decode the id and extract the UUID from the slug, before you use it in your API call.", RunE: func(cmd *cobra.Command, args []string) error { - req := client.NewRequest(config.CallingBaseURL, "GET", "/contacts/organizations/{orgId}/contacts/{contactId}") - req.PathParam("orgId", orgId) + req := client.NewRequest(config.CallingBaseURL, "GET", "/contacts/organizations/{orgid}/contacts/{contactId}") + req.PathParam("orgid", orgid) req.PathParam("contactId", contactId) if config.Paginate() { resp, statusCode, err := req.DoPaginated(true) @@ -84,15 +84,15 @@ func init() { return output.Print(resp, statusCode) }, } - cmd.Flags().StringVar(&orgId, "org-id", "", "Webex Identity assigned organization identifier for the user's organization or the organization he manages.") - cmd.MarkFlagRequired("org-id") + cmd.Flags().StringVar(&orgid, "orgid", "", "Webex Identity assigned organization identifier for the user's organization or the organization he manages.") + cmd.MarkFlagRequired("orgid") cmd.Flags().StringVar(&contactId, "contact-id", "", "The contact ID.") cmd.MarkFlagRequired("contact-id") orgContactsCmd.AddCommand(cmd) } { // update - var orgId string + var orgid string var contactId string var bodyRaw string var bodyFile string @@ -101,8 +101,8 @@ func init() { Short: "Update a Contact", Long: "Update details for contact by ID. Only an admin can update a contact.\nSpecify the organization ID in the `orgId` parameter in the URI, and specify the contact ID in the `contactId` parameter in the URI.\n\nUse the optional `groupIds` field to update the group IDs by changing the existing array. You can add or remove one or all groups. To remove all associated groups, pass an empty array in the `groupIds` field.", RunE: func(cmd *cobra.Command, args []string) error { - req := client.NewRequest(config.CallingBaseURL, "PATCH", "/contacts/organizations/{orgId}/contacts/{contactId}") - req.PathParam("orgId", orgId) + req := client.NewRequest(config.CallingBaseURL, "PATCH", "/contacts/organizations/{orgid}/contacts/{contactId}") + req.PathParam("orgid", orgid) req.PathParam("contactId", contactId) if bodyFile != "" { if err := req.SetBodyFile(bodyFile); err != nil { @@ -118,8 +118,8 @@ func init() { return output.Print(resp, statusCode) }, } - cmd.Flags().StringVar(&orgId, "org-id", "", "Webex Identity assigned organization identifier for the user's organization or the organization he manages.") - cmd.MarkFlagRequired("org-id") + cmd.Flags().StringVar(&orgid, "orgid", "", "Webex Identity assigned organization identifier for the user's organization or the organization he manages.") + cmd.MarkFlagRequired("orgid") cmd.Flags().StringVar(&contactId, "contact-id", "", "The contact ID.") cmd.MarkFlagRequired("contact-id") cmd.Flags().StringVar(&bodyRaw, "body", "", "Raw JSON body") @@ -128,15 +128,15 @@ func init() { } { // delete - var orgId string + var orgid string var contactId string cmd := &cobra.Command{ Use: "delete", Short: "Delete a Contact", Long: "Remove a contact from the organization. Only an admin can remove a contact.\n\nSpecify the organization ID in the `orgId` parameter in the URI, and specify the contact ID in the `contactId` parameter in the URI.", RunE: func(cmd *cobra.Command, args []string) error { - req := client.NewRequest(config.CallingBaseURL, "DELETE", "/contacts/organizations/{orgId}/contacts/{contactId}") - req.PathParam("orgId", orgId) + req := client.NewRequest(config.CallingBaseURL, "DELETE", "/contacts/organizations/{orgid}/contacts/{contactId}") + req.PathParam("orgid", orgid) req.PathParam("contactId", contactId) resp, statusCode, err := req.Do() if err != nil { @@ -145,15 +145,15 @@ func init() { return output.Print(resp, statusCode) }, } - cmd.Flags().StringVar(&orgId, "org-id", "", "Webex Identity assigned organization identifier for the user's organization or the organization he manages.") - cmd.MarkFlagRequired("org-id") + cmd.Flags().StringVar(&orgid, "orgid", "", "Webex Identity assigned organization identifier for the user's organization or the organization he manages.") + cmd.MarkFlagRequired("orgid") cmd.Flags().StringVar(&contactId, "contact-id", "", "The contact ID.") cmd.MarkFlagRequired("contact-id") orgContactsCmd.AddCommand(cmd) } { // list - var orgId string + var orgid string var keyword string var source string var limit string @@ -163,8 +163,8 @@ func init() { Short: "List Contacts", Long: "List contacts in the organization. The default limit is `100`.\n\n`keyword` can be the value of \"displayName\", \"firstName\", \"lastName\", \"email\". An empty string of `keyword` means get all contacts.\n\n`groupIds` is a comma separated list group IDs. Results are filtered based on those group IDs.\n\nLong result sets will be split into [pages](/docs/basics#pagination).", RunE: func(cmd *cobra.Command, args []string) error { - req := client.NewRequest(config.CallingBaseURL, "GET", "/contacts/organizations/{orgId}/contacts/search") - req.PathParam("orgId", orgId) + req := client.NewRequest(config.CallingBaseURL, "GET", "/contacts/organizations/{orgid}/contacts/search") + req.PathParam("orgid", orgid) req.QueryParam("keyword", keyword) req.QueryParam("source", source) req.QueryParam("limit", limit) @@ -184,8 +184,8 @@ func init() { return output.Print(resp, statusCode) }, } - cmd.Flags().StringVar(&orgId, "org-id", "", "The organization ID.") - cmd.MarkFlagRequired("org-id") + cmd.Flags().StringVar(&orgid, "orgid", "", "The organization ID.") + cmd.MarkFlagRequired("orgid") cmd.Flags().StringVar(&keyword, "keyword", "", "List contacts with a keyword.") cmd.Flags().StringVar(&source, "source", "", "List contacts with source.") cmd.Flags().StringVar(&limit, "limit", "", "Limit the maximum number of contact in the response. + Default: 100 ") @@ -194,7 +194,7 @@ func init() { } { // bulk-create-update - var orgId string + var orgid string var bodyRaw string var bodyFile string cmd := &cobra.Command{ @@ -202,8 +202,8 @@ func init() { Short: "Bulk Create or Update Contacts", Long: "Create or update contacts in bulk. Update an existing contact by specifying the contact ID in the `contactId` parameter in the request body.", RunE: func(cmd *cobra.Command, args []string) error { - req := client.NewRequest(config.CallingBaseURL, "POST", "/contacts/organizations/{orgId}/contacts/bulk") - req.PathParam("orgId", orgId) + req := client.NewRequest(config.CallingBaseURL, "POST", "/contacts/organizations/{orgid}/contacts/bulk") + req.PathParam("orgid", orgid) if bodyFile != "" { if err := req.SetBodyFile(bodyFile); err != nil { return err @@ -218,15 +218,15 @@ func init() { return output.Print(resp, statusCode) }, } - cmd.Flags().StringVar(&orgId, "org-id", "", "Webex Identity assigned organization identifier for the user's organization or the organization he manages.") - cmd.MarkFlagRequired("org-id") + cmd.Flags().StringVar(&orgid, "orgid", "", "Webex Identity assigned organization identifier for the user's organization or the organization he manages.") + cmd.MarkFlagRequired("orgid") cmd.Flags().StringVar(&bodyRaw, "body", "", "Raw JSON body") cmd.Flags().StringVar(&bodyFile, "body-file", "", "Path to JSON body file") orgContactsCmd.AddCommand(cmd) } { // bulk-delete - var orgId string + var orgid string var schemas string var objectIds []string var bodyRaw string @@ -236,8 +236,8 @@ func init() { Short: "Bulk Delete Contacts", Long: `Delete contacts in bulk.`, RunE: func(cmd *cobra.Command, args []string) error { - req := client.NewRequest(config.CallingBaseURL, "POST", "/contacts/organizations/{orgId}/contacts/bulk/delete") - req.PathParam("orgId", orgId) + req := client.NewRequest(config.CallingBaseURL, "POST", "/contacts/organizations/{orgid}/contacts/bulk/delete") + req.PathParam("orgid", orgid) if bodyFile != "" { if err := req.SetBodyFile(bodyFile); err != nil { return err @@ -255,8 +255,8 @@ func init() { return output.Print(resp, statusCode) }, } - cmd.Flags().StringVar(&orgId, "org-id", "", "Webex Identity assigned organization identifier for the user's organization or the organization he manages.") - cmd.MarkFlagRequired("org-id") + cmd.Flags().StringVar(&orgid, "orgid", "", "Webex Identity assigned organization identifier for the user's organization or the organization he manages.") + cmd.MarkFlagRequired("orgid") cmd.Flags().StringVar(&schemas, "schemas", "", "") cmd.Flags().StringSliceVar(&objectIds, "object-ids", nil, "") cmd.Flags().StringVar(&bodyRaw, "body", "", "Raw JSON body") diff --git a/cmd/root.go b/cmd/root.go index cbf0297..f1cbc70 100644 --- a/cmd/root.go +++ b/cmd/root.go @@ -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 { @@ -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. diff --git a/codegen/generate_cli.py b/codegen/generate_cli.py index 4775d29..70eb8ad 100644 --- a/codegen/generate_cli.py +++ b/codegen/generate_cli.py @@ -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': diff --git a/internal/client/client.go b/internal/client/client.go index 549e8b0..205a875 100644 --- a/internal/client/client.go +++ b/internal/client/client.go @@ -7,7 +7,9 @@ import ( "net/http" urlpkg "net/url" "os" + "strconv" "strings" + "time" "github.com/Cloverhound/webex-cli/internal/config" ) @@ -15,22 +17,55 @@ import ( // 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 { @@ -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 @@ -99,18 +134,18 @@ 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() { @@ -118,10 +153,10 @@ func doOnce(req *Request) ([]byte, int, error) { } 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 { diff --git a/internal/config/config.go b/internal/config/config.go index 25d018d..3a6aff8 100644 --- a/internal/config/config.go +++ b/internal/config/config.go @@ -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 } diff --git a/skill/SKILL.md b/skill/SKILL.md index e116916..e8868dd 100644 --- a/skill/SKILL.md +++ b/skill/SKILL.md @@ -76,6 +76,20 @@ webex cc site list --orgid="Y2lzY29zcGFyazovL3Vz..." # Base64 — auto-decoded If you need to override the org, use `--organization ` (global flag) which feeds into `--orgid` automatically. Both base64 and UUID formats are accepted. +## OrgID Format Troubleshooting + +**General rule:** CC commands (`webex cc ...`) use UUID format; all other commands (`webex admin`, `webex calling`, etc.) use base64 format. The CLI normalizes automatically, but if an API call returns a 400 or 404 with an org-related error, the format passed may be wrong for that endpoint. + +**If you get an org ID error, try the other format:** +```bash +# Got a 400 on a CC command? Make sure the value decodes to a plain UUID: +webex cc site list --orgid="4ebc486d-ff5f-4abc-9d44-1234567890ab" # UUID +# Got a 400 on an admin/calling command? Make sure it's base64: +webex admin people list --organization="Y2lzY29zcGFyazovL3Vz..." # Base64 +``` + +You can inspect what the CLI resolved to by running `webex auth status` — the org ID shown there is the base64 form. Strip the trailing `=` padding issues or swap formats if calls are failing with 400/404 on org-scoped endpoints. + ## CC Subcommand Names Most CC resources use a consistent `list` subcommand. A few exceptions remain: