mirror of
https://github.com/larksuite/cli.git
synced 2026-07-06 00:06:28 +08:00
Every failure on the authentication, authorization, and configuration
path now surfaces as a typed structured error instead of an ad-hoc
envelope. Users and scripts that consume CLI output get:
- a fixed nine-category taxonomy on the wire, each mapped to a
stable shell exit code (authentication/authorization/config = 3,
network = 4, internal = 5, policy = 6, confirmation = 10)
- identity-aware detail fields (missing_scopes, requested_scopes,
granted_scopes, console_url, log_id, retryable, hint) carried
uniformly on the envelope
- a single canonical policy envelope at exit 6; the legacy
auth_error carve-out is retired
- per-subtype canonical message + hint that preserves Lark's
diagnostic phrasing and routes recovery to the right actor:
app developer (app_scope_not_applied), user (missing_scope,
token_scope_insufficient, user_unauthorized), or tenant admin
(app_unavailable, app_disabled)
- wrong app credentials classify as config/invalid_client whether
surfaced by the Open API endpoint (99991543) or the tenant
access-token mint endpoint (10003 / 10014), instead of
collapsing to a transport error or api/unknown
- local shortcut scope preflight emits the same
authorization/missing_scope envelope (identity + deterministic
missing-scope set) used by the post-call permission path, so AI
consumers read the same structured shape from precheck and from
server-returned permission denial
- streaming download/upload failures keep the same network subtype
split (timeout / TLS / DNS / transport) as the non-stream path
instead of collapsing every cause to a generic transport failure
- console_url is carried only on the bot-perspective
app_scope_not_applied envelope (where the recovery action is
"developer applies the scope at the developer console"); the
user-perspective missing_scope envelope drops the field, since
the only actionable user recovery is `lark-cli auth login --scope`
and pointing an end user at a console they cannot modify is
misleading
- bind workflows (Hermes / OpenClaw / lark-channel) flatten dynamic
Type tags to wire 'config' with the original module name kept
as a metric label
All 10 typed errors are cause-bearing, nil-safe on .Error() and
.Unwrap(), and defensively clone slice setter inputs. Four lint
rules (CheckNilSafeError / CheckBuilderImmutable / CheckUnwrapSymmetry
/ CheckBuildAPIErrorArms) lock these invariants on migrated paths.
598 lines
29 KiB
Markdown
598 lines
29 KiB
Markdown
# lark-cli Error Contract
|
||
|
||
`errs/` defines a typed, RFC 7807–aligned error taxonomy for the CLI. Three
|
||
audiences depend on it: **AI agents and shell scripts** parsing the JSON
|
||
envelope on stderr; **protocol adapters** mapping CLI errors into MCP /
|
||
OAuth shapes; and **framework + business code** producing errors. This file
|
||
is the single source of truth for all three.
|
||
|
||
This document describes the **typed authoring target**. The refactor lands
|
||
in stages; some boundaries (e.g. `client.WrapDoAPIError`) still operate on
|
||
legacy shapes today — see **Migration** for what is live in each stage.
|
||
|
||
Migrating an `*output.ExitError` call site? See **Migration**. Something off
|
||
in production? See **Troubleshooting**.
|
||
|
||
## Invariants
|
||
|
||
1. Every error belongs to exactly one **Category**. The set is closed
|
||
(`errs/category.go`); adding a member requires deliberate review.
|
||
2. Every **newly constructed** typed error has a **Subtype** — a stable
|
||
lowercase-with-underscores identifier declared in `errs/subtypes*.go`.
|
||
Undeclared subtypes fail CI. The constraint applies only to typed
|
||
`*errs.*` literals; stage-1 legacy `*core.ConfigError` flows via the
|
||
dispatcher's `asExitError` → legacy envelope path (not the typed
|
||
taxonomy) and is unaffected. `errcompat.PromoteConfigError` is a
|
||
stage-1 passthrough; its stage-2+ typed migration will subject the
|
||
promoted typed error to this Subtype constraint at that time.
|
||
3. **`Category` + `Subtype`** are wire-stable identifiers consumers may
|
||
branch on. Renaming either is a breaking change.
|
||
4. `Code` is the upstream numeric code when known (e.g. Lark API code).
|
||
It is `omitempty` and never carries CLI-internal meaning.
|
||
5. Every typed error embeds `errs.Problem`. `CheckProblemEmbed` rejects
|
||
exported `*Error` structs that do not.
|
||
6. Wrapping is idempotent: re-wrapping an already-typed error returns it
|
||
unchanged across the `errors.As` / `errors.Unwrap` chain.
|
||
7. For the typed-envelope path, exit codes derive from `Category` only
|
||
via `output.ExitCodeForCategory` — including `SecurityPolicyError`,
|
||
which exits `6` via `CategoryPolicy`. Unmigrated `*output.ExitError`
|
||
producers still carry a hand-set `Code` until they finish migrating.
|
||
`output.ErrBare(code)` is the lone exception: a deliberate
|
||
predicate-command signal that bypasses the envelope (see
|
||
**Predicate commands** below).
|
||
|
||
## Wire format
|
||
|
||
Typed errors render to **stderr** as one JSON object per process exit:
|
||
|
||
```json
|
||
{
|
||
"ok": false,
|
||
"identity": "user",
|
||
"error": {
|
||
"type": "authorization",
|
||
"subtype": "missing_scope",
|
||
"code": 99991679,
|
||
"message": "missing scope `calendar:event:create` for app cli_xxx",
|
||
"hint": "run lark-cli auth login --scope calendar:event:create",
|
||
"log_id": "20260520-0a1b2c3d",
|
||
"missing_scopes": ["calendar:event:create"],
|
||
"console_url": "https://open.feishu.cn/app/cli_xxx/auth?q=..."
|
||
}
|
||
}
|
||
```
|
||
|
||
| Field | Stability | Notes |
|
||
|-------|-----------|-------|
|
||
| `ok` | wire-stable | always `false` for errors |
|
||
| `identity` | wire-stable | `user` \| `bot` — caller identity; omitted when not resolved |
|
||
| `error.type` | **wire-stable** | one of the 9 Categories |
|
||
| `error.subtype` | **wire-stable** | declared Subtype constant |
|
||
| `error.code` | wire-stable | upstream numeric code, omitted when zero |
|
||
| `error.message` | informational | not safe to branch on |
|
||
| `error.hint` | informational | actionable recovery guidance |
|
||
| `error.log_id` | informational | upstream request id (server-side trace) |
|
||
| `error.retryable` | wire-stable | `true` when present; omitted when `false` |
|
||
| per-Subtype extension fields | per-Subtype-stable | e.g. `missing_scopes`, `console_url`, `challenge_url` |
|
||
|
||
`SecurityPolicyError` renders through the same typed envelope as every
|
||
other category. `error.type` is `"policy"`, `error.subtype` is one of
|
||
`challenge_required` / `access_denied`, and process exit is `6` via
|
||
`CategoryPolicy`. The legacy `auth_error` envelope at exit `1` has been
|
||
retired.
|
||
|
||
## Categories
|
||
|
||
| Category | When | Exit | Typed struct |
|
||
|----------|------|------|--------------|
|
||
| `validation` | malformed user input | 2 | `ValidationError` |
|
||
| `authentication` | no valid token / login required | 3 | `AuthenticationError` |
|
||
| `authorization` | token lacks scope / app permission denied | 3 | `PermissionError` |
|
||
| `config` | local config missing / unbound | 3 | `ConfigError` |
|
||
| `network` | DNS, refused, timeout, transport | 4 | `NetworkError` |
|
||
| `api` | server-side Lark error w/o specific bucket | 1 | `APIError` |
|
||
| `policy` | content safety / security challenge | 6 | `SecurityPolicyError`, `ContentSafetyError` |
|
||
| `internal` | SDK contract violation / decode failure | 5 | `InternalError` |
|
||
| `confirmation` | high-risk action needs `--yes` | 10 | `ConfirmationRequiredError` |
|
||
|
||
Canonical mapping: `internal/output/exitcode.go` `ExitCodeForCategory`.
|
||
|
||
> **Note on the `authorization` / `PermissionError` asymmetry.** The wire
|
||
> `type` field uses the RFC 7807 / taxonomy-formal name `"authorization"`,
|
||
> but the Go type is named `PermissionError`. This is deliberate, following
|
||
> the gRPC / Google APIs convention (`codes.Unauthenticated` +
|
||
> `codes.PermissionDenied`): each name is chosen to be **maximally
|
||
> distinct and readable on its own**, not to be perfectly symmetric.
|
||
> `AuthenticationError` and `AuthorizationError` differ visually only at
|
||
> the 5th character and are easy to confuse in code review;
|
||
> `AuthenticationError` and `PermissionError` cannot be confused. The wire
|
||
> field stays formal because it is the protocol-level taxonomy; the Go
|
||
> type favors call-site readability.
|
||
|
||
## Flow
|
||
|
||
```
|
||
call site
|
||
│ constructs typed error (e.g. *errs.ValidationError)
|
||
▼
|
||
command runE returns err
|
||
│
|
||
▼
|
||
cmd/root.go handleRootError dispatches:
|
||
├─ output.ErrBare(code) → no envelope (stdout already written); exit = code
|
||
├─ typed (errs.ProblemOf) → typed JSON envelope; exit = ExitCodeOf(err)
|
||
│ (includes *errs.SecurityPolicyError → policy envelope, exit 6)
|
||
├─ *core.ConfigError → promoted to typed via errcompat ↑
|
||
├─ *output.ExitError → legacy JSON envelope; exit = exitErr.Code
|
||
└─ untyped / Cobra error → plain "Error: <msg>" (no envelope); exit 1
|
||
```
|
||
|
||
Only the typed and `*output.ExitError` branches emit a JSON envelope on
|
||
stderr. Untyped errors (including Cobra's "required flag missing" / unknown
|
||
subcommand messages) print plain text and exit `1` — consumers must
|
||
tolerate that fallback.
|
||
|
||
### Predicate commands (`output.ErrBare`)
|
||
|
||
A small class of commands is **predicates**: they answer a yes/no
|
||
question and signal the answer through the shell exit code so callers
|
||
can write `if cmd; then ... fi`. `lark-cli auth check` is the canonical
|
||
example — its `README` contract is `exit 0 = ok, 1 = missing`.
|
||
|
||
These commands deliberately:
|
||
|
||
1. write a structured JSON answer to **stdout** themselves, and
|
||
2. return `output.ErrBare(exitCode)` to communicate the exit code to
|
||
the dispatcher without producing a `stderr` envelope.
|
||
|
||
`output.ErrBare` is **not** an error in the typed-envelope sense — it
|
||
carries no category, subtype, or message. It is a one-bit output-
|
||
control signal that lives outside the contract for the same reason
|
||
`grep -q` / `diff` / `systemctl is-active` set non-zero exit codes
|
||
without printing anything to stderr: pollution of stderr by a
|
||
predicate's negative answer would break `2>/dev/null` log hygiene in
|
||
caller scripts.
|
||
|
||
New code should not reach for `ErrBare` unless the command is
|
||
genuinely a predicate. Anything carrying recoverable error content
|
||
belongs in a typed `*errs.XxxError`.
|
||
|
||
## Consumers
|
||
|
||
### Go (in-process)
|
||
|
||
```go
|
||
var pe *errs.PermissionError
|
||
if errors.As(err, &pe) {
|
||
fmt.Println("missing:", pe.MissingScopes)
|
||
}
|
||
```
|
||
|
||
Predicates cover the common categories (`errs/predicates.go`):
|
||
|
||
```go
|
||
if errs.IsAuthentication(err) { ... }
|
||
if errs.IsPermission(err) { ... }
|
||
if errs.IsValidation(err) { ... }
|
||
```
|
||
|
||
Type-agnostic field access:
|
||
|
||
```go
|
||
if p, ok := errs.ProblemOf(err); ok {
|
||
log.Printf("cat=%s subtype=%s retryable=%t", p.Category, p.Subtype, p.Retryable)
|
||
}
|
||
exitCode := output.ExitCodeOf(err) // ExitInternal for non-typed errors
|
||
```
|
||
|
||
### Shell / AI
|
||
|
||
```bash
|
||
out=$(lark-cli ... 2>&1)
|
||
code=$?
|
||
|
||
# Untyped / Cobra errors print plain text — guard before jq.
|
||
if ! jq -e . >/dev/null 2>&1 <<<"$out"; then
|
||
printf '%s\n' "$out" >&2
|
||
exit "$code"
|
||
fi
|
||
|
||
case "$(jq -r '.error.type // empty' <<<"$out")" in
|
||
authorization) jq -r '.error.missing_scopes[]' <<<"$out" ;;
|
||
network) echo "transport failure, safe to retry" ;;
|
||
internal) echo "bug — file an issue with log_id $(jq -r '.error.log_id // "n/a"' <<<"$out")" ;;
|
||
esac
|
||
```
|
||
|
||
Unknown fields are forward-compatible additions: ignore, don't fail.
|
||
Branch only on `type`, `subtype`, `code`, `retryable`, and declared
|
||
extension fields — `message` is human-readable prose that may be
|
||
reworded without notice.
|
||
|
||
## Producers
|
||
|
||
### Quick reference
|
||
|
||
The canonical producer surface is the **builder API in `errs/types.go`** (per type: struct + `NewXxxError` + chained `WithX` setters live in one place):
|
||
each `NewXxxError(subtype, format, args...)` locks `Category` at the
|
||
constructor name, requires `Subtype` + `Message` positionally, and exposes
|
||
optional fields via chained `.WithX(...)` setters. Struct literals remain
|
||
legal for framework dynamic paths (e.g. classifier fanout) but the lint
|
||
`CheckTypedErrorCompleteness` still requires `Category` + `Subtype` +
|
||
`Message` on any literal it sees.
|
||
|
||
| Situation | Use |
|
||
|-----------|-----|
|
||
| Bad user input | `errs.NewValidationError(subtype, msg).WithParam("--flag")` |
|
||
| Login required | `errs.NewAuthenticationError(errs.SubtypeTokenMissing, msg)` |
|
||
| Token lacks scope | `errclass.BuildAPIError(resp, ctx)` |
|
||
| Local config missing | `errs.NewConfigError(errs.SubtypeNotConfigured, msg)` |
|
||
| Transport failure | `errs.NewNetworkError(errs.SubtypeNetworkTimeout, msg).WithCause(err)` (subtype: `timeout` / `tls` / `dns` / `server_error` / `transport`) |
|
||
| Lark API error | `errclass.BuildAPIError(resp, ctx)` |
|
||
| SDK / decode bug | `errs.NewInternalError(errs.SubtypeSDKError, msg).WithCause(err)` |
|
||
| Policy block | `errs.NewSecurityPolicyError(subtype, msg).WithChallengeURL(url)` or `errs.NewContentSafetyError(subtype, msg).WithRules(...)` |
|
||
| Needs `--yes` | `errs.NewConfirmationRequiredError(risk, action, msg)` |
|
||
|
||
### Authoring discipline
|
||
|
||
Five rules every producer follows. Some are enforced by `lint/errscontract`
|
||
AST guards (`go run -C lint . ..`); the rest by code review.
|
||
|
||
#### Propagate typed errors unchanged
|
||
|
||
A function that receives an error already carrying `errs.Problem`
|
||
returns it as-is up the stack. Reclassification at non-boundary frames
|
||
(e.g., wrapping a `*ValidationError` into `*InternalError`) defeats the
|
||
single-source taxonomy and silently downgrades typed signals.
|
||
|
||
Conforming:
|
||
|
||
```go
|
||
_, err := runtime.DoAPI(req, opts)
|
||
if err != nil {
|
||
return err // already typed by the framework boundary
|
||
}
|
||
```
|
||
|
||
Non-conforming:
|
||
|
||
```go
|
||
return fmt.Errorf("calling /open-apis: %v", err) // %v strips the typed shape
|
||
return &errs.InternalError{Cause: err} // re-decides category
|
||
```
|
||
|
||
#### Never return a typed-nil pointer
|
||
|
||
A typed-nil pointer (`var pe *errs.PermissionError; return pe`) wraps as
|
||
a non-nil interface — `errors.As` matches and `.Error()` may panic.
|
||
Return interface `nil` literally.
|
||
|
||
Non-conforming:
|
||
|
||
```go
|
||
var e *errs.ValidationError // nil pointer
|
||
return e // non-nil interface holding nil pointer
|
||
```
|
||
|
||
#### Let `Category` derive the exit code
|
||
|
||
Do not pick exit codes by hand in new typed producers — `ExitCodeForCategory`
|
||
maps `Category` to the shell code. A new exit-code requirement means a
|
||
new `Category`, not a one-off override at the call site.
|
||
|
||
(Legacy `*output.ExitError` retains hand-set codes until removal;
|
||
`SecurityPolicyError` retains a hand-set code on main until the framework
|
||
migration PR retires the carve-out — see **Migration**.)
|
||
|
||
#### Split `Message`, `Hint`, and `Cause`
|
||
|
||
Each field carries a distinct role:
|
||
|
||
| Field | Carries | Style |
|
||
|-------|---------|-------|
|
||
| `Message` | What is wrong | Direct, lowercase first letter, no trailing period |
|
||
| `Hint` | What to do next | Imperative ("run `lark-cli auth login`", "use `--as user`") |
|
||
| `Cause` | The wrapped upstream `error`, not a stringified copy | Typed; serialized as `json:"-"` |
|
||
|
||
`Hint` must not be merged into `Message`. AI agents and humans read them
|
||
on separate channels; merging defeats both.
|
||
|
||
`Cause` must be a real `error`. If the upstream returned an `error`,
|
||
place it in `Cause` so `errors.Is` and `errors.Unwrap` walk the chain —
|
||
do not inline its `.Error()` into `Message`.
|
||
|
||
Conforming:
|
||
|
||
```go
|
||
return errs.NewNetworkError(errs.SubtypeNetworkTransport,
|
||
"request to /open-apis failed after 3 retries").
|
||
WithHint("check connectivity and retry; set --log-level debug if it persists").
|
||
WithCause(ioErr)
|
||
```
|
||
|
||
Non-conforming:
|
||
|
||
```go
|
||
Message: fmt.Sprintf("request failed: %v — retry later", ioErr)
|
||
// conflates what + what-to-do + cause into one string
|
||
```
|
||
|
||
#### `ValidationError.Param` uses the `--flag` form
|
||
|
||
When a `*ValidationError` originates from a flag value, `Param` holds the
|
||
flag name with leading dashes (`"--priority"`, not `"priority"`). AI
|
||
agents grep this field literally to surface "the bad flag was `--X`".
|
||
|
||
For positional arguments, use the canonical name without dashes
|
||
(`"target_user_id"`).
|
||
|
||
### Constructing typed errors
|
||
|
||
Prefer the **builder API**. The constructor pins `Category` + `Subtype` +
|
||
`Message`, the chained setters fill optional fields, and the resulting
|
||
value retains its concrete `*XxxError` pointer through the chain so
|
||
type-specific setters remain reachable to the end:
|
||
|
||
```go
|
||
return errs.NewValidationError(errs.SubtypeInvalidArgument,
|
||
"--data must be a valid JSON object: %v", parseErr).
|
||
WithParam("--data")
|
||
```
|
||
|
||
Why builder over struct literal:
|
||
|
||
- `Category` is locked at the function name — caller cannot mis-specify it
|
||
- `Subtype` and `Message` are positional arguments — `go build` rejects
|
||
the call site if either is missing
|
||
- The chain reads top-down: required identity first, optional fields after
|
||
- Message is `fmt.Sprintf`-formatted from `(format, args...)`, matching
|
||
`fmt.Errorf` muscle memory and avoiding a separate `Sprintf` line
|
||
|
||
Struct literals remain legal — `CheckTypedErrorCompleteness` continues to
|
||
enforce `Category` + `Subtype` + `Message` on any literal it sees — and
|
||
the framework classifier (`internal/errclass/classify.go`) still uses
|
||
them on the dynamic dispatch path where a `Problem` value is composed
|
||
once and wrapped per Category branch. Outside that pattern, new code
|
||
should reach for the builder.
|
||
|
||
Legacy helpers (`output.ErrValidation`, `output.ErrAuth`, `output.ErrNetwork`)
|
||
remain callable during migration but are `// Deprecated:` — new code goes
|
||
through the builder.
|
||
|
||
#### Shortcut `Execute` walkthrough
|
||
|
||
Adapted from `shortcuts/calendar/calendar_suggestion.go:222`, whose legacy
|
||
form is `output.ErrValidation("--duration-minutes must be between 1 and
|
||
1440")`. The typed migration target (builder form):
|
||
|
||
```go
|
||
Execute: func(ctx context.Context, runtime *common.RuntimeContext) error {
|
||
duration := runtime.Int("duration-minutes")
|
||
if duration < 1 || duration > 1440 {
|
||
return errs.NewValidationError(errs.SubtypeInvalidArgument,
|
||
"--duration-minutes must be between 1 and 1440, got %d", duration).
|
||
WithHint("pass a value in [1, 1440]").
|
||
WithParam("--duration-minutes")
|
||
}
|
||
|
||
_, err := runtime.DoAPI(req, opts)
|
||
if err != nil {
|
||
return err // already typed by the framework boundary; propagate
|
||
}
|
||
return nil
|
||
}
|
||
```
|
||
|
||
Two patterns visible: a producer site (the typed `*errs.ValidationError`
|
||
above) and a propagation site (the `return err` after `runtime.DoAPI`,
|
||
applying [Propagate typed errors unchanged](#propagate-typed-errors-unchanged)).
|
||
|
||
When the validation logic outgrows a single range check — multiple
|
||
flags, format parsing, conditional rules — extract it into a helper that
|
||
also returns the typed `*errs.ValidationError`. The helper, not
|
||
`Execute`, sets `Param` (a helper bound to one shortcut is normal in
|
||
this codebase; see `parseTimeRange` in
|
||
`shortcuts/calendar/calendar_agenda.go:144`).
|
||
|
||
### Wrapping upstream errors
|
||
|
||
When a producer receives an error from a function it called, four cases
|
||
cover the decision:
|
||
|
||
| Source | Decision | Example |
|
||
|--------|----------|---------|
|
||
| Helper returned a typed `*errs.*Error` | Return unchanged | `return err` |
|
||
| Helper returned an untyped error tied to user input (`strconv.Atoi`, `json.Unmarshal`, …) | Construct a typed error; put the untyped error in `Cause` | `return errs.NewValidationError(errs.SubtypeInvalidArgument, "invalid --data: %v", jsonErr).WithCause(jsonErr)` |
|
||
| SDK call via `runtime.DoAPI` failed | Return unchanged — the framework boundary already wrapped it | `return err` |
|
||
| Invariant broken (must-not-happen state) | Lift with `errs.WrapInternal`, set a `Message` describing the invariant | `return errs.WrapInternal(fmt.Errorf("identity resolver returned nil: %w", err))` |
|
||
|
||
Prefer the `Cause` field over `fmt.Errorf("ctx: %w", err)` when
|
||
attaching an upstream error to a typed one. `Cause` is the chain
|
||
`errs.UnwrapTypedError` walks and the chain consumer code expects;
|
||
`fmt.Errorf("...: %w", err)` only affects `.Error()` output, which the
|
||
wire envelope does not surface.
|
||
|
||
#### Boundary helpers (framework-internal)
|
||
|
||
These helpers are called from framework boundaries, not from domain
|
||
code:
|
||
|
||
- `errs.WrapInternal(err)` — lifts an untyped error to `*InternalError`;
|
||
already-typed errors pass through unchanged.
|
||
- `client.WrapDoAPIError(err)` — classifies SDK transport / decode
|
||
failures into `*errs.NetworkError` / `*errs.InternalError` at the SDK
|
||
boundary.
|
||
- `client.WrapJSONResponseParseError(body, err)` — lifts response-layer
|
||
JSON parse failures to `*errs.InternalError`.
|
||
|
||
If you find yourself reaching for `WrapDoAPIError` from a `shortcuts/**`
|
||
package, you are probably calling the SDK at the wrong layer — go
|
||
through `runtime.DoAPI`.
|
||
|
||
### Extending the taxonomy
|
||
|
||
#### Add a Subtype
|
||
|
||
1. Add a constant in `errs/subtypes.go` under the right Category block.
|
||
Subtypes are framework-shared — service-specific Subtypes are an
|
||
anti-pattern (the wire `code` field already identifies the source
|
||
service; Subtype encodes cross-service semantics like `not_found`,
|
||
`quota_exceeded`).
|
||
2. If it maps from a Lark code, register the mapping in
|
||
`internal/errclass/codemeta_<service>.go`.
|
||
3. Add a dispatch test in `internal/errclass/classify_test.go`.
|
||
4. Reference the constant from a producer.
|
||
5. `go run -C lint . ..` — `CheckDeclaredSubtype` fails until the
|
||
constant is wired through.
|
||
|
||
`ad_hoc_*` subtypes are a temporary unblocker that label a value for
|
||
follow-up, not a permanent identifier. Resolve any `ad_hoc_*` to a
|
||
declared constant within one week of introduction; `CheckAdHocSubtype`
|
||
emits a warning to keep them visible.
|
||
|
||
#### Add a typed Error struct
|
||
|
||
Rare; the existing structs cover the 9 Categories with room. If you must:
|
||
|
||
1. In `errs/types.go`, add a new section with: the struct embedding `errs.Problem`, a nil-receiver-safe `Unwrap()` if it carries `Cause`, a `NewXxxError(subtype, format, args...)` constructor, and one chained `WithX` setter per extension field.
|
||
2. Add an `IsXxx` predicate in `errs/predicates.go`.
|
||
3. Add a wire-format pin in `errs/marshal_test.go` and a builder-chain pin in `errs/types_builder_test.go`.
|
||
|
||
`CheckProblemEmbed` enforces the `Problem` embed at lint time. New
|
||
top-level wire fields are forbidden — per-Subtype data goes into the
|
||
typed struct as a documented extension field, not into the envelope's
|
||
top level.
|
||
|
||
## CI guards
|
||
|
||
| Check | Enforces | Where |
|
||
|-------|----------|-------|
|
||
| forbidigo | business path (`shortcuts/**`, `cmd/service/**`) must not call legacy `output.*` error constructors — route through the typed classifier | `.golangci.yml` |
|
||
| `CheckProblemEmbed` | every exported `*Error` embeds `errs.Problem` | `lint/errscontract/` AST |
|
||
| `CheckNoRegistrar` | no `mergeCodeMeta` / `RegisterServiceMap` from service code | `lint/errscontract/` AST |
|
||
| `CheckAdHocSubtype` | `ad_hoc_*` Subtypes labeled for promotion (warn) | `lint/errscontract/` AST |
|
||
| `CheckDeclaredSubtype` | every `Subtype:` value is a declared constant or `ad_hoc_*` | `lint/errscontract/` AST |
|
||
| `CheckTypedErrorCompleteness` | every `*errs.<X>Error{Problem: errs.Problem{...}}` literal must set `Category`, `Subtype`, and `Message` | `lint/errscontract/` AST |
|
||
|
||
CI runs `lint/` on every PR. Locally: `go run -C lint . ..`. The
|
||
lintcheck CLI lives in its own Go module so its `golang.org/x/tools`
|
||
dependency stays out of the shipped `lark-cli` binary's module graph;
|
||
see `lint/README.md` for how to add a new lint domain.
|
||
|
||
## Stability
|
||
|
||
| Tier | Surface | Change policy |
|
||
|------|---------|---------------|
|
||
| Wire-stable | `error.type`, `error.subtype`, `error.code`, `error.retryable`, declared extension fields, `Category` enum values | breaking change ⇒ semver major; deprecation window required |
|
||
| Additive | new Category, new declared Subtype, new extension field on an existing struct | minor release; consumers ignore unknown fields by contract |
|
||
| Experimental | `ad_hoc_*` Subtypes; fields documented as such in `errs/types.go` | may change or be promoted/removed within one release |
|
||
|
||
The deprecated `*output.ExitError` surface is outside these tiers — it
|
||
will be removed once business migration completes.
|
||
|
||
## Migration
|
||
|
||
**Strategy shift (2026-05-26).** The original plan (`docs/design/errors-refactor/spec.md` v2.12 §9) was a centrally-driven 4-PR rollout — framework → auth domain → multi-pilot → full-repo + legacy removal. That plan is **superseded** by a hybrid model: framework owner ships framework-level hardening (including a typed `*errs.*Error` migration of `internal/**`) as one focused PR; business-domain typed migration is **self-service** via [`docs/errors-guide.md`](../docs/errors-guide.md) and the builder API, with no central sweep timeline.
|
||
|
||
Why the shift: 800+ legacy call sites split across 8+ business domains do not all share a single reviewer's bandwidth, and the contract is now expressive enough that each domain owner can migrate their own code from the guide without coordinating with framework owner.
|
||
|
||
### Current state
|
||
|
||
1. **Framework slice — ✅ shipped (PR #984).** The `errs/` typed taxonomy, classifier (`internal/errclass`), promotion stub (`internal/errcompat`, passthrough), dispatcher hook (`WriteTypedErrorEnvelope`), and the `lint/errscontract` AST guards. Wire shapes preserved byte-for-byte versus pre-PR, with **one intentional semantic fix**: config-class errors (`*core.ConfigError`) now exit `3` instead of `2`, aligning with `ExitCodeForCategory` (config errors share the auth exit slot per the taxonomy). The classifier and promote helpers are *shipped but unused* in production paths — they exist so framework migration can plug in without re-architecting.
|
||
|
||
2. **Builder API — ✅ shipped (this branch).** `errs/types.go` adds the canonical producer surface (`errs.NewXxxError(subtype, format, args...).WithX(...)`) for all 10 typed types, alongside each struct declaration. Constructor signature pins `Category` (via function name) and `Subtype` + `Message` (positional), so the producer cannot mis-specify any of the three identity fields. Optional fields chain through `.WithX(...)` setters that preserve the concrete pointer type.
|
||
|
||
### Next: framework migration PR (planned)
|
||
|
||
A single PR consolidates the work the original §9 spec split across PRs 2–4 — restricted to framework code, no business sweep:
|
||
|
||
- **Migrate `internal/**` typed construction to the builder API.** ~16 call sites in `internal/errclass/classify.go` (BuildAPIError fanout), `internal/auth/transport.go` (SecurityPolicy), `internal/auth/uat_client.go`, `internal/errcompat/promote*.go`, `internal/client/client.go`, `internal/client/api_errors.go`.
|
||
- **Land the framework-side semantic changes** previously scoped to spec §9 PR 2: `SecurityPolicyError` exit `1→6`, `WrapDoAPIError` typed (`*NetworkError` with subtype timeout/tls/dns/server_error/transport, `*InternalError` for JSON-decode), `WrapJSONResponseParseError` typed, `errcompat.PromoteConfigError` real Type routing, `PromoteAuthError` helper + dispatcher wiring, 10 credential Lark codes registered in codeMeta, 99991543 config classification, `resolveAccessToken` typed `*AuthenticationError`, `BuildAPIError` filling `*PermissionError.MissingScopes` / `Identity` / `ConsoleURL`, deletion of `scopeAwareChecker`.
|
||
- **Add `forbidigo` rule** banning `output.Err*` constructors in `shortcuts/**` and `cmd/**` (mirrors the contract that new business code must use the builder).
|
||
- **CHANGELOG** lists the resulting ~10 shell-exit-code shifts in one release entry (vs the spec §1 spread of 11 — the remaining one site lives in `task` business code).
|
||
|
||
### Business-domain migration (self-service, no central timeline)
|
||
|
||
Each business package migrates its own `output.Err*` call sites to the builder when convenient — typically batched within one domain. The guide at [`docs/errors-guide.md`](../docs/errors-guide.md) walks owners through the 8 typical error modes (validation / authorization / authentication / config / network / api / internal / policy) with real `file:line` examples from main. The three-layer extension model (add Subtype / add field / add Category) handles cases the existing taxonomy does not cover.
|
||
|
||
Helper assertions accept both shapes during migration (see `shortcuts/mail/mail_shortcut_validation_test.go` `assertValidationError`) so domain migrations stay green incrementally.
|
||
|
||
### Legacy removal
|
||
|
||
Deferred until business migration completion approaches the asymptote. `Errorf`, `ErrAPI`, `ErrAuth`, `ErrWithHint`, `ErrBare`, `ClassifyLarkError`, `ErrDetail`, `ExitError`, and `ErrorEnvelope` are `// Deprecated:` today and stay callable. No fixed removal date.
|
||
|
||
### Before / after at a call site
|
||
|
||
```go
|
||
// before (legacy)
|
||
return output.ErrAPI(larkCode, "create event failed", resp.RawBody())
|
||
|
||
// after (typed) — cc carries Brand / AppID / Identity from the caller's context
|
||
return errclass.BuildAPIError(parsedResp, cc)
|
||
```
|
||
|
||
```go
|
||
// before (legacy validation)
|
||
return output.ErrValidation("--duration-minutes must be between 1 and 1440")
|
||
|
||
// after (builder)
|
||
return errs.NewValidationError(errs.SubtypeInvalidArgument,
|
||
"--duration-minutes must be between 1 and 1440, got %d", duration).
|
||
WithParam("--duration-minutes")
|
||
```
|
||
|
||
## Troubleshooting
|
||
|
||
**Envelope shows `type=api subtype=unknown` for what should be a more
|
||
specific category.** The Lark code is unknown to `LookupCodeMeta` and fell
|
||
through to the generic bucket (`internal/errclass/classify.go`). Add the
|
||
code to `internal/errclass/codemeta_<service>.go` with the right Category
|
||
and Subtype, plus a dispatch test in `classify_test.go`.
|
||
|
||
**Envelope shows `type=internal subtype=sdk_error`.** Origin is
|
||
`client.WrapDoAPIError` taking the non-transport branch
|
||
(`internal/client/api_errors.go`). Check: did the SDK fail to decode the
|
||
response (look for `subtype=invalid_response` in the wrapped chain)? Was the
|
||
transport detection too narrow for this error (e.g. a `*url.Error` with an
|
||
inner that does not satisfy `net.Error`)? Either widen the transport
|
||
predicate or add an explicit typed wrap upstream.
|
||
|
||
**`CheckDeclaredSubtype` rejects my Subtype.** The constant must be
|
||
declared in `errs/subtypes*.go` *and* referenced from the dispatch path.
|
||
Bare string literals trip `CheckDeclaredSubtype` unless they match the
|
||
`ad_hoc_*` prefix; `ad_hoc_*` then trips `CheckAdHocSubtype` as a
|
||
follow-up warning.
|
||
|
||
**`errors.As(&typedErr)` panics with a nil-pointer receiver.** A typed-nil
|
||
slipped through. All typed errors define nil-safe `Unwrap()`, but
|
||
returning a typed-nil pointer up the stack still defeats `errors.As`.
|
||
Return interface `nil` from constructors, never a typed-nil pointer.
|
||
|
||
**Exit code is 5 (internal) when I expected 3 (auth).** The error was not
|
||
typed before reaching `handleRootError`. Wrap at the boundary
|
||
(`client.WrapDoAPIError` or a typed constructor) — the bare `error.Error()`
|
||
string cannot be classified retroactively.
|
||
|
||
## Security & privacy
|
||
|
||
- `log_id` is a server-side trace token. Safe to surface; it does not
|
||
carry user content.
|
||
- `missing_scopes` is app configuration, not user data.
|
||
- `Message` and `Hint` must not contain tokens, JWTs, or personally
|
||
identifying values. CI does not catch this — producer responsibility.
|
||
- Wrapped `Cause` is **not** serialized to the wire (`json:"-"`). It is
|
||
retained for in-process `errors.Is` / `errors.Unwrap` traversal and
|
||
optional debug logging only.
|
||
|
||
## Pointers (task-driven)
|
||
|
||
- *Which struct to construct?* → **Producers / Quick reference**
|
||
- *Add a new condition?* → **Add a Subtype**
|
||
- *Consume from a shell script?* → **Consumers / Shell / AI**
|
||
- *Understand or fix a CI failure?* → **CI guards**
|
||
- *Migrate a legacy `ExitError` call site?* → **Migration** + the
|
||
Deprecated note on the symbol being replaced.
|
||
- *Read source.* → `errs/doc.go` → `errs/category.go` → `errs/types.go`
|
||
→ `errs/predicates.go` → `internal/errclass/` →
|
||
`cmd/root.go` `handleRootError`.
|