Skip to content

Commit 1ee4cb2

Browse files
committed
feat(drive): emit typed error envelopes across the drive domain
Drive-domain errors now leave the CLI as typed, machine-branchable envelopes — a stable `type` plus `subtype` and named fields (param, params, retryable, log_id, hint) — so scripts and AI agents can branch on structure and act on a recovery hint instead of parsing prose. Changes: - Every error produced in the drive domain — validation, file I/O, and the failures returned from its Lark API calls — is emitted as a typed errs.* error; the exit code is derived from the error category. Drive's API calls now go through a shared typed classifier, so failures carry subtype, troubleshooter, a recovery hint, and the request's log_id whether the server returns it in the response body or the x-tt-logid header; an already-typed network/auth error is never downgraded into a generic API error. - Known API conditions (resource conflict, cross-tenant, cross-brand, ...) carry a recovery hint keyed by their error class; a command can refine that hint with command-specific guidance. - Batch partial failures (+push / +pull / +sync, where some items succeed and some fail) now report an honest ok:false multi-status result on stdout — the summary and every per-item outcome stay machine-readable — and exit non-zero, instead of a misleading ok:true success envelope. - Duplicate rel_path conflicts report each colliding path as a structured params entry (RFC 7807 invalid-params style). - Static guards lock the drive path so legacy error construction — direct envelopes or the auto-classifying API helpers — cannot be reintroduced, making drive the template for the remaining domains. Output changes worth noting for consumers: - Error envelopes now carry typed type/subtype and named fields; exit codes follow the error category (malformed or incomplete API responses are reported as internal errors rather than generic API errors). - Batch partial failures (+push / +pull / +sync) emit an ok:false result envelope on stdout (summary + per-item items[]) and exit non-zero; the per-item results stay on stdout rather than in a stderr error envelope. Errors surfaced through shared cross-domain helpers (scope precheck, media import upload, metadata lookup, save-path resolution) are not yet typed; they migrate with the shared layer in a follow-up change.
1 parent 0aa9e96 commit 1ee4cb2

56 files changed

Lines changed: 2115 additions & 813 deletions

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

.golangci.yml

Lines changed: 31 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -65,10 +65,23 @@ linters:
6565
- forbidigo
6666
# errs-typed-only enforced on paths already migrated to errs.NewXxxError.
6767
# Add a path when its migration is complete.
68-
- path-except: (internal/auth/|internal/errcompat/|internal/errclass/|internal/client/|internal/cmdutil/factory\.go|cmd/auth/|cmd/config/|cmd/service/|shortcuts/common/mcp_client\.go|shortcuts/calendar/helpers\.go)
68+
- path-except: (internal/auth/|internal/errcompat/|internal/errclass/|internal/client/|internal/cmdutil/factory\.go|cmd/auth/|cmd/config/|cmd/service/|shortcuts/common/mcp_client\.go|shortcuts/calendar/helpers\.go|shortcuts/drive/)
6969
text: errs-typed-only
7070
linters:
7171
- forbidigo
72+
# errs-no-bare-wrap enforced on paths fully migrated to typed final
73+
# errors. Scoped separately from errs-typed-only because cmd/auth/,
74+
# cmd/config/ still have residual fmt.Errorf and must not be caught.
75+
- path-except: (shortcuts/drive/|shortcuts/calendar/helpers\.go|shortcuts/common/mcp_client\.go)
76+
text: errs-no-bare-wrap
77+
linters:
78+
- forbidigo
79+
# errs-no-legacy-helper is drive-only: the shared helpers it bans are
80+
# still used by other domains until their later migration phase.
81+
- path-except: (shortcuts/drive/)
82+
text: errs-no-legacy-helper
83+
linters:
84+
- forbidigo
7285

