← back to Cli Printing Press
fix(cli): treat access-denied sync errors as warnings (#274) (#280)
55114c4e344fd9563495745142d41c0551a4ae41 · 2026-04-25 01:46:36 -0700 · Trevin Chow
Generated <cli> sync used to exit non-zero whenever a single auth-restricted
resource failed, even when every other resource synced cleanly. Issue #274's
motivating case: meta-ads-pp-cli's targeting endpoint requires a Workplace-tier
token; on a normal Marketing API token the whole sync run failed despite
accounts syncing correctly.
This change classifies HTTP 403 and HTTP 400-with-access-policy bodies as
non-fatal warnings. Sync exits non-zero only on hard errors or when every
selected resource was access-denied. HTTP 401 stays a hard auth failure
(re-auth needed; not per-resource ACL).
Implementation:
internal/cli/helpers.go (new shared helpers, gated on auth + data layer):
accessWarning struct with Status / Reason / Message fields.
isSyncAccessWarning(err) classifies via errors.As against the typed
*client.APIError (and *client.GraphQLAccessDeniedError on GraphQL
CLIs), not strings.Contains on the error message. Survives any
future change to APIError.Error() format.
looksLikeAccessDenial(body) uses regexp word boundaries (\bforbidden\b,
\binsufficient[\s_-]?(scope|permission|privilege), etc.) so common
substrings inside unrelated tokens (author, pagination_token,
insufficient_funds) do not produce false positives. No API/brand
names baked in; vendor-specific phrasings should be addressed at
the spec/profiler level per AGENTS.md.
No-op stub emitted when the CLI has no auth so sync.go stays
auth-agnostic.
internal/client/graphql.go (GraphQL CLIs only):
*GraphQLAccessDeniedError returned from Query when every error in the
response carries an access-policy extension code (FORBIDDEN,
UNAUTHENTICATED, UNAUTHORIZED, PERMISSION_DENIED). Closes the gap
where field-level GraphQL access denials (HTTP 200 + errors[]) had
no transport HTTP status to substring-match on.
internal/cli/sync.go (and graphql variant):
Inline helpers removed; both templates call into the shared one.
sync_warning JSON event now carries discrete status, reason, and
message fields instead of a free-form warning string.
New {"event":"sync_summary","total_records":N,"resources":N,
"success":N,"warned":N,"errored":N,"duration_ms":N} aggregate event
in non-human mode so agents shelling out to sync get a single line
to inspect for run outcome.
syncDependentResource tracks deniedParents and returns a Warn-shaped
syncResult when every parent was access-denied with zero records
synced (the previously-dead Warn branch in the dep-result loop is
now reachable).
Resource-count denominator switched from len(resources) to
successCount+warnCount+errCount so dependent resources are counted
correctly in the summary line and totals event.
Long help text gains an "Exit codes & warnings" paragraph documenting
the contract.
internal/generator/generator.go:
Registered isGraphQL template func so helpers.go.tmpl can conditionally
include the GraphQL access-denial type.
Test additions:
TestGeneratedSyncTreatsAccessDeniedAsWarning now runs go mod tidy + go
build on the generated CLI (catches template syntax / import errors
that substring assertions cannot) and injects a behavioral test file
into the generated CLI exercising isSyncAccessWarning against real
*client.APIError values. The behavioral matrix covers positive cases
(403, 400+keywords, wrapped errors) and negative cases that would have
been false positives under the pre-fix bare-substring matcher: 401
(re-auth, not ACL), 500, validation 400 with "token field required",
billing 400 with "insufficient_funds", body containing "pagination_token",
path containing "/authors". Asserts the brand-name keywords are absent
from the rendered template (regression guard for AGENTS.md compliance).
Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Files touched
M internal/generator/generator.goM internal/generator/generator_test.goM internal/generator/templates/graphql_client.go.tmplM internal/generator/templates/graphql_sync.go.tmplM internal/generator/templates/helpers.go.tmplM internal/generator/templates/sync.go.tmpl
Diff
commit 55114c4e344fd9563495745142d41c0551a4ae41
Author: Trevin Chow <trevin@trevinchow.com>
Date: Sat Apr 25 01:46:36 2026 -0700
fix(cli): treat access-denied sync errors as warnings (#274) (#280)
Generated <cli> sync used to exit non-zero whenever a single auth-restricted
resource failed, even when every other resource synced cleanly. Issue #274's
motivating case: meta-ads-pp-cli's targeting endpoint requires a Workplace-tier
token; on a normal Marketing API token the whole sync run failed despite
accounts syncing correctly.
This change classifies HTTP 403 and HTTP 400-with-access-policy bodies as
non-fatal warnings. Sync exits non-zero only on hard errors or when every
selected resource was access-denied. HTTP 401 stays a hard auth failure
(re-auth needed; not per-resource ACL).
Implementation:
internal/cli/helpers.go (new shared helpers, gated on auth + data layer):
accessWarning struct with Status / Reason / Message fields.
isSyncAccessWarning(err) classifies via errors.As against the typed
*client.APIError (and *client.GraphQLAccessDeniedError on GraphQL
CLIs), not strings.Contains on the error message. Survives any
future change to APIError.Error() format.
looksLikeAccessDenial(body) uses regexp word boundaries (\bforbidden\b,
\binsufficient[\s_-]?(scope|permission|privilege), etc.) so common
substrings inside unrelated tokens (author, pagination_token,
insufficient_funds) do not produce false positives. No API/brand
names baked in; vendor-specific phrasings should be addressed at
the spec/profiler level per AGENTS.md.
No-op stub emitted when the CLI has no auth so sync.go stays
auth-agnostic.
internal/client/graphql.go (GraphQL CLIs only):
*GraphQLAccessDeniedError returned from Query when every error in the
response carries an access-policy extension code (FORBIDDEN,
UNAUTHENTICATED, UNAUTHORIZED, PERMISSION_DENIED). Closes the gap
where field-level GraphQL access denials (HTTP 200 + errors[]) had
no transport HTTP status to substring-match on.
internal/cli/sync.go (and graphql variant):
Inline helpers removed; both templates call into the shared one.
sync_warning JSON event now carries discrete status, reason, and
message fields instead of a free-form warning string.
New {"event":"sync_summary","total_records":N,"resources":N,
"success":N,"warned":N,"errored":N,"duration_ms":N} aggregate event
in non-human mode so agents shelling out to sync get a single line
to inspect for run outcome.
syncDependentResource tracks deniedParents and returns a Warn-shaped
syncResult when every parent was access-denied with zero records
synced (the previously-dead Warn branch in the dep-result loop is
now reachable).
Resource-count denominator switched from len(resources) to
successCount+warnCount+errCount so dependent resources are counted
correctly in the summary line and totals event.
Long help text gains an "Exit codes & warnings" paragraph documenting
the contract.
internal/generator/generator.go:
Registered isGraphQL template func so helpers.go.tmpl can conditionally
include the GraphQL access-denial type.
Test additions:
TestGeneratedSyncTreatsAccessDeniedAsWarning now runs go mod tidy + go
build on the generated CLI (catches template syntax / import errors
that substring assertions cannot) and injects a behavioral test file
into the generated CLI exercising isSyncAccessWarning against real
*client.APIError values. The behavioral matrix covers positive cases
(403, 400+keywords, wrapped errors) and negative cases that would have
been false positives under the pre-fix bare-substring matcher: 401
(re-auth, not ACL), 500, validation 400 with "token field required",
billing 400 with "insufficient_funds", body containing "pagination_token",
path containing "/authors". Asserts the brand-name keywords are absent
from the rendered template (regression guard for AGENTS.md compliance).
Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
---
internal/generator/generator.go | 5 +-
internal/generator/generator_test.go | 145 +++++++++++++++++++++
.../generator/templates/graphql_client.go.tmpl | 35 +++++
internal/generator/templates/graphql_sync.go.tmpl | 45 ++++++-
internal/generator/templates/helpers.go.tmpl | 103 +++++++++++++++
internal/generator/templates/sync.go.tmpl | 80 +++++++++++-
6 files changed, 403 insertions(+), 10 deletions(-)
diff --git a/internal/generator/generator.go b/internal/generator/generator.go
index da0c669f..456000fa 100644
--- a/internal/generator/generator.go
+++ b/internal/generator/generator.go
@@ -216,8 +216,9 @@ func New(s *spec.APISpec, outputDir string) *Generator {
"graphqlFieldSelection": func(typeName string, types map[string]spec.TypeDef) []string {
return graphqlFieldSelection(typeName, types)
},
- "backtick": func() string { return "`" },
- "kebab": toKebab,
+ "isGraphQL": isGraphQLSpec,
+ "backtick": func() string { return "`" },
+ "kebab": toKebab,
"humanName": func(s string) string {
// "steam-web" → "Steam Web", "notion" → "Notion"
return cases.Title(language.English).String(strings.ReplaceAll(s, "-", " "))
diff --git a/internal/generator/generator_test.go b/internal/generator/generator_test.go
index 6e4f21c7..9d341635 100644
--- a/internal/generator/generator_test.go
+++ b/internal/generator/generator_test.go
@@ -3023,6 +3023,151 @@ func TestGenerateDependentSyncReservedWordCompiles(t *testing.T) {
runGoCommand(t, outputDir, "test", "./internal/store")
}
+func TestGeneratedSyncTreatsAccessDeniedAsWarning(t *testing.T) {
+ t.Parallel()
+
+ apiSpec := &spec.APISpec{
+ Name: "metasync",
+ Version: "0.1.0",
+ BaseURL: "https://graph.example.com",
+ Auth: spec.AuthConfig{
+ Type: "bearer_token",
+ Header: "Authorization",
+ EnvVars: []string{"METASYNC_TOKEN"},
+ },
+ Config: spec.ConfigSpec{
+ Format: "toml",
+ Path: "~/.config/metasync-pp-cli/config.toml",
+ },
+ Resources: map[string]spec.Resource{
+ "accounts": {
+ Description: "Manage accounts",
+ Endpoints: map[string]spec.Endpoint{
+ "list": {
+ Method: "GET",
+ Path: "/me/adaccounts",
+ Description: "List accounts",
+ Response: spec.ResponseDef{Type: "array"},
+ Pagination: &spec.Pagination{CursorParam: "after", LimitParam: "limit"},
+ },
+ },
+ },
+ "targeting": {
+ Description: "Manage targeting",
+ Endpoints: map[string]spec.Endpoint{
+ "search": {
+ Method: "GET",
+ Path: "/search",
+ Description: "Search targeting",
+ Response: spec.ResponseDef{Type: "array"},
+ Pagination: &spec.Pagination{CursorParam: "after", LimitParam: "limit"},
+ },
+ },
+ },
+ },
+ }
+
+ outputDir := filepath.Join(t.TempDir(), naming.CLI(apiSpec.Name))
+ gen := New(apiSpec, outputDir)
+ require.NoError(t, gen.Generate())
+
+ syncGo, err := os.ReadFile(filepath.Join(outputDir, "internal", "cli", "sync.go"))
+ require.NoError(t, err)
+ syncContent := string(syncGo)
+
+ // Sync emits the structured warn event and routes to the warn-aware exit branch.
+ assert.Contains(t, syncContent, `Warn error`)
+ assert.Contains(t, syncContent, `{"event":"sync_warning"`)
+ assert.Contains(t, syncContent, `"status":%d,"reason":"%s"`)
+ assert.Contains(t, syncContent, `{"event":"sync_summary"`)
+ assert.Contains(t, syncContent, `Sync complete: %d records across %d resources (%d warned, %.1fs)`)
+ assert.Contains(t, syncContent, `successCount == 0`)
+ assert.Contains(t, syncContent, `skipped due to insufficient access`)
+ assert.Contains(t, syncContent, `return nil`)
+ // The classifier moved to helpers.go; sync.go must call into it, not redefine it.
+ assert.Contains(t, syncContent, `isSyncAccessWarning(err)`)
+ assert.NotContains(t, syncContent, `func isSyncAccessWarning`)
+ assert.NotContains(t, syncContent, `func looksLikeSyncAccessDenial`)
+ // AGENTS.md: do not hardcode one API into reusable machine artifacts. The
+ // pre-fix patch leaked Meta-specific brand names into every printed CLI;
+ // guard against regression.
+ assert.NotContains(t, syncContent, `"workplace"`)
+
+ helpersGo, err := os.ReadFile(filepath.Join(outputDir, "internal", "cli", "helpers.go"))
+ require.NoError(t, err)
+ helpersContent := string(helpersGo)
+ assert.Contains(t, helpersContent, `func isSyncAccessWarning(err error) (*accessWarning, bool)`)
+ assert.Contains(t, helpersContent, `func looksLikeAccessDenial(body string) bool`)
+ assert.Contains(t, helpersContent, `*client.APIError`)
+ assert.Contains(t, helpersContent, `errors.As`)
+ assert.NotContains(t, helpersContent, `"workplace"`)
+
+ // Build to catch template-syntax / import errors that substring assertions miss.
+ runGoCommand(t, outputDir, "mod", "tidy")
+ runGoCommand(t, outputDir, "build", "./...")
+
+ // Inject a behavioral test that exercises isSyncAccessWarning against real
+ // *client.APIError values plus the false-positive vectors flagged in code
+ // review (path containing "auth", body "validation failed: token required",
+ // "insufficient_funds", HTTP 500). This catches regressions where the helper
+ // gets stubbed to "return nil, true" or its negative cases stop holding.
+ behaviorTest := `package cli
+
+import (
+ "errors"
+ "fmt"
+ "testing"
+
+ "metasync-pp-cli/internal/client"
+)
+
+func TestIsSyncAccessWarningClassification(t *testing.T) {
+ cases := []struct {
+ name string
+ err error
+ wantOK bool
+ wantStatus int
+ wantReason string
+ }{
+ {"nil error", nil, false, 0, ""},
+ {"403 forbidden", &client.APIError{Method: "GET", Path: "/me/ads", StatusCode: 403, Body: "forbidden: missing scope"}, true, 403, "forbidden"},
+ {"400 with insufficient scope", &client.APIError{Method: "GET", Path: "/search", StatusCode: 400, Body: "(#27) insufficient scope to call this method"}, true, 400, "insufficient_access"},
+ {"400 with permission denied", &client.APIError{Method: "GET", Path: "/foo", StatusCode: 400, Body: "permission denied for resource"}, true, 400, "insufficient_access"},
+ {"400 with unauthorized", &client.APIError{Method: "GET", Path: "/foo", StatusCode: 400, Body: "unauthorized"}, true, 400, "insufficient_access"},
+ // Negative cases — these MUST NOT classify as access warnings.
+ {"401 token expired (whole-CLI re-auth, not per-resource ACL)", &client.APIError{Method: "GET", Path: "/foo", StatusCode: 401, Body: "token expired"}, false, 0, ""},
+ {"500 server error", &client.APIError{Method: "GET", Path: "/foo", StatusCode: 500, Body: "internal error"}, false, 0, ""},
+ {"400 validation: missing token field", &client.APIError{Method: "GET", Path: "/foo", StatusCode: 400, Body: "validation failed: token field is required"}, false, 0, ""},
+ {"400 billing: insufficient_funds", &client.APIError{Method: "POST", Path: "/charges", StatusCode: 400, Body: "{\"error\":\"insufficient_funds\"}"}, false, 0, ""},
+ {"400 with pagination_token in body", &client.APIError{Method: "GET", Path: "/foo", StatusCode: 400, Body: "invalid pagination_token: malformed cursor"}, false, 0, ""},
+ {"400 path /authors with no body keyword", &client.APIError{Method: "GET", Path: "/authors/123", StatusCode: 400, Body: "id not found"}, false, 0, ""},
+ {"plain Go error", errors.New("connection refused"), false, 0, ""},
+ {"wrapped 403 still detected via errors.As", fmt.Errorf("fetching foo: %w", &client.APIError{Method: "GET", Path: "/foo", StatusCode: 403, Body: "forbidden"}), true, 403, "forbidden"},
+ }
+ for _, tc := range cases {
+ t.Run(tc.name, func(t *testing.T) {
+ w, ok := isSyncAccessWarning(tc.err)
+ if ok != tc.wantOK {
+ t.Fatalf("ok = %v, want %v (err=%v)", ok, tc.wantOK, tc.err)
+ }
+ if !ok {
+ return
+ }
+ if w.Status != tc.wantStatus {
+ t.Errorf("status = %d, want %d", w.Status, tc.wantStatus)
+ }
+ if w.Reason != tc.wantReason {
+ t.Errorf("reason = %q, want %q", w.Reason, tc.wantReason)
+ }
+ })
+ }
+}
+`
+ testPath := filepath.Join(outputDir, "internal", "cli", "sync_classify_test.go")
+ require.NoError(t, os.WriteFile(testPath, []byte(behaviorTest), 0o644))
+ runGoCommand(t, outputDir, "test", "./internal/cli", "-run", "TestIsSyncAccessWarningClassification")
+}
+
func TestGenerateGraphQLCompiles(t *testing.T) {
t.Parallel()
diff --git a/internal/generator/templates/graphql_client.go.tmpl b/internal/generator/templates/graphql_client.go.tmpl
index e7aad2af..d037b5dc 100644
--- a/internal/generator/templates/graphql_client.go.tmpl
+++ b/internal/generator/templates/graphql_client.go.tmpl
@@ -29,6 +29,30 @@ type GraphQLError struct {
} `json:"extensions"`
}
+// GraphQLAccessDeniedError is returned when every GraphQL error in a response
+// carries an access-policy extension code (FORBIDDEN, UNAUTHENTICATED,
+// PERMISSION_DENIED). Callers detect it via errors.As to distinguish access
+// denial from generic GraphQL errors.
+type GraphQLAccessDeniedError struct {
+ Codes []string
+ Messages []string
+}
+
+func (e *GraphQLAccessDeniedError) Error() string {
+ return fmt.Sprintf("graphql access denied: %s", strings.Join(e.Messages, "; "))
+}
+
+// isGraphQLAccessDenialCode reports whether code is a recognized access-denial
+// extension code. The set follows the convention used by Apollo, GraphQL-Yoga,
+// and the GitHub/Shopify/Hasura ecosystems.
+func isGraphQLAccessDenialCode(code string) bool {
+ switch strings.ToUpper(code) {
+ case "FORBIDDEN", "UNAUTHENTICATED", "UNAUTHORIZED", "PERMISSION_DENIED":
+ return true
+ }
+ return false
+}
+
// Query executes a GraphQL query via POST /graphql and returns the raw data payload.
func (c *Client) Query(query string, variables map[string]any) (json.RawMessage, error) {
req := GraphQLRequest{Query: query, Variables: variables}
@@ -43,8 +67,19 @@ func (c *Client) Query(query string, variables map[string]any) (json.RawMessage,
}
if len(resp.Errors) > 0 {
msgs := make([]string, len(resp.Errors))
+ codes := make([]string, 0, len(resp.Errors))
+ allDenial := true
for i, e := range resp.Errors {
msgs[i] = e.Message
+ if e.Extensions.Code != "" {
+ codes = append(codes, e.Extensions.Code)
+ }
+ if !isGraphQLAccessDenialCode(e.Extensions.Code) {
+ allDenial = false
+ }
+ }
+ if allDenial && len(codes) > 0 {
+ return nil, &GraphQLAccessDeniedError{Codes: codes, Messages: msgs}
}
return nil, fmt.Errorf("graphql: %s", strings.Join(msgs, "; "))
}
diff --git a/internal/generator/templates/graphql_sync.go.tmpl b/internal/generator/templates/graphql_sync.go.tmpl
index af260814..e5d799b9 100644
--- a/internal/generator/templates/graphql_sync.go.tmpl
+++ b/internal/generator/templates/graphql_sync.go.tmpl
@@ -23,6 +23,7 @@ type syncResult struct {
Resource string
Count int
Err error
+ Warn error
Duration time.Duration
}
@@ -40,7 +41,17 @@ func newSyncCmd(flags *rootFlags) *cobra.Command {
Short: "Sync API data to local SQLite for offline search and analysis",
Long: `Sync data from the API into a local SQLite database. Supports resumable
incremental sync (only fetches new data since last sync) and full resync.
-Once synced, use the 'search' command for instant full-text search.`,
+Once synced, use the 'search' command for instant full-text search.
+
+Exit codes & warnings:
+ Resources the API denies access to (HTTP 403, HTTP 400 with an
+ access-policy body, or GraphQL errors carrying FORBIDDEN /
+ UNAUTHENTICATED / PERMISSION_DENIED extensions) are reported as
+ warnings rather than failing the run. In --json mode each is emitted
+ as a {"event":"sync_warning",...} line carrying status, reason, and
+ message fields, and a final {"event":"sync_summary",...} aggregates
+ the run. The command exits non-zero only when every selected
+ resource was access-denied or any resource hit a hard error.`,
Example: ` # Sync all resources
{{.Name}}-pp-cli sync
@@ -146,27 +157,48 @@ Once synced, use the 'search' command for instant full-text search.`,
var totalSynced int
var errCount int
+ var warnCount int
+ var successCount int
for res := range results {
if res.Err != nil {
if humanFriendly {
fmt.Fprintf(os.Stderr, " %s: error: %v\n", res.Resource, res.Err)
}
errCount++
+ } else if res.Warn != nil {
+ if humanFriendly {
+ fmt.Fprintf(os.Stderr, " %s: warning: %v\n", res.Resource, res.Warn)
+ }
+ warnCount++
} else {
if humanFriendly {
fmt.Fprintf(os.Stderr, " %s: %d synced (done)\n", res.Resource, res.Count)
}
totalSynced += res.Count
+ successCount++
}
}
elapsed := time.Since(started)
- fmt.Fprintf(os.Stderr, "Sync complete: %d records across %d resources (%.1fs)\n",
- totalSynced, len(resources), elapsed.Seconds())
+ totalResources := successCount + warnCount + errCount
+ if !humanFriendly {
+ fmt.Fprintf(os.Stderr, `{"event":"sync_summary","total_records":%d,"resources":%d,"success":%d,"warned":%d,"errored":%d,"duration_ms":%d}`+"\n",
+ totalSynced, totalResources, successCount, warnCount, errCount, elapsed.Milliseconds())
+ }
+ if warnCount > 0 {
+ fmt.Fprintf(os.Stderr, "Sync complete: %d records across %d resources (%d warned, %.1fs)\n",
+ totalSynced, totalResources, warnCount, elapsed.Seconds())
+ } else {
+ fmt.Fprintf(os.Stderr, "Sync complete: %d records across %d resources (%.1fs)\n",
+ totalSynced, totalResources, elapsed.Seconds())
+ }
if errCount > 0 {
return fmt.Errorf("%d resource(s) failed to sync", errCount)
}
+ if warnCount > 0 && successCount == 0 {
+ return fmt.Errorf("%d resource(s) skipped due to insufficient access", warnCount)
+ }
return nil
},
}
@@ -237,6 +269,13 @@ func syncResource(c *client.Client, db *store.Store, resource, sinceTS string, f
data, err := c.Query(def.Query, variables)
if err != nil {
+ if w, ok := isSyncAccessWarning(err); ok {
+ if !humanFriendly {
+ fmt.Fprintf(os.Stderr, `{"event":"sync_warning","resource":"%s","status":%d,"reason":"%s","message":"%s"}`+"\n",
+ resource, w.Status, w.Reason, strings.ReplaceAll(w.Message, `"`, `\"`))
+ }
+ return syncResult{Resource: resource, Count: totalCount, Warn: fmt.Errorf("skipped %s: %s", resource, w.Reason), Duration: time.Since(started)}
+ }
if !humanFriendly {
fmt.Fprintf(os.Stderr, `{"event":"sync_error","resource":"%s","error":"%s"}`+"\n", resource, strings.ReplaceAll(err.Error(), `"`, `\"`))
}
diff --git a/internal/generator/templates/helpers.go.tmpl b/internal/generator/templates/helpers.go.tmpl
index ccb1e88f..13b38a8d 100644
--- a/internal/generator/templates/helpers.go.tmpl
+++ b/internal/generator/templates/helpers.go.tmpl
@@ -23,6 +23,9 @@ import (
{{- end}}
"unicode"
+{{- if and (and .Auth.Type (ne .Auth.Type "none")) .HasDataLayer}}
+ "{{modulePath}}/internal/client"
+{{- end}}
"github.com/spf13/cobra"
"github.com/spf13/pflag"
)
@@ -140,6 +143,106 @@ func sanitizeErrorBody(msg string) string {
}
{{- end}}
+{{- if .HasDataLayer}}
+
+// accessWarning describes an API access-denial that sync converts into a
+// non-fatal warning. It carries enough structured data for the sync_warning
+// JSON event without parsing free-form error strings downstream.
+type accessWarning struct {
+ Status int // HTTP status when applicable; 0 for GraphQL field-level denials.
+ Reason string // "forbidden" | "insufficient_access" | "unauthenticated"
+ Message string // human-readable detail (the API's body or GraphQL error message)
+}
+{{- end}}
+
+{{- if and (and .Auth.Type (ne .Auth.Type "none")) .HasDataLayer}}
+
+// accessDenialPatterns matches API error bodies that indicate the request was
+// rejected for access-policy reasons rather than for input validity. Matching
+// is case-insensitive and uses word boundaries so common substrings inside
+// unrelated tokens (e.g. "author", "pagination_token", "insufficient_funds")
+// do not produce false positives. The set deliberately excludes brand names —
+// vendor-specific phrasings should be addressed at the spec/profiler level,
+// not in this universal classifier.
+var accessDenialPatterns = []*regexp.Regexp{
+ regexp.MustCompile(`\bforbidden\b`),
+ regexp.MustCompile(`\bunauthorized\b`),
+ regexp.MustCompile(`\bnot[\s_-]?authorized\b`),
+ regexp.MustCompile(`\bpermission[\s_-]?denied\b`),
+ regexp.MustCompile(`\baccess[\s_-]?denied\b`),
+ regexp.MustCompile(`\binsufficient[\s_-]?(scope|permission|privilege)`),
+ regexp.MustCompile(`\binvalid[\s_-]?scope\b`),
+ regexp.MustCompile(`\bmissing[\s_-]?scope\b`),
+ regexp.MustCompile(`\brequires?\s+(elevated|admin|enterprise|business|workspace|enterprise[\s_-]?tier)`),
+}
+
+// looksLikeAccessDenial reports whether body text describes an access-policy
+// rejection. Use it on response-body content (apiErr.Body), not on the full
+// error string — the request path can contain words like "auth" or "tokens"
+// that would produce false positives if the whole error message were scanned.
+func looksLikeAccessDenial(body string) bool {
+ lower := strings.ToLower(body)
+ for _, p := range accessDenialPatterns {
+ if p.MatchString(lower) {
+ return true
+ }
+ }
+ return false
+}
+
+// isSyncAccessWarning classifies err as an access-denial warning suitable for
+// sync's warn-and-continue path. It returns nil, false for any error that
+// should remain a hard sync failure: HTTP 401 (token-level auth failure
+// requiring re-auth), 5xx, network errors, and HTTP 400 responses whose
+// bodies do not match an access-policy pattern.
+//
+// Recognized warning shapes:
+// - HTTP 403 (per-resource ACL rejection)
+// - HTTP 400 + access-denial body keyword (insufficient scope, etc.)
+// - GraphQL response carrying only access-denial extension codes
+func isSyncAccessWarning(err error) (*accessWarning, bool) {
+ if err == nil {
+ return nil, false
+ }
+
+ var apiErr *client.APIError
+ if errors.As(err, &apiErr) {
+ switch apiErr.StatusCode {
+ case 403:
+ return &accessWarning{Status: 403, Reason: "forbidden", Message: apiErr.Body}, true
+ case 400:
+ if looksLikeAccessDenial(apiErr.Body) {
+ return &accessWarning{Status: 400, Reason: "insufficient_access", Message: apiErr.Body}, true
+ }
+ }
+ }
+{{- if isGraphQL .APISpec}}
+
+ var gqlErr *client.GraphQLAccessDeniedError
+ if errors.As(err, &gqlErr) {
+ reason := "forbidden"
+ for _, code := range gqlErr.Codes {
+ upper := strings.ToUpper(code)
+ if upper == "UNAUTHENTICATED" || upper == "UNAUTHORIZED" {
+ reason = "unauthenticated"
+ break
+ }
+ }
+ return &accessWarning{Status: 0, Reason: reason, Message: strings.Join(gqlErr.Messages, "; ")}, true
+ }
+{{- end}}
+
+ return nil, false
+}
+{{- else if .HasDataLayer}}
+
+// isSyncAccessWarning is a stub: this CLI has no auth, so the API cannot
+// reject sync requests on access-policy grounds. Every error stays a hard
+// failure. Defining the function unconditionally keeps sync.go agnostic to
+// auth presence.
+func isSyncAccessWarning(err error) (*accessWarning, bool) { return nil, false }
+{{- end}}
+
// classifyAPIError maps API errors to structured exit codes with actionable hints.
func classifyAPIError(err error) error {
msg := err.Error()
diff --git a/internal/generator/templates/sync.go.tmpl b/internal/generator/templates/sync.go.tmpl
index aa843495..e237a912 100644
--- a/internal/generator/templates/sync.go.tmpl
+++ b/internal/generator/templates/sync.go.tmpl
@@ -23,6 +23,7 @@ type syncResult struct {
Resource string
Count int
Err error
+ Warn error
Duration time.Duration
}
@@ -43,7 +44,16 @@ func newSyncCmd(flags *rootFlags) *cobra.Command {
Short: "Sync API data to local SQLite for offline search and analysis",
Long: `Sync data from the API into a local SQLite database. Supports resumable
incremental sync (only fetches new data since last sync) and full resync.
-Once synced, use the 'search' command for instant full-text search.`,
+Once synced, use the 'search' command for instant full-text search.
+
+Exit codes & warnings:
+ Resources the API denies access to (HTTP 403, or HTTP 400 with an
+ access-policy body) are reported as warnings rather than failing the
+ run. In --json mode each is emitted as a {"event":"sync_warning",...}
+ line carrying status, reason, and message fields, and a final
+ {"event":"sync_summary",...} aggregates the run. The command exits
+ non-zero only when every selected resource was access-denied or any
+ resource hit a hard error.`,
Example: ` # Sync all resources
{{.Name}}-pp-cli sync
@@ -167,17 +177,25 @@ Once synced, use the 'search' command for instant full-text search.`,
var totalSynced int
var errCount int
+ var warnCount int
+ var successCount int
for res := range results {
if res.Err != nil {
if humanFriendly {
fmt.Fprintf(os.Stderr, " %s: error: %v\n", res.Resource, res.Err)
}
errCount++
+ } else if res.Warn != nil {
+ if humanFriendly {
+ fmt.Fprintf(os.Stderr, " %s: warning: %v\n", res.Resource, res.Warn)
+ }
+ warnCount++
} else {
if humanFriendly {
fmt.Fprintf(os.Stderr, " %s: %d synced (done)\n", res.Resource, res.Count)
}
totalSynced += res.Count
+ successCount++
}
}
@@ -190,22 +208,41 @@ Once synced, use the 'search' command for instant full-text search.`,
fmt.Fprintf(os.Stderr, " %s: error: %v\n", res.Resource, res.Err)
}
errCount++
+ } else if res.Warn != nil {
+ if humanFriendly {
+ fmt.Fprintf(os.Stderr, " %s: warning: %v\n", res.Resource, res.Warn)
+ }
+ warnCount++
} else {
if humanFriendly {
fmt.Fprintf(os.Stderr, " %s: %d synced (done)\n", res.Resource, res.Count)
}
totalSynced += res.Count
+ successCount++
}
}
{{- end}}
elapsed := time.Since(started)
- fmt.Fprintf(os.Stderr, "Sync complete: %d records across %d resources (%.1fs)\n",
- totalSynced, len(resources), elapsed.Seconds())
+ totalResources := successCount + warnCount + errCount
+ if !humanFriendly {
+ fmt.Fprintf(os.Stderr, `{"event":"sync_summary","total_records":%d,"resources":%d,"success":%d,"warned":%d,"errored":%d,"duration_ms":%d}`+"\n",
+ totalSynced, totalResources, successCount, warnCount, errCount, elapsed.Milliseconds())
+ }
+ if warnCount > 0 {
+ fmt.Fprintf(os.Stderr, "Sync complete: %d records across %d resources (%d warned, %.1fs)\n",
+ totalSynced, totalResources, warnCount, elapsed.Seconds())
+ } else {
+ fmt.Fprintf(os.Stderr, "Sync complete: %d records across %d resources (%.1fs)\n",
+ totalSynced, totalResources, elapsed.Seconds())
+ }
if errCount > 0 {
return fmt.Errorf("%d resource(s) failed to sync", errCount)
}
+ if warnCount > 0 && successCount == 0 {
+ return fmt.Errorf("%d resource(s) skipped due to insufficient access", warnCount)
+ }
return nil
},
}
@@ -282,6 +319,13 @@ func syncResource(c interface {
data, err := c.Get(path, params)
if err != nil {
+ if w, ok := isSyncAccessWarning(err); ok {
+ if !humanFriendly {
+ fmt.Fprintf(os.Stderr, `{"event":"sync_warning","resource":"%s","status":%d,"reason":"%s","message":"%s"}`+"\n",
+ resource, w.Status, w.Reason, strings.ReplaceAll(w.Message, `"`, `\"`))
+ }
+ return syncResult{Resource: resource, Count: totalCount, Warn: fmt.Errorf("skipped %s: %s", resource, w.Reason), Duration: time.Since(started)}
+ }
if !humanFriendly {
fmt.Fprintf(os.Stderr, `{"event":"sync_error","resource":"%s","error":"%s"}`+"\n", resource, strings.ReplaceAll(err.Error(), `"`, `\"`))
}
@@ -616,6 +660,8 @@ func syncDependentResource(c interface {
}
var totalCount int
+ var deniedParents int
+ var firstDenial *accessWarning
pageSize := determinePaginationDefaults()
for idx, parentID := range parentIDs {
@@ -646,8 +692,21 @@ func syncDependentResource(c interface {
data, err := c.Get(path, params)
if err != nil {
- // Non-fatal per parent: log and continue to next parent
- if humanFriendly {
+ // Non-fatal per parent: log and continue to next parent.
+ // Track access-denial separately so an all-denied dependent
+ // resource can surface as a Warn rather than silent success.
+ if w, ok := isSyncAccessWarning(err); ok {
+ deniedParents++
+ if firstDenial == nil {
+ firstDenial = w
+ }
+ if humanFriendly {
+ fmt.Fprintf(os.Stderr, "\n %s: access denied for parent %s: %s\n", dep.Name, parentID, w.Reason)
+ } else {
+ fmt.Fprintf(os.Stderr, `{"event":"sync_warning","resource":"%s","parent":"%s","status":%d,"reason":"%s","message":"%s"}`+"\n",
+ dep.Name, parentID, w.Status, w.Reason, strings.ReplaceAll(w.Message, `"`, `\"`))
+ }
+ } else if humanFriendly {
fmt.Fprintf(os.Stderr, "\n %s: error for parent %s: %v\n", dep.Name, parentID, err)
}
break
@@ -698,6 +757,17 @@ func syncDependentResource(c interface {
}
_ = db.SaveSyncState(dep.Name, "", totalCount)
+
+ // If every parent was access-denied and nothing was synced, surface as a
+ // warning so the run-level summary and exit code reflect insufficient access.
+ if deniedParents == len(parentIDs) && totalCount == 0 && firstDenial != nil {
+ return syncResult{
+ Resource: dep.Name,
+ Count: 0,
+ Warn: fmt.Errorf("skipped %s: %s on all %d parents", dep.Name, firstDenial.Reason, len(parentIDs)),
+ Duration: time.Since(started),
+ }
+ }
return syncResult{Resource: dep.Name, Count: totalCount, Duration: time.Since(started)}
}
{{- end}}
← ea0fbe94 fix(cli): validate generated JSON string flags (#278)
·
back to Cli Printing Press
·
fix(cli): honor explicit --output flag in generate (#281) 6bbae936 →