From 8883efad675f20821249162618f75f25ff2c2e2e Mon Sep 17 00:00:00 2001 From: mycollablab Date: Mon, 29 Jun 2026 09:36:05 -0400 Subject: [PATCH 1/4] Fix org-contacts: use UUID (not base64) for orgId path param MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The /contacts/organizations/{orgId}/* API requires UUID format, not the base64 format used by most other Admin endpoints. The codegen was sending base64 because the blanket --org-id → base64 rule in root.go applied to all non-CC commands indiscriminately. Fix: extend the orgId normalization condition in generate_cli.py to also apply UUID handling (--orgid flag, {orgid} path) for any endpoint whose path contains /contacts/organizations/, matching the explicit Postman note "orgId used in path are the org UUIDs." All 7 org-contacts commands now use --orgid and auto-receive a decoded UUID from root.go. Co-Authored-By: Claude Sonnet 4.6 --- cmd/admin/org_contacts.go | 70 +++++++++++++++++++-------------------- codegen/generate_cli.py | 9 +++-- 2 files changed, 41 insertions(+), 38 deletions(-) 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/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': From 1532cc8b3594fec57dd4e5bb4cdc801d196dd3be Mon Sep 17 00:00:00 2001 From: mycollablab Date: Mon, 29 Jun 2026 09:54:51 -0400 Subject: [PATCH 2/4] Add OrgID format troubleshooting note to skill Documents the CC=UUID vs admin/calling=base64 rule and tells the model to try the other format when an API returns a 400/404 org-related error. Co-Authored-By: Claude Sonnet 4.6 --- skill/SKILL.md | 14 ++++++++++++++ 1 file changed, 14 insertions(+) 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: From a3e1bea88bc449011f0fdc0ac77948f4db6e0302 Mon Sep 17 00:00:00 2001 From: mycollablab Date: Mon, 29 Jun 2026 10:17:05 -0400 Subject: [PATCH 3/4] Add 429 rate-limit retry with Retry-After backoff On a 429 response the client now reads the Retry-After header (seconds), sleeps that long, and retries up to 3 times. Falls back to 5 seconds if the header is absent or unparseable. Prints a message to stderr so the user knows why the command is pausing. Co-Authored-By: Claude Sonnet 4.6 --- internal/client/client.go | 43 ++++++++++++++++++++++++++++++--------- 1 file changed, 33 insertions(+), 10 deletions(-) diff --git a/internal/client/client.go b/internal/client/client.go index 549e8b0..d894175 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,43 @@ 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) + } + + for retries := 0; status == 429 && retries < 3; retries++ { + wait := retryAfterDuration(headers.Get("Retry-After")) + fmt.Fprintf(os.Stderr, "Rate limited (429). Retrying in %s...\n", wait.Round(time.Second)) + time.Sleep(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 +81,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 +122,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 +141,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 { From 84564e0cfb3b7b5745068c4ecc18550446910e9e Mon Sep 17 00:00:00 2001 From: mycollablab Date: Mon, 29 Jun 2026 10:27:51 -0400 Subject: [PATCH 4/4] Add --max-retry and --max-retry-timer global flags for 429 rate limiting Adds two new persistent flags to control retry behavior on HTTP 429 responses: - --max-retry (default 3): max number of retry attempts before giving up - --max-retry-timer (default 60s): max total seconds allowed across all retries If either limit would be exceeded before the next retry, the command exits immediately with a clear error message stating the throttle condition and how long to wait before retrying, rather than silently sleeping through it. Co-Authored-By: Claude Sonnet 4.6 --- cmd/root.go | 8 ++++++++ internal/client/client.go | 16 ++++++++++++++-- internal/config/config.go | 9 +++++++++ 3 files changed, 31 insertions(+), 2 deletions(-) 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/internal/client/client.go b/internal/client/client.go index d894175..205a875 100644 --- a/internal/client/client.go +++ b/internal/client/client.go @@ -32,10 +32,22 @@ func Do(req *Request) ([]byte, int, error) { body, status, headers, err = doOnce(req) } - for retries := 0; status == 429 && retries < 3; retries++ { + 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")) - fmt.Fprintf(os.Stderr, "Rate limited (429). Retrying in %s...\n", wait.Round(time.Second)) + + 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) } 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 }