← back to Cli Printing Press
feat(generator): retry logic, structured exit codes, and dry-run support
086f1d4584c13ca8097895ba7294d1b59efa3b5c · 2026-03-23 20:12:54 -0700 · Matt Van Horn
- Client retries on 5xx with exponential backoff (3 attempts)
- Client detects 429 rate limits and waits using Retry-After header
- APIError type carries HTTP status for structured exit codes
- Exit codes: 2=usage, 3=not-found, 4=auth, 5=api, 7=rate-limit, 10=config
- --dry-run flag shows method/URL/headers/body without sending
- classifyAPIError maps HTTP status to appropriate exit code
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Files touched
M internal/generator/templates/client.go.tmplM internal/generator/templates/command.go.tmplM internal/generator/templates/helpers.go.tmplM internal/generator/templates/root.go.tmpl
Diff
commit 086f1d4584c13ca8097895ba7294d1b59efa3b5c
Author: Matt Van Horn <455140+mvanhorn@users.noreply.github.com>
Date: Mon Mar 23 20:12:54 2026 -0700
feat(generator): retry logic, structured exit codes, and dry-run support
- Client retries on 5xx with exponential backoff (3 attempts)
- Client detects 429 rate limits and waits using Retry-After header
- APIError type carries HTTP status for structured exit codes
- Exit codes: 2=usage, 3=not-found, 4=auth, 5=api, 7=rate-limit, 10=config
- --dry-run flag shows method/URL/headers/body without sending
- classifyAPIError maps HTTP status to appropriate exit code
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
---
internal/generator/templates/client.go.tmpl | 162 ++++++++++++++++++++++-----
internal/generator/templates/command.go.tmpl | 2 +-
internal/generator/templates/helpers.go.tmpl | 35 +++++-
internal/generator/templates/root.go.tmpl | 6 +-
4 files changed, 172 insertions(+), 33 deletions(-)
diff --git a/internal/generator/templates/client.go.tmpl b/internal/generator/templates/client.go.tmpl
index 8c1f921c..8521e5ff 100644
--- a/internal/generator/templates/client.go.tmpl
+++ b/internal/generator/templates/client.go.tmpl
@@ -4,7 +4,10 @@ import (
"encoding/json"
"fmt"
"io"
+ "math"
"net/http"
+ "os"
+ "strconv"
"strings"
"time"
)
@@ -13,6 +16,19 @@ type Client struct {
BaseURL string
AuthHeader string
HTTPClient *http.Client
+ DryRun bool
+}
+
+// APIError carries HTTP status information for structured exit codes.
+type APIError struct {
+ Method string
+ Path string
+ StatusCode int
+ Body string
+}
+
+func (e *APIError) Error() string {
+ return fmt.Sprintf("%s %s returned HTTP %d: %s", e.Method, e.Path, e.StatusCode, e.Body)
}
func New(baseURL, authHeader string, timeout time.Duration) *Client {
@@ -46,54 +62,146 @@ func (c *Client) Patch(path string, body any) (json.RawMessage, error) {
func (c *Client) do(method, path string, params map[string]string, body any) (json.RawMessage, error) {
url := c.BaseURL + path
- var bodyReader io.Reader
+ var bodyBytes []byte
if body != nil {
b, err := json.Marshal(body)
if err != nil {
return nil, fmt.Errorf("marshaling body: %w", err)
}
- bodyReader = strings.NewReader(string(b))
+ bodyBytes = b
}
- req, err := http.NewRequest(method, url, bodyReader)
- if err != nil {
- return nil, fmt.Errorf("creating request: %w", err)
+ // Build the request for dry-run display or actual execution
+ if c.DryRun {
+ return c.dryRun(method, url, params, bodyBytes)
}
- if c.AuthHeader != "" {
- req.Header.Set("Authorization", c.AuthHeader)
- }
- if body != nil {
- req.Header.Set("Content-Type", "application/json")
+ const maxRetries = 3
+ var lastErr error
+
+ for attempt := 0; attempt <= maxRetries; attempt++ {
+ var bodyReader io.Reader
+ if bodyBytes != nil {
+ bodyReader = strings.NewReader(string(bodyBytes))
+ }
+
+ req, err := http.NewRequest(method, url, bodyReader)
+ if err != nil {
+ return nil, fmt.Errorf("creating request: %w", err)
+ }
+
+ if c.AuthHeader != "" {
+ req.Header.Set("Authorization", c.AuthHeader)
+ }
+ if bodyBytes != nil {
+ req.Header.Set("Content-Type", "application/json")
+ }
+ req.Header.Set("User-Agent", "{{.Name}}-cli/{{.Version}}")
+
+ if params != nil {
+ q := req.URL.Query()
+ for k, v := range params {
+ if v != "" {
+ q.Set(k, v)
+ }
+ }
+ req.URL.RawQuery = q.Encode()
+ }
+
+ resp, err := c.HTTPClient.Do(req)
+ if err != nil {
+ lastErr = fmt.Errorf("%s %s: %w", method, path, err)
+ continue
+ }
+
+ respBody, err := io.ReadAll(resp.Body)
+ resp.Body.Close()
+ if err != nil {
+ return nil, fmt.Errorf("reading response: %w", err)
+ }
+
+ // Success
+ if resp.StatusCode < 400 {
+ return json.RawMessage(respBody), nil
+ }
+
+ apiErr := &APIError{
+ Method: method,
+ Path: path,
+ StatusCode: resp.StatusCode,
+ Body: truncateBody(respBody),
+ }
+
+ // Rate limited - wait and retry
+ if resp.StatusCode == 429 && attempt < maxRetries {
+ wait := retryAfter(resp)
+ fmt.Fprintf(os.Stderr, "rate limited, waiting %s (attempt %d/%d)\n", wait, attempt+1, maxRetries)
+ time.Sleep(wait)
+ lastErr = apiErr
+ continue
+ }
+
+ // Server error - retry with backoff
+ if resp.StatusCode >= 500 && attempt < maxRetries {
+ wait := time.Duration(math.Pow(2, float64(attempt))) * time.Second
+ fmt.Fprintf(os.Stderr, "server error %d, retrying in %s (attempt %d/%d)\n", resp.StatusCode, wait, attempt+1, maxRetries)
+ time.Sleep(wait)
+ lastErr = apiErr
+ continue
+ }
+
+ // Client error or retries exhausted - return the error
+ return nil, apiErr
}
- req.Header.Set("User-Agent", "{{.Name}}-cli/{{.Version}}")
+ return nil, lastErr
+}
+
+func (c *Client) dryRun(method, url string, params map[string]string, body []byte) (json.RawMessage, error) {
+ fmt.Fprintf(os.Stderr, "%s %s\n", method, url)
if params != nil {
- q := req.URL.Query()
for k, v := range params {
if v != "" {
- q.Set(k, v)
+ fmt.Fprintf(os.Stderr, " ?%s=%s\n", k, v)
}
}
- req.URL.RawQuery = q.Encode()
}
-
- resp, err := c.HTTPClient.Do(req)
- if err != nil {
- return nil, fmt.Errorf("%s %s: %w", method, path, err)
+ if c.AuthHeader != "" {
+ // Mask token for safety
+ auth := c.AuthHeader
+ if len(auth) > 20 {
+ auth = auth[:15] + "..."
+ }
+ fmt.Fprintf(os.Stderr, " Authorization: %s\n", auth)
}
- defer resp.Body.Close()
-
- respBody, err := io.ReadAll(resp.Body)
- if err != nil {
- return nil, fmt.Errorf("reading response: %w", err)
+ if body != nil {
+ var pretty json.RawMessage
+ if json.Unmarshal(body, &pretty) == nil {
+ enc := json.NewEncoder(os.Stderr)
+ enc.SetIndent(" ", " ")
+ fmt.Fprintf(os.Stderr, " Body:\n")
+ enc.Encode(pretty)
+ }
}
+ fmt.Fprintf(os.Stderr, "\n(dry run - no request sent)\n")
+ return json.RawMessage(`{"dry_run": true}`), nil
+}
- if resp.StatusCode >= 400 {
- return nil, fmt.Errorf("%s %s returned HTTP %d: %s", method, path, resp.StatusCode, truncateBody(respBody))
+func retryAfter(resp *http.Response) time.Duration {
+ header := resp.Header.Get("Retry-After")
+ if header == "" {
+ return 5 * time.Second
}
-
- return json.RawMessage(respBody), nil
+ if seconds, err := strconv.Atoi(header); err == nil {
+ return time.Duration(seconds) * time.Second
+ }
+ if t, err := http.ParseTime(header); err == nil {
+ wait := time.Until(t)
+ if wait > 0 {
+ return wait
+ }
+ }
+ return 5 * time.Second
}
func truncateBody(b []byte) string {
diff --git a/internal/generator/templates/command.go.tmpl b/internal/generator/templates/command.go.tmpl
index 07d38c5e..cc30181c 100644
--- a/internal/generator/templates/command.go.tmpl
+++ b/internal/generator/templates/command.go.tmpl
@@ -92,7 +92,7 @@ func new{{camel $.FuncPrefix}}{{camel $eName}}Cmd(flags *rootFlags) *cobra.Comma
data, err := c.Patch(path, body)
{{- end}}
if err != nil {
- return apiErr(err)
+ return classifyAPIError(err)
}
return printOutput(cmd.OutOrStdout(), data, flags.asJSON)
diff --git a/internal/generator/templates/helpers.go.tmpl b/internal/generator/templates/helpers.go.tmpl
index 22fc7888..e1f6052a 100644
--- a/internal/generator/templates/helpers.go.tmpl
+++ b/internal/generator/templates/helpers.go.tmpl
@@ -20,10 +20,37 @@ type cliError struct {
func (e *cliError) Error() string { return e.err.Error() }
func (e *cliError) Unwrap() error { return e.err }
-func usageErr(err error) error { return &cliError{code: 2, err: err} }
-func configErr(err error) error { return &cliError{code: 3, err: err} }
-func authErr(err error) error { return &cliError{code: 4, err: err} }
-func apiErr(err error) error { return &cliError{code: 5, err: err} }
+func usageErr(err error) error { return &cliError{code: 2, err: err} }
+func notFoundErr(err error) error { return &cliError{code: 3, err: err} }
+func authErr(err error) error { return &cliError{code: 4, err: err} }
+func apiErr(err error) error { return &cliError{code: 5, err: err} }
+func configErr(err error) error { return &cliError{code: 10, err: err} }
+func rateLimitErr(err error) error { return &cliError{code: 7, err: err} }
+
+// classifyAPIError maps API errors to structured exit codes.
+func classifyAPIError(err error) error {
+ var apiError interface{ Error() string }
+ // Check if it has StatusCode field (client.APIError)
+ type statusCoder interface {
+ Error() string
+ }
+ type withStatus interface {
+ statusCoder
+ }
+ // Use reflection-free approach: check error message for HTTP status
+ msg := err.Error()
+ switch {
+ case strings.Contains(msg, "HTTP 401") || strings.Contains(msg, "HTTP 403"):
+ return authErr(err)
+ case strings.Contains(msg, "HTTP 404"):
+ return notFoundErr(err)
+ case strings.Contains(msg, "HTTP 429"):
+ return rateLimitErr(err)
+ default:
+ _ = apiError
+ return apiErr(err)
+ }
+}
func truncate(s string, max int) string {
if len(s) <= max {
diff --git a/internal/generator/templates/root.go.tmpl b/internal/generator/templates/root.go.tmpl
index ad3f58c1..55089ee7 100644
--- a/internal/generator/templates/root.go.tmpl
+++ b/internal/generator/templates/root.go.tmpl
@@ -17,6 +17,7 @@ type rootFlags struct {
asJSON bool
plain bool
quiet bool
+ dryRun bool
configPath string
timeout time.Duration
}
@@ -38,6 +39,7 @@ func Execute() error {
rootCmd.PersistentFlags().BoolVar(&flags.quiet, "quiet", false, "Bare output, one value per line")
rootCmd.PersistentFlags().StringVar(&flags.configPath, "config", "", "Config file path")
rootCmd.PersistentFlags().DurationVar(&flags.timeout, "timeout", 30*time.Second, "Request timeout")
+ rootCmd.PersistentFlags().BoolVar(&flags.dryRun, "dry-run", false, "Show request without sending")
{{- range $name, $resource := .Resources}}
rootCmd.AddCommand(new{{camel $name}}Cmd(&flags)) {{/* FuncPrefix matches resource name for top-level */}}
@@ -61,7 +63,9 @@ func (f *rootFlags) newClient() (*client.Client, error) {
if err != nil {
return nil, configErr(err)
}
- return client.New(cfg.BaseURL, cfg.AuthHeader(), f.timeout), nil
+ c := client.New(cfg.BaseURL, cfg.AuthHeader(), f.timeout)
+ c.DryRun = f.dryRun
+ return c, nil
}
func (f *rootFlags) printJSON(w *cobra.Command, v any) error {
← c5618c08 feat(generator): auto-detect array responses and render as f
·
back to Cli Printing Press
·
feat(generator): auto-detect pagination and generate --limit 332fe595 →