[object Object]

← 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

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 →