larksuite--cli
bf9395e022
CI / license-header (push) Has been skipped
CI / e2e-dry-run (push) Has been skipped
CI / fast-gate (push) Failing after 0s
Test PR Label Logic / test-pr-labels (push) Failing after 1s
Skill Format Check / check-format (push) Failing after 2s
CI / security (push) Failing after 5s
CI / unit-test (push) Has been skipped
CI / lint (push) Has been skipped
CI / script-test (push) Has been skipped
CI / deterministic-gate (push) Has been skipped
CI / coverage (push) Has been skipped
CI / results (push) Has been cancelled
CI / deadcode (push) Has been cancelled
CI / e2e-live (push) Has been cancelled
608 行
27 KiB
Markdown
608 行
27 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.
|
|
|
|
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 typed error has a **Subtype** — a stable
|
|
lowercase-with-underscores identifier declared in `errs/subtypes*.go`.
|
|
Undeclared subtypes fail CI. Every error path constructs a typed
|
|
`*errs.*` error at its origin, so the constraint applies uniformly.
|
|
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`. `output.ErrBare(code)` is the
|
|
exception: it constructs an `*output.BareError`, a deliberate
|
|
silent-exit signal (stdout already carries the answer) 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` |
|
|
| `error.param` | per-Subtype-stable | single offending parameter (`ValidationError`); see **Validation parameters** |
|
|
| `error.params` | per-Subtype-stable | per-parameter validation detail array (`ValidationError`); see **Validation parameters** |
|
|
| 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`.
|
|
|
|
### Success envelope (stdout)
|
|
|
|
For contrast: success responses render to **stdout** as an
|
|
`output.Envelope` (`internal/output/envelope.go`), exit code `0`:
|
|
|
|
```json
|
|
{
|
|
"ok": true,
|
|
"identity": "user",
|
|
"data": { "guid": "e297d3d0-..." },
|
|
"meta": { "count": 1 }
|
|
}
|
|
```
|
|
|
|
Consumers must branch on `ok` (or the process exit code). The success
|
|
envelope has **no top-level `code` or `msg` field** — `code` exists only
|
|
inside `error`, where it is the upstream numeric code (invariant 4).
|
|
Wrappers that follow the raw OpenAPI convention and test `code == 0`
|
|
will misclassify every successful call as a failure, which is
|
|
especially dangerous around write commands (e.g. retrying a create that
|
|
already succeeded).
|
|
|
|
## 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:
|
|
├─ typed (errs.ProblemOf) → typed JSON envelope; exit = ExitCodeOf(err)
|
|
│ (includes *errs.SecurityPolicyError → policy envelope, exit 6;
|
|
│ *errs.ConfigError, constructed typed at origin)
|
|
├─ *output.PartialFailureError → no stderr envelope (ok:false result already on stdout); exit = code
|
|
├─ *output.BareError → no envelope (stdout already written); exit = code
|
|
└─ Cobra usage error → typed validation envelope (invalid_argument); exit 2
|
|
```
|
|
|
|
The dispatcher emits a JSON envelope on stderr for both the typed branch and
|
|
residual Cobra usage errors (missing required flag, unknown command,
|
|
argument validation): the latter are classified into a typed validation
|
|
envelope (`invalid_argument`) and exit `2`, matching the explicit flag and
|
|
subcommand guards.
|
|
|
|
### Predicate commands (`output.BareError`)
|
|
|
|
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)` — an `*output.BareError` — to
|
|
communicate the exit code to the dispatcher without producing a
|
|
`stderr` envelope.
|
|
|
|
`*output.BareError` is **not** an error in the typed-envelope sense — it
|
|
carries no category, subtype, or message, only an exit code. 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.
|
|
|
|
A second class also uses `ErrBare`: a command that emits its own complete
|
|
structured result envelope on **stdout** under `--json` (e.g. `update`, whose
|
|
`{ok:false, error:{type, message}}` is its established output shape) and needs
|
|
only the exit code conveyed, with no `stderr` envelope. Like a predicate, its
|
|
answer is already on stdout; `ErrBare` carries the exit code alone.
|
|
|
|
New code should not reach for `ErrBare` unless the command's full answer is
|
|
already on stdout — a predicate's yes/no, or a self-contained result envelope
|
|
as above. Anything whose error content must reach the caller on `stderr`
|
|
belongs in a typed `*errs.XxxError` — or, for a batch result, in the
|
|
partial-failure outcome below.
|
|
|
|
### Partial failure (batch / multi-status)
|
|
|
|
A batch command (e.g. `drive +push` / `+pull` / `+sync`) that processes
|
|
many items can finish in a third state, neither full success nor a single
|
|
error: some items succeeded and some failed. Its primary output is the
|
|
per-item result, so it does **not** belong in a `stderr` error envelope.
|
|
|
|
Such a command returns `runtime.OutPartialFailure(data, meta)`, which:
|
|
|
|
1. writes the full result to **stdout** as an `ok:false` envelope — the
|
|
summary and every per-item outcome (succeeded *and* failed) stay
|
|
machine-readable, exactly as a successful `Out(...)` would carry them,
|
|
but with `ok` honestly reporting failure; and
|
|
2. returns `*output.PartialFailureError`, a typed exit signal the
|
|
dispatcher maps to a non-zero exit code while writing nothing further
|
|
to `stderr`.
|
|
|
|
This is distinct from `ErrBare` (a predicate's one-bit answer) and from a
|
|
typed `*errs.XxxError` (a `stderr` error envelope): a partial failure is a
|
|
*result*, reported on stdout, that also failed. Consumers branch on
|
|
`ok == false` and then read `data.summary` / `data.items[]`.
|
|
|
|
## 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=$?
|
|
|
|
# Defensive guard: tolerate any non-JSON output before parsing with 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.
|
|
|
|
(The only exits not derived from `Category` are the
|
|
`*output.BareError` and the `*output.PartialFailureError` signals, which
|
|
carry their own code by design and sit outside the typed-envelope contract —
|
|
see **Predicate commands**.)
|
|
|
|
#### 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
|
|
```
|
|
|
|
#### Validation parameters: `Param` and `Params`
|
|
|
|
`ValidationError` carries two additive parameter fields. Both are
|
|
optional; a producer sets whichever fits the failure.
|
|
|
|
**`Param string` (wire `param`)** — the single offending parameter. 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"`).
|
|
|
|
**`Params []InvalidParam` (wire `params`)** — per-parameter validation
|
|
detail, for failures that need to report *which* parameters failed and
|
|
*why*, one entry each. Each `errs.InvalidParam` is
|
|
`{Name, Reason string, Suggestions []string}`: `Name` identifies the
|
|
parameter, `Reason` states why it failed, and the optional `Suggestions`
|
|
(wire `suggestions`, omitted when empty) carries ranked candidate
|
|
corrections an agent can retry with — the did-you-mean candidates for an
|
|
unknown flag or subcommand — without parsing the human-facing `hint`. This
|
|
is the CLI's rendering of the RFC 7807 `invalid-params` extension member
|
|
(RFC 7807 §3.1). The wire key is `params`, not `invalid_params`: the
|
|
enclosing envelope already carries `type:"validation"`, so the `invalid_`
|
|
qualifier would be redundant on the wire.
|
|
|
|
`Param` and `Params` are independent additive fields, not alternates of a
|
|
single representation. Use `Param` for the common single-parameter error;
|
|
use `Params` when one failure spans several parameters or needs a
|
|
per-parameter reason. Set with `.WithParam("--flag")` / `.WithParams(...)`.
|
|
|
|
A `params` wire example (multiple parameters each carrying a reason):
|
|
|
|
```json
|
|
{
|
|
"ok": false,
|
|
"identity": "user",
|
|
"error": {
|
|
"type": "validation",
|
|
"subtype": "invalid_argument",
|
|
"message": "2 parameters failed validation",
|
|
"params": [
|
|
{ "name": "--start", "reason": "expected RFC3339, got \"yesterday\"" },
|
|
{ "name": "--end", "reason": "must be after --start" }
|
|
]
|
|
}
|
|
}
|
|
```
|
|
|
|
### 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.
|
|
|
|
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`).
|
|
|
|
### 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_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
|
|
|
|
Two golangci-lint rules and the custom `errscontract` AST module enforce the
|
|
contract; CI runs all three on every PR.
|
|
|
|
**golangci-lint** — scopes are defined in `.golangci.yml` (not duplicated here,
|
|
so this spec cannot drift from the lint config):
|
|
|
|
| Rule | Enforces |
|
|
|------|----------|
|
|
| forbidigo `errs-no-bare-wrap` | a command / wire-boundary final error must be typed (`errs.NewXxxError`), never a bare `fmt.Errorf` / `errors.New`; a genuine intermediate wrap opts out with `//nolint:forbidigo` + a reason |
|
|
| errorlint | every error wrap uses `%w` and every comparison uses `errors.Is` / `errors.As` — interior wraps stay legal but cannot break the `errors.Unwrap` chain the typed boundary relies on |
|
|
|
|
**errscontract** (`lint/errscontract/`, a separate Go module so its
|
|
`golang.org/x/tools` dependency stays out of the shipped binary; run locally
|
|
with `go run -C lint . ..`):
|
|
|
|
| Check | Enforces |
|
|
|-------|----------|
|
|
| `CheckNoLegacyEnvelopeLiteral` / `CheckNoLegacyCommonHelperCall` / `CheckNoLegacyRuntimeAPICall` | the removed `output.*` legacy error surface cannot be reintroduced anywhere |
|
|
| `CheckProblemEmbed` | every exported `*Error` embeds `errs.Problem` |
|
|
| `CheckDeclaredSubtype` | every `Subtype:` value is a declared constant (or `ad_hoc_*`) |
|
|
| `CheckTypedErrorCompleteness` | every typed-error struct literal sets `Category`, `Subtype`, and `Message` |
|
|
| `CheckAdHocSubtype` | `ad_hoc_*` Subtypes flagged for promotion (warning) |
|
|
| `CheckNoRegistrar` | no `mergeCodeMeta` / `RegisterServiceMap` from service code |
|
|
|
|
`errscontract` also carries framework-internal invariants (nil-safe `Unwrap`,
|
|
builder immutability, unwrap symmetry); see `lint/errscontract/` for the full
|
|
set and `lint/README.md` for adding 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 |
|
|
|
|
## 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 `internal/errclass/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**
|
|
- *Read source.* → `errs/doc.go` → `errs/category.go` → `errs/types.go`
|
|
→ `errs/predicates.go` → `internal/errclass/` →
|
|
`cmd/root.go` `handleRootError`.
|