← back to Cli Printing Press
fix(cli): detect partial-failure response shape in :mutate handlers, add --allow-partial-failure flag (#1360)
875d64e9c238f937223ae8eadb6ba0f76c20d0a3 · 2026-05-17 01:54:35 -0400 · tallyberner
* fix(cli): detect partial-failure response shape in mutate handlers
Add --allow-partial-failure flag. Mutate envelope's success now depends
on both HTTP status and absence of body-level partial-failure (e.g.
Google Ads partialFailureError). Generic detector lives in helpers.go,
not coupled to one API: any 2xx body with a top-level
partialFailureError carrying a non-zero code or non-empty message is
flagged, results[].resourceName is extracted, and the typed exit
(code 6, distinct from apiErr's code 5) fires unless
--allow-partial-failure downgrades it to a stderr warning.
Detection runs once per mutate call before output-mode selection so
table, JSON envelope, --quiet, and raw paths all surface the same
exit code. partialFailureReport is emitted into the envelope under
partial_failure with field/message/code/details/resource_names so
agents can route per-operation remediation.
Runtime test in generator_test.go drops a *_test.go into a generated
Petstore CLI module and runs go test against the emitted
detectPartialFailure; this is the load-bearing safeguard that proves
the detector works on a Google-Ads-shaped body, not just that the
strings are emitted.
* fix(cli): enforce partial-failure exit on mutate raw/quiet output path
Codex review flagged a gap: when neither the wantsHumanTable nor the
asJSON/piped branch handled a mutate response, control fell through to
printOutputWithFlags without checking partialFailure. That meant
--quiet, --csv, --plain, and default terminal raw output would exit 0
on a detected partial failure even though the warning was printed to
stderr — same silent-swallow regression the patch is supposed to be
preventing for JSON consumers.
Wrap the fall-through call so the typed code-6 exit fires unless
--allow-partial-failure is set, matching the table and JSON paths.
* test(cli): update golden fixtures for partial-failure detection
Updated 18 golden cases to reflect the new emitted code:
- helpers.go gains detectPartialFailure + partialFailureErr +
partialFailureReport.
- root.go gains the --allow-partial-failure persistent flag and the
rootFlags.allowPartialFailure field.
- mutate command handlers (POST/PUT/PATCH/DELETE) gain the detection
block at the top of the response handling, partialFailure-aware
envelope serialization, and the fall-through partial-failure exit
guard so --quiet/--csv/--plain/raw output paths surface code 6 too.
Behaviour change is intentional and matches the patch in the prior two
commits.
* fix(cli): include allowPartialFailure in JSON envelope success
When --allow-partial-failure is set and a partial failure is detected, the
process exits 0 (the user opted in to allow it) but the JSON envelope was
still emitting success: false. Automated callers parsing the envelope to
decide whether to retry or alert would see a failure signal on an
intentionally-allowed partial result, disagreeing with the exit code.
Align the envelope's success expression with the exit-code semantics:
treat the request as successful when the status is 2xx AND there is no
partial failure OR the caller passed --allow-partial-failure. Regenerated
goldens mirror the template fix in every affected case.
---------
Co-authored-by: dior <agents@futureremodeling.com>
Co-authored-by: Trevin Chow <trevin@trevinchow.com>
Files touched
M internal/generator/generator_test.goM internal/generator/templates/command_endpoint.go.tmplM internal/generator/templates/helpers.go.tmplM internal/generator/templates/root.go.tmplM testdata/golden/expected/generate-embedded-paged-api/embedded-paged-api/internal/cli/helpers.goM testdata/golden/expected/generate-golden-api-rich-auth/printing-press-rich-auth/internal/cli/helpers.goM testdata/golden/expected/generate-golden-api/dogfood.jsonM testdata/golden/expected/generate-golden-api/printing-press-golden/internal/cli/helpers.goM testdata/golden/expected/generate-golden-api/printing-press-golden/internal/cli/projects_avatar_upload-project.goM testdata/golden/expected/generate-golden-api/printing-press-golden/internal/cli/projects_create.goM testdata/golden/expected/generate-golden-api/printing-press-golden/internal/cli/projects_tasks_update-project.goM testdata/golden/expected/generate-golden-api/printing-press-golden/internal/cli/root.goM testdata/golden/expected/generate-graphql-shared-endpoint/graphql-shared-golden/internal/cli/things_get.goM testdata/golden/expected/generate-graphql-shared-endpoint/graphql-shared-golden/internal/cli/things_list.goM testdata/golden/expected/generate-public-param-names/public-param-golden/internal/cli/stores_create.go
Diff
commit 875d64e9c238f937223ae8eadb6ba0f76c20d0a3
Author: tallyberner <127001603+tallyberner@users.noreply.github.com>
Date: Sun May 17 01:54:35 2026 -0400
fix(cli): detect partial-failure response shape in :mutate handlers, add --allow-partial-failure flag (#1360)
* fix(cli): detect partial-failure response shape in mutate handlers
Add --allow-partial-failure flag. Mutate envelope's success now depends
on both HTTP status and absence of body-level partial-failure (e.g.
Google Ads partialFailureError). Generic detector lives in helpers.go,
not coupled to one API: any 2xx body with a top-level
partialFailureError carrying a non-zero code or non-empty message is
flagged, results[].resourceName is extracted, and the typed exit
(code 6, distinct from apiErr's code 5) fires unless
--allow-partial-failure downgrades it to a stderr warning.
Detection runs once per mutate call before output-mode selection so
table, JSON envelope, --quiet, and raw paths all surface the same
exit code. partialFailureReport is emitted into the envelope under
partial_failure with field/message/code/details/resource_names so
agents can route per-operation remediation.
Runtime test in generator_test.go drops a *_test.go into a generated
Petstore CLI module and runs go test against the emitted
detectPartialFailure; this is the load-bearing safeguard that proves
the detector works on a Google-Ads-shaped body, not just that the
strings are emitted.
* fix(cli): enforce partial-failure exit on mutate raw/quiet output path
Codex review flagged a gap: when neither the wantsHumanTable nor the
asJSON/piped branch handled a mutate response, control fell through to
printOutputWithFlags without checking partialFailure. That meant
--quiet, --csv, --plain, and default terminal raw output would exit 0
on a detected partial failure even though the warning was printed to
stderr — same silent-swallow regression the patch is supposed to be
preventing for JSON consumers.
Wrap the fall-through call so the typed code-6 exit fires unless
--allow-partial-failure is set, matching the table and JSON paths.
* test(cli): update golden fixtures for partial-failure detection
Updated 18 golden cases to reflect the new emitted code:
- helpers.go gains detectPartialFailure + partialFailureErr +
partialFailureReport.
- root.go gains the --allow-partial-failure persistent flag and the
rootFlags.allowPartialFailure field.
- mutate command handlers (POST/PUT/PATCH/DELETE) gain the detection
block at the top of the response handling, partialFailure-aware
envelope serialization, and the fall-through partial-failure exit
guard so --quiet/--csv/--plain/raw output paths surface code 6 too.
Behaviour change is intentional and matches the patch in the prior two
commits.
* fix(cli): include allowPartialFailure in JSON envelope success
When --allow-partial-failure is set and a partial failure is detected, the
process exits 0 (the user opted in to allow it) but the JSON envelope was
still emitting success: false. Automated callers parsing the envelope to
decide whether to retry or alert would see a failure signal on an
intentionally-allowed partial result, disagreeing with the exit code.
Align the envelope's success expression with the exit-code semantics:
treat the request as successful when the status is 2xx AND there is no
partial failure OR the caller passed --allow-partial-failure. Regenerated
goldens mirror the template fix in every affected case.
---------
Co-authored-by: dior <agents@futureremodeling.com>
Co-authored-by: Trevin Chow <trevin@trevinchow.com>
---
internal/generator/generator_test.go | 147 ++++++++++++++++++++-
.../generator/templates/command_endpoint.go.tmpl | 57 +++++++-
internal/generator/templates/helpers.go.tmpl | 75 +++++++++++
internal/generator/templates/root.go.tmpl | 6 +
.../embedded-paged-api/internal/cli/helpers.go | 75 +++++++++++
.../internal/cli/helpers.go | 75 +++++++++++
.../expected/generate-golden-api/dogfood.json | 4 +-
.../printing-press-golden/internal/cli/helpers.go | 75 +++++++++++
.../internal/cli/projects_avatar_upload-project.go | 55 +++++++-
.../internal/cli/projects_create.go | 55 +++++++-
.../internal/cli/projects_tasks_update-project.go | 55 +++++++-
.../printing-press-golden/internal/cli/root.go | 44 +++---
.../internal/cli/things_get.go | 55 +++++++-
.../internal/cli/things_list.go | 55 +++++++-
.../internal/cli/stores_create.go | 55 +++++++-
15 files changed, 846 insertions(+), 42 deletions(-)
diff --git a/internal/generator/generator_test.go b/internal/generator/generator_test.go
index e669ef5b..731bfabb 100644
--- a/internal/generator/generator_test.go
+++ b/internal/generator/generator_test.go
@@ -4368,7 +4368,38 @@ func TestGeneratedOutput_MutatingCommandsHaveEnvelope(t *testing.T) {
assert.Contains(t, content, `"action":`)
assert.Contains(t, content, `"resource":`)
assert.Contains(t, content, `"status": statusCode`)
- assert.Contains(t, content, `"success": statusCode >= 200 && statusCode < 300`)
+ // Mutate envelope's success depends on HTTP status AND the absence of a
+ // body-level partial-failure (e.g. Google Ads partialFailureError), unless
+ // the caller opted in via --allow-partial-failure (flags.allowPartialFailure)
+ // in which case the envelope agrees with the exit-code 0.
+ assert.Contains(t, content, `"success": statusCode >= 200 && statusCode < 300 && (partialFailure == nil || flags.allowPartialFailure)`)
+ // Detection runs before any output-mode branch so the exit code is
+ // consistent across table, JSON envelope, and raw output paths.
+ assert.Contains(t, content, `detectPartialFailure(data)`)
+ // --allow-partial-failure downgrades the typed exit (code 6) to a warning.
+ assert.Contains(t, content, `flags.allowPartialFailure`)
+ assert.Contains(t, content, `partialFailureErr(`)
+
+ // The generated helpers.go must carry the detection helper, the
+ // typed-exit constructor, and the structured report shape. These are
+ // what makes the partial-failure path machine-readable; if any goes
+ // missing on regen the silent-swallow regression returns.
+ helpersGo, err := os.ReadFile(filepath.Join(outputDir, "internal", "cli", "helpers.go"))
+ require.NoError(t, err)
+ helpersContent := string(helpersGo)
+ assert.Contains(t, helpersContent, `func detectPartialFailure(`)
+ assert.Contains(t, helpersContent, `func partialFailureErr(`)
+ assert.Contains(t, helpersContent, `type partialFailureReport struct`)
+ assert.Contains(t, helpersContent, `&cliError{code: 6, err: err}`)
+
+ // Root.go must expose the --allow-partial-failure persistent flag and
+ // the matching rootFlags field.
+ rootGo, err := os.ReadFile(filepath.Join(outputDir, "internal", "cli", "root.go"))
+ require.NoError(t, err)
+ rootContent := string(rootGo)
+ assert.Contains(t, rootContent, `allow-partial-failure`)
+ assert.Contains(t, rootContent, `allowPartialFailure bool`)
+
// Envelope fires on --json and on piped output, but explicit format flags
// (--csv, --quiet, --plain) opt out of the auto-JSON path so piped agents
// that asked for a non-JSON format actually get it.
@@ -4393,6 +4424,120 @@ func TestGeneratedOutput_MutatingCommandsHaveEnvelope(t *testing.T) {
assert.Contains(t, content, `envelope["success"] = false`)
}
+// TestGeneratedOutput_PartialFailureDetectionRuntime drops a runtime
+// test into the generated CLI module and executes go test against the
+// emitted detectPartialFailure helper. This is the load-bearing
+// safeguard: the per-string assertions above prove the strings are
+// emitted, but only running the generated code proves the detector
+// actually identifies a Google-Ads-style partialFailureError body,
+// extracts results[].resourceName, and returns nil on a clean 2xx.
+//
+// The fixture mirrors the shape the Google Ads API returns for a
+// `campaigns:mutate` call where partial_failure=true and at least one
+// operation failed.
+func TestGeneratedOutput_PartialFailureDetectionRuntime(t *testing.T) {
+ t.Parallel()
+
+ outputDir := generatePetstore(t)
+
+ // Place the runtime test alongside helpers.go in the generated
+ // internal/cli package so it can call the unexported detector.
+ runtimeTest := `package cli
+
+import (
+ "encoding/json"
+ "testing"
+)
+
+func TestDetectPartialFailure_GoogleAdsBatchShape(t *testing.T) {
+ body := []byte(` + "`" + `{
+ "results": [
+ {"resourceName": "customers/123/campaigns/1"},
+ {"resourceName": "customers/123/campaigns/2"}
+ ],
+ "partialFailureError": {
+ "code": 3,
+ "message": "Some operations failed",
+ "details": [
+ {"errors": [{"errorCode": {"campaignError": "DUPLICATE_NAME"}}]}
+ ]
+ }
+ }` + "`" + `)
+ got := detectPartialFailure(body)
+ if got == nil {
+ t.Fatalf("expected partialFailureReport, got nil")
+ }
+ if got.Field != "partialFailureError" {
+ t.Errorf("Field = %q, want %q", got.Field, "partialFailureError")
+ }
+ if got.Code != 3 {
+ t.Errorf("Code = %d, want 3", got.Code)
+ }
+ if got.Message != "Some operations failed" {
+ t.Errorf("Message = %q, want %q", got.Message, "Some operations failed")
+ }
+ if len(got.ResourceNames) != 2 {
+ t.Fatalf("ResourceNames length = %d, want 2", len(got.ResourceNames))
+ }
+ if got.ResourceNames[0] != "customers/123/campaigns/1" {
+ t.Errorf("ResourceNames[0] = %q", got.ResourceNames[0])
+ }
+ // Report must round-trip to the envelope shape. Marshalling here
+ // asserts the json tags match what mutate-envelope consumers expect.
+ out, err := json.Marshal(got)
+ if err != nil {
+ t.Fatalf("json.Marshal(report): %v", err)
+ }
+ var roundTrip map[string]any
+ if err := json.Unmarshal(out, &roundTrip); err != nil {
+ t.Fatalf("json.Unmarshal(report): %v", err)
+ }
+ if roundTrip["field"] != "partialFailureError" {
+ t.Errorf("round-trip field = %v", roundTrip["field"])
+ }
+ if rn, _ := roundTrip["resource_names"].([]any); len(rn) != 2 {
+ t.Errorf("round-trip resource_names = %v", roundTrip["resource_names"])
+ }
+}
+
+func TestDetectPartialFailure_CleanResponseReturnsNil(t *testing.T) {
+ body := []byte(` + "`" + `{"results": [{"resourceName": "customers/123/campaigns/1"}]}` + "`" + `)
+ if got := detectPartialFailure(body); got != nil {
+ t.Errorf("expected nil for clean response, got %+v", got)
+ }
+}
+
+func TestDetectPartialFailure_EmptyErrorObjectIgnored(t *testing.T) {
+ // Some servers return an empty partialFailureError{} when
+ // partial_failure mode was not enabled. Empty code + empty message
+ // must not flag a partial failure or callers exit non-zero on
+ // every batch.
+ body := []byte(` + "`" + `{"results": [], "partialFailureError": {}}` + "`" + `)
+ if got := detectPartialFailure(body); got != nil {
+ t.Errorf("expected nil for empty partialFailureError, got %+v", got)
+ }
+}
+
+func TestDetectPartialFailure_PartialFailureErrTypedExitCode(t *testing.T) {
+ // partialFailureErr must wrap with the typed exit code (6) so the
+ // CLI's ExitCode() emits a distinguishable code; HTTP failures use
+ // apiErr (5), so 6 stays reserved for partial-failure semantics.
+ err := partialFailureErr(&jsonSyntaxStub{msg: "x"})
+ if got := ExitCode(err); got != 6 {
+ t.Errorf("ExitCode = %d, want 6", got)
+ }
+}
+
+type jsonSyntaxStub struct{ msg string }
+
+func (e *jsonSyntaxStub) Error() string { return e.msg }
+`
+ testPath := filepath.Join(outputDir, "internal", "cli", "partialfailure_runtime_test.go")
+ require.NoError(t, os.WriteFile(testPath, []byte(runtimeTest), 0o644))
+
+ runGoCommand(t, outputDir, "test", "-run", "TestDetectPartialFailure", "./internal/cli")
+}
+
// TestCompactListFieldsPreservesUnknownShapes pins the contracts that keep
// `--agent` / `--compact` from silently emitting {} or partial-strip rows
// for records that don't match the canonical id/name/title allowlist:
diff --git a/internal/generator/templates/command_endpoint.go.tmpl b/internal/generator/templates/command_endpoint.go.tmpl
index 8ed96435..17ea512b 100644
--- a/internal/generator/templates/command_endpoint.go.tmpl
+++ b/internal/generator/templates/command_endpoint.go.tmpl
@@ -523,6 +523,24 @@ func new{{camel .FuncPrefix}}{{camel .EndpointName}}Cmd(flags *rootFlags) *cobra
{{- end}}
{{- if not (or (eq .Endpoint.Method "GET") (eq .Endpoint.Method "HEAD"))}}
+ // Inspect the mutate response body for a partial-failure-shaped
+ // field (e.g. Google Ads `partialFailureError`). Several Google
+ // APIs return 200 OK with a partial-failure field when some
+ // operations in the batch failed; ignoring it silently swallows
+ // real failures. Detection runs before output-mode selection so
+ // the exit code is consistent regardless of how stdout is
+ // rendered. --dry-run short-circuits because no real request
+ // was sent.
+ var partialFailure *partialFailureReport
+ if !flags.dryRun && statusCode >= 200 && statusCode < 300 {
+ partialFailure = detectPartialFailure(data)
+ if partialFailure != nil {
+ fmt.Fprintf(os.Stderr, "warning: partial failure detected in %s response: %s\n", "{{.ResourceName}}", partialFailure.Message)
+ if len(partialFailure.ResourceNames) > 0 {
+ fmt.Fprintf(os.Stderr, " succeeded: %d operation(s)\n", len(partialFailure.ResourceNames))
+ }
+ }
+ }
if wantsHumanTable(cmd.OutOrStdout(), flags) {
// Check if response contains an array (directly or wrapped in "data")
var items []map[string]any
@@ -530,6 +548,9 @@ func new{{camel .FuncPrefix}}{{camel .EndpointName}}Cmd(flags *rootFlags) *cobra
if err := printAutoTable(cmd.OutOrStdout(), items); err != nil {
fmt.Fprintf(os.Stderr, "warning: table rendering failed, falling back to JSON: %v\n", err)
} else {
+ if partialFailure != nil && !flags.allowPartialFailure {
+ return partialFailureErr(fmt.Errorf("partial failure in %s response: %s", "{{.ResourceName}}", partialFailure.Message))
+ }
return nil
}
} else {
@@ -538,6 +559,9 @@ func new{{camel .FuncPrefix}}{{camel .EndpointName}}Cmd(flags *rootFlags) *cobra
if err := printAutoTable(cmd.OutOrStdout(), wrapped.Data); err != nil {
fmt.Fprintf(os.Stderr, "warning: table rendering failed, falling back to JSON: %v\n", err)
} else {
+ if partialFailure != nil && !flags.allowPartialFailure {
+ return partialFailureErr(fmt.Errorf("partial failure in %s response: %s", "{{.ResourceName}}", partialFailure.Message))
+ }
return nil
}
}
@@ -545,6 +569,9 @@ func new{{camel .FuncPrefix}}{{camel .EndpointName}}Cmd(flags *rootFlags) *cobra
}
if flags.asJSON || (!isTerminal(cmd.OutOrStdout()) && !flags.csv && !flags.quiet && !flags.plain) {
if flags.quiet {
+ if partialFailure != nil && !flags.allowPartialFailure {
+ return partialFailureErr(fmt.Errorf("partial failure in %s response: %s", "{{.ResourceName}}", partialFailure.Message))
+ }
return nil
}
// Apply --compact and --select to the API response before wrapping.
@@ -562,7 +589,10 @@ func new{{camel .FuncPrefix}}{{camel .EndpointName}}Cmd(flags *rootFlags) *cobra
"resource": "{{.ResourceName}}",
"path": path,
"status": statusCode,
- "success": statusCode >= 200 && statusCode < 300,
+ "success": statusCode >= 200 && statusCode < 300 && (partialFailure == nil || flags.allowPartialFailure),
+ }
+ if partialFailure != nil {
+ envelope["partial_failure"] = partialFailure
}
if flags.dryRun {
envelope["dry_run"] = true
@@ -579,7 +609,13 @@ func new{{camel .FuncPrefix}}{{camel .EndpointName}}Cmd(flags *rootFlags) *cobra
if err != nil {
return err
}
- return printOutput(cmd.OutOrStdout(), json.RawMessage(envelopeJSON), true)
+ if perr := printOutput(cmd.OutOrStdout(), json.RawMessage(envelopeJSON), true); perr != nil {
+ return perr
+ }
+ if partialFailure != nil && !flags.allowPartialFailure {
+ return partialFailureErr(fmt.Errorf("partial failure in %s response: %s", "{{.ResourceName}}", partialFailure.Message))
+ }
+ return nil
}
{{- end}}
{{- if or (eq .Endpoint.Method "GET") (eq .Endpoint.Method "HEAD")}}
@@ -627,7 +663,24 @@ func new{{camel .FuncPrefix}}{{camel .EndpointName}}Cmd(flags *rootFlags) *cobra
}
}
{{- end}}
+{{- if not (or (eq .Endpoint.Method "GET") (eq .Endpoint.Method "HEAD"))}}
+ // Fall-through for mutate paths that did not hit the table or
+ // asJSON branches: --quiet, --csv, --plain, and default terminal
+ // raw output. printOutputWithFlags renders the body, then the
+ // typed partial-failure exit fires unless --allow-partial-failure
+ // downgrades it. Without this guard a partial failure would exit
+ // 0 for these output modes — the exact silent-swallow regression
+ // the surrounding patch is preventing for asJSON / piped output.
+ if perr := printOutputWithFlags(cmd.OutOrStdout(), data, flags); perr != nil {
+ return perr
+ }
+ if partialFailure != nil && !flags.allowPartialFailure {
+ return partialFailureErr(fmt.Errorf("partial failure in %s response: %s", "{{.ResourceName}}", partialFailure.Message))
+ }
+ return nil
+{{- else}}
return printOutputWithFlags(cmd.OutOrStdout(), data, flags)
+{{- end}}
{{- end}}
},
}
diff --git a/internal/generator/templates/helpers.go.tmpl b/internal/generator/templates/helpers.go.tmpl
index 8b17ae4c..fbe97902 100644
--- a/internal/generator/templates/helpers.go.tmpl
+++ b/internal/generator/templates/helpers.go.tmpl
@@ -118,6 +118,81 @@ 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} }
+// partialFailureErr signals that the upstream API returned a 2xx with a
+// body shape indicating some operations in a batch failed (e.g. Google
+// Ads `partialFailureError`, similar shapes from Drive batch, Sheets
+// batchUpdate, Cloud Resource Manager). Distinct from apiErr (HTTP-level
+// failure) so callers can distinguish "request rejected" from "request
+// accepted but some ops failed".
+func partialFailureErr(err error) error { return &cliError{code: 6, err: err} }
+
+// partialFailureReport describes the structured detection result for a
+// mutate-style response body. Emitted in the envelope under
+// "partial_failure" so machine-readable callers can route per-operation
+// remediation.
+type partialFailureReport struct {
+ Field string `json:"field"`
+ Message string `json:"message,omitempty"`
+ Code int `json:"code,omitempty"`
+ Details any `json:"details,omitempty"`
+ ResourceNames []string `json:"resource_names,omitempty"`
+}
+
+// detectPartialFailure inspects a mutate-style JSON response for a
+// partial-failure-shaped field. Returns nil when no partial failure is
+// detected. The detector is intentionally generic across APIs that emit
+// 2xx-with-batch-errors. New partial-failure-shaped fields are added to
+// partialFailureFields, not at call sites. When `results[]` is present
+// (Google Ads convention) it extracts per-op `resourceName` so callers
+// can see which operations did succeed.
+func detectPartialFailure(data []byte) *partialFailureReport {
+ if len(data) == 0 {
+ return nil
+ }
+ var top map[string]any
+ if err := json.Unmarshal(data, &top); err != nil {
+ return nil
+ }
+ partialFailureFields := []string{"partialFailureError"}
+ for _, field := range partialFailureFields {
+ raw, ok := top[field]
+ if !ok || raw == nil {
+ continue
+ }
+ obj, ok := raw.(map[string]any)
+ if !ok {
+ continue
+ }
+ message, _ := obj["message"].(string)
+ var code int
+ if n, ok := obj["code"].(float64); ok {
+ code = int(n)
+ }
+ // Empty object means partial-failure mode was off or no ops
+ // failed; do not flag.
+ if code == 0 && strings.TrimSpace(message) == "" {
+ continue
+ }
+ report := &partialFailureReport{
+ Field: field,
+ Message: message,
+ Code: code,
+ Details: obj["details"],
+ }
+ if results, ok := top["results"].([]any); ok {
+ for _, r := range results {
+ if rm, ok := r.(map[string]any); ok {
+ if name, ok := rm["resourceName"].(string); ok && name != "" {
+ report.ResourceNames = append(report.ResourceNames, name)
+ }
+ }
+ }
+ }
+ return report
+ }
+ return nil
+}
+
// dryRunOK reports whether the command should short-circuit without doing any
// real work because --dry-run was set. The verify pipeline probes hand-written
// commands with --dry-run; commands that put validation in cobra's `Args:` or
diff --git a/internal/generator/templates/root.go.tmpl b/internal/generator/templates/root.go.tmpl
index 4c69d04e..97088e9b 100644
--- a/internal/generator/templates/root.go.tmpl
+++ b/internal/generator/templates/root.go.tmpl
@@ -34,6 +34,11 @@ type rootFlags struct {
{{- end}}
yes bool
agent bool
+ // allowPartialFailure downgrades a detected response-body partial-failure
+ // (e.g. Google Ads `partialFailureError`) from a non-zero exit to a
+ // stderr warning. Default false so silent partial successes surface as
+ // failures by default.
+ allowPartialFailure bool
selectFields string
configPath string
profileName string
@@ -216,6 +221,7 @@ Run '{{.Name}}-pp-cli doctor' to verify auth and connectivity.{{backtick}},
rootCmd.PersistentFlags().BoolVar(&noColor, "no-color", false, "Disable colored output")
rootCmd.PersistentFlags().BoolVar(&humanFriendly, "human-friendly", false, "Enable colored output and rich formatting")
rootCmd.PersistentFlags().BoolVar(&flags.agent, "agent", false, "Set all agent-friendly defaults (--json --compact --no-input --no-color --yes)")
+ rootCmd.PersistentFlags().BoolVar(&flags.allowPartialFailure, "allow-partial-failure", false, "Downgrade response-body partial-failure (e.g. partialFailureError) to a warning instead of a non-zero exit")
rootCmd.PersistentFlags().StringVar(&flags.dataSource, "data-source", "auto", "Data source for read commands: auto (live with local fallback), live (API only), local (synced data only)")
rootCmd.PersistentFlags().StringVar(&flags.profileName, "profile", "", "Apply values from a saved profile (see '{{.Name}}-pp-cli profile list')")
rootCmd.PersistentFlags().StringVar(&flags.deliverSpec, "deliver", "", "Route output to a sink: stdout (default), file:<path>, webhook:<url>")
diff --git a/testdata/golden/expected/generate-embedded-paged-api/embedded-paged-api/internal/cli/helpers.go b/testdata/golden/expected/generate-embedded-paged-api/embedded-paged-api/internal/cli/helpers.go
index 5182dbc1..e7a4de48 100644
--- a/testdata/golden/expected/generate-embedded-paged-api/embedded-paged-api/internal/cli/helpers.go
+++ b/testdata/golden/expected/generate-embedded-paged-api/embedded-paged-api/internal/cli/helpers.go
@@ -102,6 +102,81 @@ 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} }
+// partialFailureErr signals that the upstream API returned a 2xx with a
+// body shape indicating some operations in a batch failed (e.g. Google
+// Ads `partialFailureError`, similar shapes from Drive batch, Sheets
+// batchUpdate, Cloud Resource Manager). Distinct from apiErr (HTTP-level
+// failure) so callers can distinguish "request rejected" from "request
+// accepted but some ops failed".
+func partialFailureErr(err error) error { return &cliError{code: 6, err: err} }
+
+// partialFailureReport describes the structured detection result for a
+// mutate-style response body. Emitted in the envelope under
+// "partial_failure" so machine-readable callers can route per-operation
+// remediation.
+type partialFailureReport struct {
+ Field string `json:"field"`
+ Message string `json:"message,omitempty"`
+ Code int `json:"code,omitempty"`
+ Details any `json:"details,omitempty"`
+ ResourceNames []string `json:"resource_names,omitempty"`
+}
+
+// detectPartialFailure inspects a mutate-style JSON response for a
+// partial-failure-shaped field. Returns nil when no partial failure is
+// detected. The detector is intentionally generic across APIs that emit
+// 2xx-with-batch-errors. New partial-failure-shaped fields are added to
+// partialFailureFields, not at call sites. When `results[]` is present
+// (Google Ads convention) it extracts per-op `resourceName` so callers
+// can see which operations did succeed.
+func detectPartialFailure(data []byte) *partialFailureReport {
+ if len(data) == 0 {
+ return nil
+ }
+ var top map[string]any
+ if err := json.Unmarshal(data, &top); err != nil {
+ return nil
+ }
+ partialFailureFields := []string{"partialFailureError"}
+ for _, field := range partialFailureFields {
+ raw, ok := top[field]
+ if !ok || raw == nil {
+ continue
+ }
+ obj, ok := raw.(map[string]any)
+ if !ok {
+ continue
+ }
+ message, _ := obj["message"].(string)
+ var code int
+ if n, ok := obj["code"].(float64); ok {
+ code = int(n)
+ }
+ // Empty object means partial-failure mode was off or no ops
+ // failed; do not flag.
+ if code == 0 && strings.TrimSpace(message) == "" {
+ continue
+ }
+ report := &partialFailureReport{
+ Field: field,
+ Message: message,
+ Code: code,
+ Details: obj["details"],
+ }
+ if results, ok := top["results"].([]any); ok {
+ for _, r := range results {
+ if rm, ok := r.(map[string]any); ok {
+ if name, ok := rm["resourceName"].(string); ok && name != "" {
+ report.ResourceNames = append(report.ResourceNames, name)
+ }
+ }
+ }
+ }
+ return report
+ }
+ return nil
+}
+
// dryRunOK reports whether the command should short-circuit without doing any
// real work because --dry-run was set. The verify pipeline probes hand-written
// commands with --dry-run; commands that put validation in cobra's `Args:` or
diff --git a/testdata/golden/expected/generate-golden-api-rich-auth/printing-press-rich-auth/internal/cli/helpers.go b/testdata/golden/expected/generate-golden-api-rich-auth/printing-press-rich-auth/internal/cli/helpers.go
index 3d01225d..7ed03438 100644
--- a/testdata/golden/expected/generate-golden-api-rich-auth/printing-press-rich-auth/internal/cli/helpers.go
+++ b/testdata/golden/expected/generate-golden-api-rich-auth/printing-press-rich-auth/internal/cli/helpers.go
@@ -101,6 +101,81 @@ 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} }
+// partialFailureErr signals that the upstream API returned a 2xx with a
+// body shape indicating some operations in a batch failed (e.g. Google
+// Ads `partialFailureError`, similar shapes from Drive batch, Sheets
+// batchUpdate, Cloud Resource Manager). Distinct from apiErr (HTTP-level
+// failure) so callers can distinguish "request rejected" from "request
+// accepted but some ops failed".
+func partialFailureErr(err error) error { return &cliError{code: 6, err: err} }
+
+// partialFailureReport describes the structured detection result for a
+// mutate-style response body. Emitted in the envelope under
+// "partial_failure" so machine-readable callers can route per-operation
+// remediation.
+type partialFailureReport struct {
+ Field string `json:"field"`
+ Message string `json:"message,omitempty"`
+ Code int `json:"code,omitempty"`
+ Details any `json:"details,omitempty"`
+ ResourceNames []string `json:"resource_names,omitempty"`
+}
+
+// detectPartialFailure inspects a mutate-style JSON response for a
+// partial-failure-shaped field. Returns nil when no partial failure is
+// detected. The detector is intentionally generic across APIs that emit
+// 2xx-with-batch-errors. New partial-failure-shaped fields are added to
+// partialFailureFields, not at call sites. When `results[]` is present
+// (Google Ads convention) it extracts per-op `resourceName` so callers
+// can see which operations did succeed.
+func detectPartialFailure(data []byte) *partialFailureReport {
+ if len(data) == 0 {
+ return nil
+ }
+ var top map[string]any
+ if err := json.Unmarshal(data, &top); err != nil {
+ return nil
+ }
+ partialFailureFields := []string{"partialFailureError"}
+ for _, field := range partialFailureFields {
+ raw, ok := top[field]
+ if !ok || raw == nil {
+ continue
+ }
+ obj, ok := raw.(map[string]any)
+ if !ok {
+ continue
+ }
+ message, _ := obj["message"].(string)
+ var code int
+ if n, ok := obj["code"].(float64); ok {
+ code = int(n)
+ }
+ // Empty object means partial-failure mode was off or no ops
+ // failed; do not flag.
+ if code == 0 && strings.TrimSpace(message) == "" {
+ continue
+ }
+ report := &partialFailureReport{
+ Field: field,
+ Message: message,
+ Code: code,
+ Details: obj["details"],
+ }
+ if results, ok := top["results"].([]any); ok {
+ for _, r := range results {
+ if rm, ok := r.(map[string]any); ok {
+ if name, ok := rm["resourceName"].(string); ok && name != "" {
+ report.ResourceNames = append(report.ResourceNames, name)
+ }
+ }
+ }
+ }
+ return report
+ }
+ return nil
+}
+
// dryRunOK reports whether the command should short-circuit without doing any
// real work because --dry-run was set. The verify pipeline probes hand-written
// commands with --dry-run; commands that put validation in cobra's `Args:` or
diff --git a/testdata/golden/expected/generate-golden-api/dogfood.json b/testdata/golden/expected/generate-golden-api/dogfood.json
index d3c0383b..f0770511 100644
--- a/testdata/golden/expected/generate-golden-api/dogfood.json
+++ b/testdata/golden/expected/generate-golden-api/dogfood.json
@@ -12,11 +12,11 @@
},
"dead_flags": {
"dead": 0,
- "total": 18
+ "total": 19
},
"dead_functions": {
"dead": 0,
- "total": 60
+ "total": 62
},
"dir": "<ARTIFACT_DIR>/generate-golden-api/printing-press-golden",
"example_check": {
diff --git a/testdata/golden/expected/generate-golden-api/printing-press-golden/internal/cli/helpers.go b/testdata/golden/expected/generate-golden-api/printing-press-golden/internal/cli/helpers.go
index 6bdf7e9f..489384d3 100644
--- a/testdata/golden/expected/generate-golden-api/printing-press-golden/internal/cli/helpers.go
+++ b/testdata/golden/expected/generate-golden-api/printing-press-golden/internal/cli/helpers.go
@@ -102,6 +102,81 @@ 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} }
+// partialFailureErr signals that the upstream API returned a 2xx with a
+// body shape indicating some operations in a batch failed (e.g. Google
+// Ads `partialFailureError`, similar shapes from Drive batch, Sheets
+// batchUpdate, Cloud Resource Manager). Distinct from apiErr (HTTP-level
+// failure) so callers can distinguish "request rejected" from "request
+// accepted but some ops failed".
+func partialFailureErr(err error) error { return &cliError{code: 6, err: err} }
+
+// partialFailureReport describes the structured detection result for a
+// mutate-style response body. Emitted in the envelope under
+// "partial_failure" so machine-readable callers can route per-operation
+// remediation.
+type partialFailureReport struct {
+ Field string `json:"field"`
+ Message string `json:"message,omitempty"`
+ Code int `json:"code,omitempty"`
+ Details any `json:"details,omitempty"`
+ ResourceNames []string `json:"resource_names,omitempty"`
+}
+
+// detectPartialFailure inspects a mutate-style JSON response for a
+// partial-failure-shaped field. Returns nil when no partial failure is
+// detected. The detector is intentionally generic across APIs that emit
+// 2xx-with-batch-errors. New partial-failure-shaped fields are added to
+// partialFailureFields, not at call sites. When `results[]` is present
+// (Google Ads convention) it extracts per-op `resourceName` so callers
+// can see which operations did succeed.
+func detectPartialFailure(data []byte) *partialFailureReport {
+ if len(data) == 0 {
+ return nil
+ }
+ var top map[string]any
+ if err := json.Unmarshal(data, &top); err != nil {
+ return nil
+ }
+ partialFailureFields := []string{"partialFailureError"}
+ for _, field := range partialFailureFields {
+ raw, ok := top[field]
+ if !ok || raw == nil {
+ continue
+ }
+ obj, ok := raw.(map[string]any)
+ if !ok {
+ continue
+ }
+ message, _ := obj["message"].(string)
+ var code int
+ if n, ok := obj["code"].(float64); ok {
+ code = int(n)
+ }
+ // Empty object means partial-failure mode was off or no ops
+ // failed; do not flag.
+ if code == 0 && strings.TrimSpace(message) == "" {
+ continue
+ }
+ report := &partialFailureReport{
+ Field: field,
+ Message: message,
+ Code: code,
+ Details: obj["details"],
+ }
+ if results, ok := top["results"].([]any); ok {
+ for _, r := range results {
+ if rm, ok := r.(map[string]any); ok {
+ if name, ok := rm["resourceName"].(string); ok && name != "" {
+ report.ResourceNames = append(report.ResourceNames, name)
+ }
+ }
+ }
+ }
+ return report
+ }
+ return nil
+}
+
// dryRunOK reports whether the command should short-circuit without doing any
// real work because --dry-run was set. The verify pipeline probes hand-written
// commands with --dry-run; commands that put validation in cobra's `Args:` or
diff --git a/testdata/golden/expected/generate-golden-api/printing-press-golden/internal/cli/projects_avatar_upload-project.go b/testdata/golden/expected/generate-golden-api/printing-press-golden/internal/cli/projects_avatar_upload-project.go
index 3bddcf56..7727b02d 100644
--- a/testdata/golden/expected/generate-golden-api/printing-press-golden/internal/cli/projects_avatar_upload-project.go
+++ b/testdata/golden/expected/generate-golden-api/printing-press-golden/internal/cli/projects_avatar_upload-project.go
@@ -50,6 +50,24 @@ func newProjectsAvatarUploadProjectCmd(flags *rootFlags) *cobra.Command {
if err != nil {
return classifyAPIError(err, flags)
}
+ // Inspect the mutate response body for a partial-failure-shaped
+ // field (e.g. Google Ads `partialFailureError`). Several Google
+ // APIs return 200 OK with a partial-failure field when some
+ // operations in the batch failed; ignoring it silently swallows
+ // real failures. Detection runs before output-mode selection so
+ // the exit code is consistent regardless of how stdout is
+ // rendered. --dry-run short-circuits because no real request
+ // was sent.
+ var partialFailure *partialFailureReport
+ if !flags.dryRun && statusCode >= 200 && statusCode < 300 {
+ partialFailure = detectPartialFailure(data)
+ if partialFailure != nil {
+ fmt.Fprintf(os.Stderr, "warning: partial failure detected in %s response: %s\n", "avatar", partialFailure.Message)
+ if len(partialFailure.ResourceNames) > 0 {
+ fmt.Fprintf(os.Stderr, " succeeded: %d operation(s)\n", len(partialFailure.ResourceNames))
+ }
+ }
+ }
if wantsHumanTable(cmd.OutOrStdout(), flags) {
// Check if response contains an array (directly or wrapped in "data")
var items []map[string]any
@@ -57,6 +75,9 @@ func newProjectsAvatarUploadProjectCmd(flags *rootFlags) *cobra.Command {
if err := printAutoTable(cmd.OutOrStdout(), items); err != nil {
fmt.Fprintf(os.Stderr, "warning: table rendering failed, falling back to JSON: %v\n", err)
} else {
+ if partialFailure != nil && !flags.allowPartialFailure {
+ return partialFailureErr(fmt.Errorf("partial failure in %s response: %s", "avatar", partialFailure.Message))
+ }
return nil
}
} else {
@@ -67,6 +88,9 @@ func newProjectsAvatarUploadProjectCmd(flags *rootFlags) *cobra.Command {
if err := printAutoTable(cmd.OutOrStdout(), wrapped.Data); err != nil {
fmt.Fprintf(os.Stderr, "warning: table rendering failed, falling back to JSON: %v\n", err)
} else {
+ if partialFailure != nil && !flags.allowPartialFailure {
+ return partialFailureErr(fmt.Errorf("partial failure in %s response: %s", "avatar", partialFailure.Message))
+ }
return nil
}
}
@@ -74,6 +98,9 @@ func newProjectsAvatarUploadProjectCmd(flags *rootFlags) *cobra.Command {
}
if flags.asJSON || (!isTerminal(cmd.OutOrStdout()) && !flags.csv && !flags.quiet && !flags.plain) {
if flags.quiet {
+ if partialFailure != nil && !flags.allowPartialFailure {
+ return partialFailureErr(fmt.Errorf("partial failure in %s response: %s", "avatar", partialFailure.Message))
+ }
return nil
}
// Apply --compact and --select to the API response before wrapping.
@@ -91,7 +118,10 @@ func newProjectsAvatarUploadProjectCmd(flags *rootFlags) *cobra.Command {
"resource": "avatar",
"path": path,
"status": statusCode,
- "success": statusCode >= 200 && statusCode < 300,
+ "success": statusCode >= 200 && statusCode < 300 && (partialFailure == nil || flags.allowPartialFailure),
+ }
+ if partialFailure != nil {
+ envelope["partial_failure"] = partialFailure
}
if flags.dryRun {
envelope["dry_run"] = true
@@ -108,9 +138,28 @@ func newProjectsAvatarUploadProjectCmd(flags *rootFlags) *cobra.Command {
if err != nil {
return err
}
- return printOutput(cmd.OutOrStdout(), json.RawMessage(envelopeJSON), true)
+ if perr := printOutput(cmd.OutOrStdout(), json.RawMessage(envelopeJSON), true); perr != nil {
+ return perr
+ }
+ if partialFailure != nil && !flags.allowPartialFailure {
+ return partialFailureErr(fmt.Errorf("partial failure in %s response: %s", "avatar", partialFailure.Message))
+ }
+ return nil
+ }
+ // Fall-through for mutate paths that did not hit the table or
+ // asJSON branches: --quiet, --csv, --plain, and default terminal
+ // raw output. printOutputWithFlags renders the body, then the
+ // typed partial-failure exit fires unless --allow-partial-failure
+ // downgrades it. Without this guard a partial failure would exit
+ // 0 for these output modes — the exact silent-swallow regression
+ // the surrounding patch is preventing for asJSON / piped output.
+ if perr := printOutputWithFlags(cmd.OutOrStdout(), data, flags); perr != nil {
+ return perr
+ }
+ if partialFailure != nil && !flags.allowPartialFailure {
+ return partialFailureErr(fmt.Errorf("partial failure in %s response: %s", "avatar", partialFailure.Message))
}
- return printOutputWithFlags(cmd.OutOrStdout(), data, flags)
+ return nil
},
}
cmd.Flags().BoolVar(&flagOverwrite, "overwrite", false, "Overwrite")
diff --git a/testdata/golden/expected/generate-golden-api/printing-press-golden/internal/cli/projects_create.go b/testdata/golden/expected/generate-golden-api/printing-press-golden/internal/cli/projects_create.go
index 4f1e7b79..fd0ca326 100644
--- a/testdata/golden/expected/generate-golden-api/printing-press-golden/internal/cli/projects_create.go
+++ b/testdata/golden/expected/generate-golden-api/printing-press-golden/internal/cli/projects_create.go
@@ -66,6 +66,24 @@ func newProjectsCreateCmd(flags *rootFlags) *cobra.Command {
if err != nil {
return classifyAPIError(err, flags)
}
+ // Inspect the mutate response body for a partial-failure-shaped
+ // field (e.g. Google Ads `partialFailureError`). Several Google
+ // APIs return 200 OK with a partial-failure field when some
+ // operations in the batch failed; ignoring it silently swallows
+ // real failures. Detection runs before output-mode selection so
+ // the exit code is consistent regardless of how stdout is
+ // rendered. --dry-run short-circuits because no real request
+ // was sent.
+ var partialFailure *partialFailureReport
+ if !flags.dryRun && statusCode >= 200 && statusCode < 300 {
+ partialFailure = detectPartialFailure(data)
+ if partialFailure != nil {
+ fmt.Fprintf(os.Stderr, "warning: partial failure detected in %s response: %s\n", "projects", partialFailure.Message)
+ if len(partialFailure.ResourceNames) > 0 {
+ fmt.Fprintf(os.Stderr, " succeeded: %d operation(s)\n", len(partialFailure.ResourceNames))
+ }
+ }
+ }
if wantsHumanTable(cmd.OutOrStdout(), flags) {
// Check if response contains an array (directly or wrapped in "data")
var items []map[string]any
@@ -73,6 +91,9 @@ func newProjectsCreateCmd(flags *rootFlags) *cobra.Command {
if err := printAutoTable(cmd.OutOrStdout(), items); err != nil {
fmt.Fprintf(os.Stderr, "warning: table rendering failed, falling back to JSON: %v\n", err)
} else {
+ if partialFailure != nil && !flags.allowPartialFailure {
+ return partialFailureErr(fmt.Errorf("partial failure in %s response: %s", "projects", partialFailure.Message))
+ }
return nil
}
} else {
@@ -83,6 +104,9 @@ func newProjectsCreateCmd(flags *rootFlags) *cobra.Command {
if err := printAutoTable(cmd.OutOrStdout(), wrapped.Data); err != nil {
fmt.Fprintf(os.Stderr, "warning: table rendering failed, falling back to JSON: %v\n", err)
} else {
+ if partialFailure != nil && !flags.allowPartialFailure {
+ return partialFailureErr(fmt.Errorf("partial failure in %s response: %s", "projects", partialFailure.Message))
+ }
return nil
}
}
@@ -90,6 +114,9 @@ func newProjectsCreateCmd(flags *rootFlags) *cobra.Command {
}
if flags.asJSON || (!isTerminal(cmd.OutOrStdout()) && !flags.csv && !flags.quiet && !flags.plain) {
if flags.quiet {
+ if partialFailure != nil && !flags.allowPartialFailure {
+ return partialFailureErr(fmt.Errorf("partial failure in %s response: %s", "projects", partialFailure.Message))
+ }
return nil
}
// Apply --compact and --select to the API response before wrapping.
@@ -107,7 +134,10 @@ func newProjectsCreateCmd(flags *rootFlags) *cobra.Command {
"resource": "projects",
"path": path,
"status": statusCode,
- "success": statusCode >= 200 && statusCode < 300,
+ "success": statusCode >= 200 && statusCode < 300 && (partialFailure == nil || flags.allowPartialFailure),
+ }
+ if partialFailure != nil {
+ envelope["partial_failure"] = partialFailure
}
if flags.dryRun {
envelope["dry_run"] = true
@@ -124,9 +154,28 @@ func newProjectsCreateCmd(flags *rootFlags) *cobra.Command {
if err != nil {
return err
}
- return printOutput(cmd.OutOrStdout(), json.RawMessage(envelopeJSON), true)
+ if perr := printOutput(cmd.OutOrStdout(), json.RawMessage(envelopeJSON), true); perr != nil {
+ return perr
+ }
+ if partialFailure != nil && !flags.allowPartialFailure {
+ return partialFailureErr(fmt.Errorf("partial failure in %s response: %s", "projects", partialFailure.Message))
+ }
+ return nil
+ }
+ // Fall-through for mutate paths that did not hit the table or
+ // asJSON branches: --quiet, --csv, --plain, and default terminal
+ // raw output. printOutputWithFlags renders the body, then the
+ // typed partial-failure exit fires unless --allow-partial-failure
+ // downgrades it. Without this guard a partial failure would exit
+ // 0 for these output modes — the exact silent-swallow regression
+ // the surrounding patch is preventing for asJSON / piped output.
+ if perr := printOutputWithFlags(cmd.OutOrStdout(), data, flags); perr != nil {
+ return perr
+ }
+ if partialFailure != nil && !flags.allowPartialFailure {
+ return partialFailureErr(fmt.Errorf("partial failure in %s response: %s", "projects", partialFailure.Message))
}
- return printOutputWithFlags(cmd.OutOrStdout(), data, flags)
+ return nil
},
}
cmd.Flags().StringVar(&bodyName, "name", "", "Name")
diff --git a/testdata/golden/expected/generate-golden-api/printing-press-golden/internal/cli/projects_tasks_update-project.go b/testdata/golden/expected/generate-golden-api/printing-press-golden/internal/cli/projects_tasks_update-project.go
index 0d5eeb1f..fadac499 100644
--- a/testdata/golden/expected/generate-golden-api/printing-press-golden/internal/cli/projects_tasks_update-project.go
+++ b/testdata/golden/expected/generate-golden-api/printing-press-golden/internal/cli/projects_tasks_update-project.go
@@ -73,6 +73,24 @@ func newProjectsTasksUpdateProjectCmd(flags *rootFlags) *cobra.Command {
if err != nil {
return classifyAPIError(err, flags)
}
+ // Inspect the mutate response body for a partial-failure-shaped
+ // field (e.g. Google Ads `partialFailureError`). Several Google
+ // APIs return 200 OK with a partial-failure field when some
+ // operations in the batch failed; ignoring it silently swallows
+ // real failures. Detection runs before output-mode selection so
+ // the exit code is consistent regardless of how stdout is
+ // rendered. --dry-run short-circuits because no real request
+ // was sent.
+ var partialFailure *partialFailureReport
+ if !flags.dryRun && statusCode >= 200 && statusCode < 300 {
+ partialFailure = detectPartialFailure(data)
+ if partialFailure != nil {
+ fmt.Fprintf(os.Stderr, "warning: partial failure detected in %s response: %s\n", "tasks", partialFailure.Message)
+ if len(partialFailure.ResourceNames) > 0 {
+ fmt.Fprintf(os.Stderr, " succeeded: %d operation(s)\n", len(partialFailure.ResourceNames))
+ }
+ }
+ }
if wantsHumanTable(cmd.OutOrStdout(), flags) {
// Check if response contains an array (directly or wrapped in "data")
var items []map[string]any
@@ -80,6 +98,9 @@ func newProjectsTasksUpdateProjectCmd(flags *rootFlags) *cobra.Command {
if err := printAutoTable(cmd.OutOrStdout(), items); err != nil {
fmt.Fprintf(os.Stderr, "warning: table rendering failed, falling back to JSON: %v\n", err)
} else {
+ if partialFailure != nil && !flags.allowPartialFailure {
+ return partialFailureErr(fmt.Errorf("partial failure in %s response: %s", "tasks", partialFailure.Message))
+ }
return nil
}
} else {
@@ -90,6 +111,9 @@ func newProjectsTasksUpdateProjectCmd(flags *rootFlags) *cobra.Command {
if err := printAutoTable(cmd.OutOrStdout(), wrapped.Data); err != nil {
fmt.Fprintf(os.Stderr, "warning: table rendering failed, falling back to JSON: %v\n", err)
} else {
+ if partialFailure != nil && !flags.allowPartialFailure {
+ return partialFailureErr(fmt.Errorf("partial failure in %s response: %s", "tasks", partialFailure.Message))
+ }
return nil
}
}
@@ -97,6 +121,9 @@ func newProjectsTasksUpdateProjectCmd(flags *rootFlags) *cobra.Command {
}
if flags.asJSON || (!isTerminal(cmd.OutOrStdout()) && !flags.csv && !flags.quiet && !flags.plain) {
if flags.quiet {
+ if partialFailure != nil && !flags.allowPartialFailure {
+ return partialFailureErr(fmt.Errorf("partial failure in %s response: %s", "tasks", partialFailure.Message))
+ }
return nil
}
// Apply --compact and --select to the API response before wrapping.
@@ -114,7 +141,10 @@ func newProjectsTasksUpdateProjectCmd(flags *rootFlags) *cobra.Command {
"resource": "tasks",
"path": path,
"status": statusCode,
- "success": statusCode >= 200 && statusCode < 300,
+ "success": statusCode >= 200 && statusCode < 300 && (partialFailure == nil || flags.allowPartialFailure),
+ }
+ if partialFailure != nil {
+ envelope["partial_failure"] = partialFailure
}
if flags.dryRun {
envelope["dry_run"] = true
@@ -131,9 +161,28 @@ func newProjectsTasksUpdateProjectCmd(flags *rootFlags) *cobra.Command {
if err != nil {
return err
}
- return printOutput(cmd.OutOrStdout(), json.RawMessage(envelopeJSON), true)
+ if perr := printOutput(cmd.OutOrStdout(), json.RawMessage(envelopeJSON), true); perr != nil {
+ return perr
+ }
+ if partialFailure != nil && !flags.allowPartialFailure {
+ return partialFailureErr(fmt.Errorf("partial failure in %s response: %s", "tasks", partialFailure.Message))
+ }
+ return nil
+ }
+ // Fall-through for mutate paths that did not hit the table or
+ // asJSON branches: --quiet, --csv, --plain, and default terminal
+ // raw output. printOutputWithFlags renders the body, then the
+ // typed partial-failure exit fires unless --allow-partial-failure
+ // downgrades it. Without this guard a partial failure would exit
+ // 0 for these output modes — the exact silent-swallow regression
+ // the surrounding patch is preventing for asJSON / piped output.
+ if perr := printOutputWithFlags(cmd.OutOrStdout(), data, flags); perr != nil {
+ return perr
+ }
+ if partialFailure != nil && !flags.allowPartialFailure {
+ return partialFailureErr(fmt.Errorf("partial failure in %s response: %s", "tasks", partialFailure.Message))
}
- return printOutputWithFlags(cmd.OutOrStdout(), data, flags)
+ return nil
},
}
cmd.Flags().BoolVar(&flagNotify, "notify", false, "Notify")
diff --git a/testdata/golden/expected/generate-golden-api/printing-press-golden/internal/cli/root.go b/testdata/golden/expected/generate-golden-api/printing-press-golden/internal/cli/root.go
index ca7494e9..1a1af02e 100644
--- a/testdata/golden/expected/generate-golden-api/printing-press-golden/internal/cli/root.go
+++ b/testdata/golden/expected/generate-golden-api/printing-press-golden/internal/cli/root.go
@@ -20,25 +20,30 @@ import (
var version = "1.0.0"
type rootFlags struct {
- asJSON bool
- compact bool
- csv bool
- plain bool
- quiet bool
- dryRun bool
- noCache bool
- noInput bool
- idempotent bool
- yes bool
- agent bool
- selectFields string
- configPath string
- profileName string
- deliverSpec string
- timeout time.Duration
- rateLimit float64
- dataSource string
- freshnessMeta any
+ asJSON bool
+ compact bool
+ csv bool
+ plain bool
+ quiet bool
+ dryRun bool
+ noCache bool
+ noInput bool
+ idempotent bool
+ yes bool
+ agent bool
+ // allowPartialFailure downgrades a detected response-body partial-failure
+ // (e.g. Google Ads `partialFailureError`) from a non-zero exit to a
+ // stderr warning. Default false so silent partial successes surface as
+ // failures by default.
+ allowPartialFailure bool
+ selectFields string
+ configPath string
+ profileName string
+ deliverSpec string
+ timeout time.Duration
+ rateLimit float64
+ dataSource string
+ freshnessMeta any
// deliverBuf captures command output when --deliver is set to a
// non-stdout sink. Flushed to the sink after Execute returns.
@@ -164,6 +169,7 @@ Run 'printing-press-golden-pp-cli doctor' to verify auth and connectivity.`,
rootCmd.PersistentFlags().BoolVar(&noColor, "no-color", false, "Disable colored output")
rootCmd.PersistentFlags().BoolVar(&humanFriendly, "human-friendly", false, "Enable colored output and rich formatting")
rootCmd.PersistentFlags().BoolVar(&flags.agent, "agent", false, "Set all agent-friendly defaults (--json --compact --no-input --no-color --yes)")
+ rootCmd.PersistentFlags().BoolVar(&flags.allowPartialFailure, "allow-partial-failure", false, "Downgrade response-body partial-failure (e.g. partialFailureError) to a warning instead of a non-zero exit")
rootCmd.PersistentFlags().StringVar(&flags.dataSource, "data-source", "auto", "Data source for read commands: auto (live with local fallback), live (API only), local (synced data only)")
rootCmd.PersistentFlags().StringVar(&flags.profileName, "profile", "", "Apply values from a saved profile (see 'printing-press-golden-pp-cli profile list')")
rootCmd.PersistentFlags().StringVar(&flags.deliverSpec, "deliver", "", "Route output to a sink: stdout (default), file:<path>, webhook:<url>")
diff --git a/testdata/golden/expected/generate-graphql-shared-endpoint/graphql-shared-golden/internal/cli/things_get.go b/testdata/golden/expected/generate-graphql-shared-endpoint/graphql-shared-golden/internal/cli/things_get.go
index 2f04632c..a19d2443 100644
--- a/testdata/golden/expected/generate-graphql-shared-endpoint/graphql-shared-golden/internal/cli/things_get.go
+++ b/testdata/golden/expected/generate-graphql-shared-endpoint/graphql-shared-golden/internal/cli/things_get.go
@@ -53,6 +53,24 @@ func newThingsGetCmd(flags *rootFlags) *cobra.Command {
if err != nil {
return classifyAPIError(err, flags)
}
+ // Inspect the mutate response body for a partial-failure-shaped
+ // field (e.g. Google Ads `partialFailureError`). Several Google
+ // APIs return 200 OK with a partial-failure field when some
+ // operations in the batch failed; ignoring it silently swallows
+ // real failures. Detection runs before output-mode selection so
+ // the exit code is consistent regardless of how stdout is
+ // rendered. --dry-run short-circuits because no real request
+ // was sent.
+ var partialFailure *partialFailureReport
+ if !flags.dryRun && statusCode >= 200 && statusCode < 300 {
+ partialFailure = detectPartialFailure(data)
+ if partialFailure != nil {
+ fmt.Fprintf(os.Stderr, "warning: partial failure detected in %s response: %s\n", "things", partialFailure.Message)
+ if len(partialFailure.ResourceNames) > 0 {
+ fmt.Fprintf(os.Stderr, " succeeded: %d operation(s)\n", len(partialFailure.ResourceNames))
+ }
+ }
+ }
if wantsHumanTable(cmd.OutOrStdout(), flags) {
// Check if response contains an array (directly or wrapped in "data")
var items []map[string]any
@@ -60,6 +78,9 @@ func newThingsGetCmd(flags *rootFlags) *cobra.Command {
if err := printAutoTable(cmd.OutOrStdout(), items); err != nil {
fmt.Fprintf(os.Stderr, "warning: table rendering failed, falling back to JSON: %v\n", err)
} else {
+ if partialFailure != nil && !flags.allowPartialFailure {
+ return partialFailureErr(fmt.Errorf("partial failure in %s response: %s", "things", partialFailure.Message))
+ }
return nil
}
} else {
@@ -70,6 +91,9 @@ func newThingsGetCmd(flags *rootFlags) *cobra.Command {
if err := printAutoTable(cmd.OutOrStdout(), wrapped.Data); err != nil {
fmt.Fprintf(os.Stderr, "warning: table rendering failed, falling back to JSON: %v\n", err)
} else {
+ if partialFailure != nil && !flags.allowPartialFailure {
+ return partialFailureErr(fmt.Errorf("partial failure in %s response: %s", "things", partialFailure.Message))
+ }
return nil
}
}
@@ -77,6 +101,9 @@ func newThingsGetCmd(flags *rootFlags) *cobra.Command {
}
if flags.asJSON || (!isTerminal(cmd.OutOrStdout()) && !flags.csv && !flags.quiet && !flags.plain) {
if flags.quiet {
+ if partialFailure != nil && !flags.allowPartialFailure {
+ return partialFailureErr(fmt.Errorf("partial failure in %s response: %s", "things", partialFailure.Message))
+ }
return nil
}
// Apply --compact and --select to the API response before wrapping.
@@ -94,7 +121,10 @@ func newThingsGetCmd(flags *rootFlags) *cobra.Command {
"resource": "things",
"path": path,
"status": statusCode,
- "success": statusCode >= 200 && statusCode < 300,
+ "success": statusCode >= 200 && statusCode < 300 && (partialFailure == nil || flags.allowPartialFailure),
+ }
+ if partialFailure != nil {
+ envelope["partial_failure"] = partialFailure
}
if flags.dryRun {
envelope["dry_run"] = true
@@ -111,9 +141,28 @@ func newThingsGetCmd(flags *rootFlags) *cobra.Command {
if err != nil {
return err
}
- return printOutput(cmd.OutOrStdout(), json.RawMessage(envelopeJSON), true)
+ if perr := printOutput(cmd.OutOrStdout(), json.RawMessage(envelopeJSON), true); perr != nil {
+ return perr
+ }
+ if partialFailure != nil && !flags.allowPartialFailure {
+ return partialFailureErr(fmt.Errorf("partial failure in %s response: %s", "things", partialFailure.Message))
+ }
+ return nil
+ }
+ // Fall-through for mutate paths that did not hit the table or
+ // asJSON branches: --quiet, --csv, --plain, and default terminal
+ // raw output. printOutputWithFlags renders the body, then the
+ // typed partial-failure exit fires unless --allow-partial-failure
+ // downgrades it. Without this guard a partial failure would exit
+ // 0 for these output modes — the exact silent-swallow regression
+ // the surrounding patch is preventing for asJSON / piped output.
+ if perr := printOutputWithFlags(cmd.OutOrStdout(), data, flags); perr != nil {
+ return perr
+ }
+ if partialFailure != nil && !flags.allowPartialFailure {
+ return partialFailureErr(fmt.Errorf("partial failure in %s response: %s", "things", partialFailure.Message))
}
- return printOutputWithFlags(cmd.OutOrStdout(), data, flags)
+ return nil
},
}
cmd.Flags().BoolVar(&stdinBody, "stdin", false, "Read request body as JSON from stdin")
diff --git a/testdata/golden/expected/generate-graphql-shared-endpoint/graphql-shared-golden/internal/cli/things_list.go b/testdata/golden/expected/generate-graphql-shared-endpoint/graphql-shared-golden/internal/cli/things_list.go
index b12420c0..15c1f5b6 100644
--- a/testdata/golden/expected/generate-graphql-shared-endpoint/graphql-shared-golden/internal/cli/things_list.go
+++ b/testdata/golden/expected/generate-graphql-shared-endpoint/graphql-shared-golden/internal/cli/things_list.go
@@ -49,6 +49,24 @@ func newThingsListCmd(flags *rootFlags) *cobra.Command {
if err != nil {
return classifyAPIError(err, flags)
}
+ // Inspect the mutate response body for a partial-failure-shaped
+ // field (e.g. Google Ads `partialFailureError`). Several Google
+ // APIs return 200 OK with a partial-failure field when some
+ // operations in the batch failed; ignoring it silently swallows
+ // real failures. Detection runs before output-mode selection so
+ // the exit code is consistent regardless of how stdout is
+ // rendered. --dry-run short-circuits because no real request
+ // was sent.
+ var partialFailure *partialFailureReport
+ if !flags.dryRun && statusCode >= 200 && statusCode < 300 {
+ partialFailure = detectPartialFailure(data)
+ if partialFailure != nil {
+ fmt.Fprintf(os.Stderr, "warning: partial failure detected in %s response: %s\n", "things", partialFailure.Message)
+ if len(partialFailure.ResourceNames) > 0 {
+ fmt.Fprintf(os.Stderr, " succeeded: %d operation(s)\n", len(partialFailure.ResourceNames))
+ }
+ }
+ }
if wantsHumanTable(cmd.OutOrStdout(), flags) {
// Check if response contains an array (directly or wrapped in "data")
var items []map[string]any
@@ -56,6 +74,9 @@ func newThingsListCmd(flags *rootFlags) *cobra.Command {
if err := printAutoTable(cmd.OutOrStdout(), items); err != nil {
fmt.Fprintf(os.Stderr, "warning: table rendering failed, falling back to JSON: %v\n", err)
} else {
+ if partialFailure != nil && !flags.allowPartialFailure {
+ return partialFailureErr(fmt.Errorf("partial failure in %s response: %s", "things", partialFailure.Message))
+ }
return nil
}
} else {
@@ -66,6 +87,9 @@ func newThingsListCmd(flags *rootFlags) *cobra.Command {
if err := printAutoTable(cmd.OutOrStdout(), wrapped.Data); err != nil {
fmt.Fprintf(os.Stderr, "warning: table rendering failed, falling back to JSON: %v\n", err)
} else {
+ if partialFailure != nil && !flags.allowPartialFailure {
+ return partialFailureErr(fmt.Errorf("partial failure in %s response: %s", "things", partialFailure.Message))
+ }
return nil
}
}
@@ -73,6 +97,9 @@ func newThingsListCmd(flags *rootFlags) *cobra.Command {
}
if flags.asJSON || (!isTerminal(cmd.OutOrStdout()) && !flags.csv && !flags.quiet && !flags.plain) {
if flags.quiet {
+ if partialFailure != nil && !flags.allowPartialFailure {
+ return partialFailureErr(fmt.Errorf("partial failure in %s response: %s", "things", partialFailure.Message))
+ }
return nil
}
// Apply --compact and --select to the API response before wrapping.
@@ -90,7 +117,10 @@ func newThingsListCmd(flags *rootFlags) *cobra.Command {
"resource": "things",
"path": path,
"status": statusCode,
- "success": statusCode >= 200 && statusCode < 300,
+ "success": statusCode >= 200 && statusCode < 300 && (partialFailure == nil || flags.allowPartialFailure),
+ }
+ if partialFailure != nil {
+ envelope["partial_failure"] = partialFailure
}
if flags.dryRun {
envelope["dry_run"] = true
@@ -107,9 +137,28 @@ func newThingsListCmd(flags *rootFlags) *cobra.Command {
if err != nil {
return err
}
- return printOutput(cmd.OutOrStdout(), json.RawMessage(envelopeJSON), true)
+ if perr := printOutput(cmd.OutOrStdout(), json.RawMessage(envelopeJSON), true); perr != nil {
+ return perr
+ }
+ if partialFailure != nil && !flags.allowPartialFailure {
+ return partialFailureErr(fmt.Errorf("partial failure in %s response: %s", "things", partialFailure.Message))
+ }
+ return nil
+ }
+ // Fall-through for mutate paths that did not hit the table or
+ // asJSON branches: --quiet, --csv, --plain, and default terminal
+ // raw output. printOutputWithFlags renders the body, then the
+ // typed partial-failure exit fires unless --allow-partial-failure
+ // downgrades it. Without this guard a partial failure would exit
+ // 0 for these output modes — the exact silent-swallow regression
+ // the surrounding patch is preventing for asJSON / piped output.
+ if perr := printOutputWithFlags(cmd.OutOrStdout(), data, flags); perr != nil {
+ return perr
+ }
+ if partialFailure != nil && !flags.allowPartialFailure {
+ return partialFailureErr(fmt.Errorf("partial failure in %s response: %s", "things", partialFailure.Message))
}
- return printOutputWithFlags(cmd.OutOrStdout(), data, flags)
+ return nil
},
}
cmd.Flags().BoolVar(&stdinBody, "stdin", false, "Read request body as JSON from stdin")
diff --git a/testdata/golden/expected/generate-public-param-names/public-param-golden/internal/cli/stores_create.go b/testdata/golden/expected/generate-public-param-names/public-param-golden/internal/cli/stores_create.go
index c8070688..23fdc7c1 100644
--- a/testdata/golden/expected/generate-public-param-names/public-param-golden/internal/cli/stores_create.go
+++ b/testdata/golden/expected/generate-public-param-names/public-param-golden/internal/cli/stores_create.go
@@ -55,6 +55,24 @@ func newStoresCreateCmd(flags *rootFlags) *cobra.Command {
if err != nil {
return classifyAPIError(err, flags)
}
+ // Inspect the mutate response body for a partial-failure-shaped
+ // field (e.g. Google Ads `partialFailureError`). Several Google
+ // APIs return 200 OK with a partial-failure field when some
+ // operations in the batch failed; ignoring it silently swallows
+ // real failures. Detection runs before output-mode selection so
+ // the exit code is consistent regardless of how stdout is
+ // rendered. --dry-run short-circuits because no real request
+ // was sent.
+ var partialFailure *partialFailureReport
+ if !flags.dryRun && statusCode >= 200 && statusCode < 300 {
+ partialFailure = detectPartialFailure(data)
+ if partialFailure != nil {
+ fmt.Fprintf(os.Stderr, "warning: partial failure detected in %s response: %s\n", "stores", partialFailure.Message)
+ if len(partialFailure.ResourceNames) > 0 {
+ fmt.Fprintf(os.Stderr, " succeeded: %d operation(s)\n", len(partialFailure.ResourceNames))
+ }
+ }
+ }
if wantsHumanTable(cmd.OutOrStdout(), flags) {
// Check if response contains an array (directly or wrapped in "data")
var items []map[string]any
@@ -62,6 +80,9 @@ func newStoresCreateCmd(flags *rootFlags) *cobra.Command {
if err := printAutoTable(cmd.OutOrStdout(), items); err != nil {
fmt.Fprintf(os.Stderr, "warning: table rendering failed, falling back to JSON: %v\n", err)
} else {
+ if partialFailure != nil && !flags.allowPartialFailure {
+ return partialFailureErr(fmt.Errorf("partial failure in %s response: %s", "stores", partialFailure.Message))
+ }
return nil
}
} else {
@@ -72,6 +93,9 @@ func newStoresCreateCmd(flags *rootFlags) *cobra.Command {
if err := printAutoTable(cmd.OutOrStdout(), wrapped.Data); err != nil {
fmt.Fprintf(os.Stderr, "warning: table rendering failed, falling back to JSON: %v\n", err)
} else {
+ if partialFailure != nil && !flags.allowPartialFailure {
+ return partialFailureErr(fmt.Errorf("partial failure in %s response: %s", "stores", partialFailure.Message))
+ }
return nil
}
}
@@ -79,6 +103,9 @@ func newStoresCreateCmd(flags *rootFlags) *cobra.Command {
}
if flags.asJSON || (!isTerminal(cmd.OutOrStdout()) && !flags.csv && !flags.quiet && !flags.plain) {
if flags.quiet {
+ if partialFailure != nil && !flags.allowPartialFailure {
+ return partialFailureErr(fmt.Errorf("partial failure in %s response: %s", "stores", partialFailure.Message))
+ }
return nil
}
// Apply --compact and --select to the API response before wrapping.
@@ -96,7 +123,10 @@ func newStoresCreateCmd(flags *rootFlags) *cobra.Command {
"resource": "stores",
"path": path,
"status": statusCode,
- "success": statusCode >= 200 && statusCode < 300,
+ "success": statusCode >= 200 && statusCode < 300 && (partialFailure == nil || flags.allowPartialFailure),
+ }
+ if partialFailure != nil {
+ envelope["partial_failure"] = partialFailure
}
if flags.dryRun {
envelope["dry_run"] = true
@@ -113,9 +143,28 @@ func newStoresCreateCmd(flags *rootFlags) *cobra.Command {
if err != nil {
return err
}
- return printOutput(cmd.OutOrStdout(), json.RawMessage(envelopeJSON), true)
+ if perr := printOutput(cmd.OutOrStdout(), json.RawMessage(envelopeJSON), true); perr != nil {
+ return perr
+ }
+ if partialFailure != nil && !flags.allowPartialFailure {
+ return partialFailureErr(fmt.Errorf("partial failure in %s response: %s", "stores", partialFailure.Message))
+ }
+ return nil
+ }
+ // Fall-through for mutate paths that did not hit the table or
+ // asJSON branches: --quiet, --csv, --plain, and default terminal
+ // raw output. printOutputWithFlags renders the body, then the
+ // typed partial-failure exit fires unless --allow-partial-failure
+ // downgrades it. Without this guard a partial failure would exit
+ // 0 for these output modes — the exact silent-swallow regression
+ // the surrounding patch is preventing for asJSON / piped output.
+ if perr := printOutputWithFlags(cmd.OutOrStdout(), data, flags); perr != nil {
+ return perr
+ }
+ if partialFailure != nil && !flags.allowPartialFailure {
+ return partialFailureErr(fmt.Errorf("partial failure in %s response: %s", "stores", partialFailure.Message))
}
- return printOutputWithFlags(cmd.OutOrStdout(), data, flags)
+ return nil
},
}
cmd.Flags().StringVar(&bodyStoreCode, "store-code", "", "Store code")
← 80ba507e fix(ci): cancel-in-progress=false so cancelled check_runs do
·
back to Cli Printing Press
·
fix(cli): drop // PATCH marker instruction from generated AG caa68801 →