7386
settings:
7487
depguard:
@@ -94,6 +107,23 @@ linters:
94107
msg: >-
95108
[errs-typed-only] use errs.NewXxxError(...) builder
96109
(see errs/types.go).
110+
# ── legacy shared error helpers banned on drive ──
111+
# These helpers internally produce legacy output.Err* shapes, so they
112+
# are invisible to the errs-typed-only ban above. Drive has migrated its
113+
# calls to typed errs.* (drive-local driveInputStatError / driveSaveError);
114+
# this prevents reintroduction. Other domains still use the shared
115+
# helpers (migrated globally in a later phase), so this is drive-scoped.
116+
- pattern: (common\.FlagErrorf|common\.WrapInputStatError|common\.WrapSaveErrorByCategory)\b
117+
msg: >-
118+
[errs-no-legacy-helper] these shared helpers emit legacy output.Err*
119+
shapes. Use the typed errs.NewXxxError builders or the drive-local
120+
driveInputStatError / driveSaveError helpers (shortcuts/drive/drive_errors.go).
121+
# ── bare error wraps banned on fully-typed paths ──
122+
- pattern: (fmt\.Errorf|errors\.New)\b
123+
msg: >-
124+
[errs-no-bare-wrap] final errors must be typed (errs.NewXxxError);
125+
wrap a cause with .WithCause(err). Genuine intermediate wraps:
126+
//nolint:forbidigo with a reason.
97127
# ── http: shortcuts must not construct raw HTTP requests ──
98128
# Bans request / client construction; constants (http.MethodPost,
99129
# http.StatusOK) and pure helpers (http.StatusText, http.Header) are

cmd/root.go

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -255,6 +255,13 @@ func handleRootError(f *cmdutil.Factory, err error) int {
255255
return typedExit
256256
}
257257

258+
// Partial-failure (batch / multi-status): the ok:false result envelope is
259+
// already on stdout; set the exit code and write nothing to stderr.
260+
var pfErr *output.PartialFailureError
261+
if errors.As(err, &pfErr) {
262+
return pfErr.Code
263+
}
264+
258265
if exitErr := asExitError(err); exitErr != nil {
259266
if !exitErr.Raw {
260267
// Raw errors (e.g. from `api` command via output.MarkRaw)

errs/ERROR_CONTRACT.md

Lines changed: 24 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -155,7 +155,30 @@ caller scripts.
155155

156156
New code should not reach for `ErrBare` unless the command is
157157
genuinely a predicate. Anything carrying recoverable error content
158-
belongs in a typed `*errs.XxxError`.
158+
belongs in a typed `*errs.XxxError` — or, for a batch result, in the
159+
partial-failure outcome below.
160+
161+
### Partial failure (batch / multi-status)
162+
163+
A batch command (e.g. `drive +push` / `+pull` / `+sync`) that processes
164+
many items can finish in a third state, neither full success nor a single
165+
error: some items succeeded and some failed. Its primary output is the
166+
per-item result, so it does **not** belong in a `stderr` error envelope.
167+
168+
Such a command returns `runtime.OutPartialFailure(data, meta)`, which:
169+
170+
1. writes the full result to **stdout** as an `ok:false` envelope — the
171+
summary and every per-item outcome (succeeded *and* failed) stay
172+
machine-readable, exactly as a successful `Out(...)` would carry them,
173+
but with `ok` honestly reporting failure; and
174+
2. returns `*output.PartialFailureError`, a typed exit signal the
175+
dispatcher maps to a non-zero exit code while writing nothing further
176+
to `stderr`.
177+
178+
This is distinct from `ErrBare` (a predicate's one-bit answer) and from a
179+
typed `*errs.XxxError` (a `stderr` error envelope): a partial failure is a
180+
*result*, reported on stdout, that also failed. Consumers branch on
181+
`ok == false` and then read `data.summary` / `data.items[]`.
159182

160183
## Consumers
161184

errs/subtypes.go

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -12,7 +12,8 @@ const (
1212

1313
// CategoryValidation subtypes
1414
const (
15-
SubtypeInvalidArgument Subtype = "invalid_argument" // user-supplied flag / arg failed validation (gRPC INVALID_ARGUMENT alignment)
15+
SubtypeInvalidArgument Subtype = "invalid_argument" // user-supplied flag / arg failed validation (gRPC INVALID_ARGUMENT alignment)
16+
SubtypeFailedPrecondition Subtype = "failed_precondition" // request is valid but the system/resource state is not in the state required to execute; caller must change state (not retry) — e.g. ambiguous remote mapping (gRPC FAILED_PRECONDITION alignment)
1617
)
1718

1819
// CategoryAuthentication subtypes

errs/types.go

Lines changed: 21 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -61,8 +61,22 @@ type TypedError interface {
6161
// it is intentionally not serialized.
6262
type ValidationError struct {
6363
Problem
64-
Param string `json:"param,omitempty"`
65-
Cause error `json:"-"`
64+
Param string `json:"param,omitempty"`
65+
Params []InvalidParam `json:"params,omitempty"`
66+
Cause error `json:"-"`
67+
}
68+
69+
// InvalidParam is one structured validation diagnostic: the parameter that
70+
// failed (Name) and why (Reason). It mirrors an RFC 7807 "invalid-params"
71+
// item (RFC 7807 §3.1 extension members).
72+
//
73+
// The wire key on ValidationError is "params" rather than "invalid_params"
74+
// because the enclosing envelope already carries type:"validation", so the
75+
// "invalid" qualifier would be redundant on the wire. The Go type keeps the
76+
// InvalidParam prefix because, at package level, the name must self-describe.
77+
type InvalidParam struct {
78+
Name string `json:"name"`
79+
Reason string `json:"reason"`
6680
}
6781

6882
// Unwrap exposes the wrapped cause so errors.Unwrap / errors.Is can traverse
@@ -122,6 +136,11 @@ func (e *ValidationError) WithParam(param string) *ValidationError {
122136
return e
123137
}
124138

139+
func (e *ValidationError) WithParams(params ...InvalidParam) *ValidationError {
140+
e.Params = append(e.Params, params...)
141+
return e
142+
}
143+
125144
func (e *ValidationError) WithCause(cause error) *ValidationError {
126145
e.Cause = cause
127146
return e

errs/types_test.go

Lines changed: 65 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -558,6 +558,71 @@ func TestTypedError_UnwrapSymmetry(t *testing.T) {
558558
})
559559
}
560560

561+
// TestValidationError_WithParams covers the structured-validation extension:
562+
// WithParams appends InvalidParam items, the scalar Param setter is unaffected,
563+
// and the wire shape nests {name, reason} under "params" (omitted when empty).
564+
func TestValidationError_WithParams(t *testing.T) {
565+
t.Run("appends and exposes fields", func(t *testing.T) {
566+
e := errs.NewValidationError(errs.SubtypeInvalidArgument, "duplicate rel_path").
567+
WithParams(errs.InvalidParam{Name: "a.md", Reason: "duplicate"})
568+
if len(e.Params) != 1 {
569+
t.Fatalf("len(Params) = %d, want 1", len(e.Params))
570+
}
571+
if e.Params[0].Name != "a.md" {
572+
t.Errorf("Params[0].Name = %q, want %q", e.Params[0].Name, "a.md")
573+
}
574+
if e.Params[0].Reason != "duplicate" {
575+
t.Errorf("Params[0].Reason = %q, want %q", e.Params[0].Reason, "duplicate")
576+
}
577+
})
578+
579+
t.Run("appends across multiple calls and returns receiver", func(t *testing.T) {
580+
e := errs.NewValidationError(errs.SubtypeInvalidArgument, "x")
581+
returned := e.WithParams(errs.InvalidParam{Name: "a.md", Reason: "dup"})
582+
if returned != e {
583+
t.Errorf("WithParams returned different pointer; want same as receiver")
584+
}
585+
e.WithParams(
586+
errs.InvalidParam{Name: "b.md", Reason: "dup"},
587+
errs.InvalidParam{Name: "c.md", Reason: "dup"},
588+
)
589+
if len(e.Params) != 3 {
590+
t.Fatalf("len(Params) = %d after two calls, want 3", len(e.Params))
591+
}
592+
})
593+
594+
t.Run("wire shape nests name and reason under params", func(t *testing.T) {
595+
e := errs.NewValidationError(errs.SubtypeInvalidArgument, "duplicate rel_path").
596+
WithParam("--rel-path").
597+
WithParams(errs.InvalidParam{Name: "a.md", Reason: "duplicate"})
598+
b, err := json.Marshal(e)
599+
if err != nil {
600+
t.Fatalf("marshal failed: %v", err)
601+
}
602+
got := string(b)
603+
for _, want := range []string{
604+
`"type":"validation"`,
605+
`"param":"--rel-path"`,
606+
`"params":[{"name":"a.md","reason":"duplicate"}]`,
607+
} {
608+
if !strings.Contains(got, want) {
609+
t.Errorf("missing %q in %s", want, got)
610+
}
611+
}
612+
})
613+
614+
t.Run("empty Params omitted from wire", func(t *testing.T) {
615+
e := errs.NewValidationError(errs.SubtypeInvalidArgument, "x")
616+
b, err := json.Marshal(e)
617+
if err != nil {
618+
t.Fatalf("marshal failed: %v", err)
619+
}
620+
if strings.Contains(string(b), `"params"`) {
621+
t.Errorf("empty Params should be omitted from wire; got %s", b)
622+
}
623+
})
624+
}
625+
561626
func TestBuilderSetter_DefensiveCopy(t *testing.T) {
562627
t.Run("WithMissingScopes clones input", func(t *testing.T) {
563628
scopes := []string{"docx:document", "im:message:send"}

internal/errclass/classify.go

Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -129,6 +129,7 @@ func BuildAPIError(resp map[string]any, cc ClassifyContext) error {
129129
Action: action,
130130
}
131131
case errs.CategoryAPI:
132+
base.Hint = APIHint(base.Subtype) // "" for subtypes without a context-free default
132133
return &errs.APIError{Problem: base}
133134
default:
134135
// Fail closed: an unrecognized Category routes to InternalError
@@ -231,6 +232,22 @@ func ConfigHint(subtype errs.Subtype) string {
231232
return ""
232233
}
233234

235+
// APIHint returns the canonical per-subtype recovery hint for a typed APIError
236+
// emitted via BuildAPIError, for API subtypes whose recovery is context-free.
237+
// Context-specific guidance (e.g. a command's flags, an API's own quota) is
238+
// layered on by the caller after BuildAPIError returns and overrides this.
239+
func APIHint(subtype errs.Subtype) string {
240+
switch subtype {
241+
case errs.SubtypeConflict:
242+
return "retry later and avoid concurrent duplicate requests on the same resource"
243+
case errs.SubtypeCrossTenant:
244+
return "operate on source and target within the same tenant and region/unit"
245+
case errs.SubtypeCrossBrand:
246+
return "operate on source and target within the same brand environment"
247+
}
248+
return ""
249+
}
250+
234251
func buildPermissionError(p errs.Problem, resp map[string]any, cc ClassifyContext) *errs.PermissionError {
235252
missing := extractMissingScopes(resp)
236253
identity := cc.Identity
Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,17 @@
1+
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
2+
// SPDX-License-Identifier: MIT
3+
4+
package errclass
5+
6+
import "github.com/larksuite/cli/errs"
7+
8+
// driveCodeMeta holds drive/docs-service Lark code → CodeMeta mappings.
9+
// Only codes whose meaning is verifiable from repo evidence are registered;
10+
// ambiguous codes fall back to CategoryAPI via BuildAPIError.
11+
// BuildAPIError consumes this map via mergeCodeMeta + LookupCodeMeta.
12+
var driveCodeMeta = map[int]CodeMeta{
13+
1061044: {Category: errs.CategoryAPI, Subtype: errs.SubtypeNotFound}, // parent folder does not exist (upload)
14+
1069302: {Category: errs.CategoryAPI, Subtype: errs.SubtypeInvalidParameters}, // comment endpoint "Invalid or missing parameters"
15+
}
16+
17+
func init() { mergeCodeMeta(driveCodeMeta, "drive") }
Lines changed: 43 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,43 @@
1+
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
2+
// SPDX-License-Identifier: MIT
3+
4+
package errclass
5+
6+
import (
7+
"fmt"
8+
"testing"
9+
10+
"github.com/larksuite/cli/errs"
11+
)
12+
13+
// TestLookupCodeMeta_DriveCodes pins each drive-service code registered via the
14+
// codemeta_drive.go init() merge to its expected Category/Subtype/Retryable.
15+
// Each case traces to repo evidence (see codemeta_drive.go comments).
16+
func TestLookupCodeMeta_DriveCodes(t *testing.T) {
17+
cases := []struct {
18+
code int
19+
wantCat errs.Category
20+
wantSubtype errs.Subtype
21+
wantRetry bool
22+
}{
23+
// 1061044: upload with a nonexistent parent folder token. The drive E2E
24+
// (tests_e2e/drive/2026_06_01_errs_migrate_drive_test.go) drives this
25+
// producer via a nonexistent parent folder → referenced resource missing.
26+
{1061044, errs.CategoryAPI, errs.SubtypeNotFound, false},
27+
// 1069302: comment endpoint's opaque "Invalid or missing parameters"
28+
// (shortcuts/drive/drive_add_comment.go) → API-side parameter rejection.
29+
{1069302, errs.CategoryAPI, errs.SubtypeInvalidParameters, false},
30+
}
31+
for _, tc := range cases {
32+
t.Run(fmt.Sprintf("%d", tc.code), func(t *testing.T) {
33+
meta, ok := LookupCodeMeta(tc.code)
34+
if !ok {
35+
t.Fatalf("code %d not registered in codeMeta", tc.code)
36+
}
37+
if meta.Category != tc.wantCat || meta.Subtype != tc.wantSubtype || meta.Retryable != tc.wantRetry {
38+
t.Errorf("code %d: got %+v, want Category=%v Subtype=%v Retryable=%v",
39+
tc.code, meta, tc.wantCat, tc.wantSubtype, tc.wantRetry)
40+
}
41+
})
42+
}
43+
}

internal/output/errors.go

Lines changed: 22 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -170,6 +170,28 @@ func ErrBare(code int) *ExitError {
170170
return &ExitError{Code: code}
171171
}
172172

173+
// PartialFailureError is the exit signal for a batch / multi-status command that
174+
// has already written an ok:false result envelope to stdout. The per-item
175+
// outcomes are the primary, machine-readable output and live on stdout, so the
176+
// dispatcher sets only the exit code and writes nothing to stderr.
177+
//
178+
// It is deliberately distinct from ErrBare (the predicate silent-exit signal)
179+
// so the predicate contract stays narrow, and from a typed *errs.XxxError
180+
// (which owns the stderr error envelope): a partial failure is a result, not an
181+
// error envelope.
182+
type PartialFailureError struct {
183+
Code int
184+
}
185+
186+
func (e *PartialFailureError) Error() string {
187+
return fmt.Sprintf("partial failure (exit %d)", e.Code)
188+
}
189+
190+
// PartialFailure builds the partial-failure exit signal with the given code.
191+
func PartialFailure(code int) *PartialFailureError {
192+
return &PartialFailureError{Code: code}
193+
}
194+
173195
// WriteTypedErrorEnvelope writes the JSON error envelope for a typed error.
174196
// Each typed error owns its wire shape via its own struct tags: Problem fields
175197
// are promoted to the top level through embedding, and extension fields

0 commit comments

Comments
 (0)