feat(errs): add structured CLI error contract (#984)

Introduce a typed error contract framework for lark-cli so in-process
Go callers can branch via errors.As(&errs.XxxError{}) and shell scripts,
AI agents, and protocol adapters can branch on stable JSON type/subtype
fields instead of regex-parsing free-form messages.

Adds:
- Canonical taxonomy under errs/ (9 categories + typed Error structs
  embedding a shared Problem, RFC 7807-aligned)
- Centralized Lark code metadata + identity-aware BuildAPIError dispatch
- Typed JSON envelope writer alongside the legacy envelope writer
- MCP / OAuth (RFC 6750 Bearer) projection adapters
- Five CI lint guards preventing ad-hoc taxonomy drift

Backward compatibility: legacy *output.ExitError producers (ErrAPI,
ErrWithHint, Errorf, ErrBare) and business shortcuts that use them
continue to render the legacy envelope unchanged. SecurityPolicyError
wire format and exit code are preserved via a carve-out; taxonomy
migration is deferred to PR 2. Domain-specific business migration is
staged across PR 3+.

Framework-direct paths now return typed *errs.*Error: ErrAuth /
ErrValidation / ErrNetwork emit category literals on the wire
(authentication / validation / network), *core.ConfigError is promoted
at the cmd/root boundary with exit code aligned from 2 to 3, and Lark
API permission denials classified by BuildAPIError exit 3.

At the SDK boundary, WrapDoAPIError preserves any already-classified
error (legacy *output.ExitError or typed *errs.*) so output.ErrAuth
from missing credentials surfaces with the auth category and exit 3
intact instead of being downgraded to a network error. Policy responses
classified by BuildAPIError (codes 21000 / 21001) extract challenge_url
and the canonical hint from the response body, matching what the
auth transport already surfaces at the HTTP layer; non-https
challenge URLs are dropped.

First PR in the feat/error-contract-* series.
This commit is contained in:
evandance
2026-05-26 11:42:33 +08:00
committed by GitHub
parent 877fbe6d47
commit fe72e41fb2
94 changed files with 7703 additions and 1250 deletions

View File

@@ -238,7 +238,11 @@ func apiRun(opts *APIOptions) error {
resp, err := ac.DoAPI(opts.Ctx, request)
if err != nil {
return output.MarkRaw(client.WrapDoAPIError(err))
// MarkRaw tells the dispatcher to skip enrichPermissionError so the
// raw API error detail (log_id, troubleshooter, permission_violations)
// stays on the wire — `lark-cli api` callers explicitly want the raw
// envelope.
return output.MarkRaw(err)
}
err = client.HandleResponse(resp, client.ResponseOptions{
OutputPath: opts.Output,
@@ -248,9 +252,15 @@ func apiRun(opts *APIOptions) error {
ErrOut: f.IOStreams.ErrOut,
FileIO: f.ResolveFileIO(opts.Ctx),
CommandPath: opts.Cmd.CommandPath(),
Identity: opts.As,
// Stage 1: CheckResponse emits the legacy *output.ExitError envelope.
// Per-domain migration in stage 2+ will route through
// errclass.BuildAPIError to populate identity-aware fields
// (PermissionError.ConsoleURL needs Brand+AppID from the client).
CheckError: ac.CheckResponse,
})
// MarkRaw tells root error handler to skip enrichPermissionError,
// preserving the original API error detail (log_id, troubleshooter, etc.).
// MarkRaw: see comment above on the DoAPI path. Applies equally to
// HandleResponse failures so the raw API error survives to the wire.
if err != nil {
return output.MarkRaw(err)
}
@@ -262,9 +272,12 @@ func apiDryRun(f *cmdutil.Factory, request client.RawApiRequest, config *core.Cl
}
func apiPaginate(ctx context.Context, ac *client.APIClient, request client.RawApiRequest, format output.Format, jqExpr string, out, errOut io.Writer, pagOpts client.PaginationOptions) error {
if pagOpts.Identity == "" {
pagOpts.Identity = request.As
}
// When jq is set, always aggregate all pages then filter.
if jqExpr != "" {
if err := client.PaginateWithJq(ctx, ac, request, jqExpr, out, pagOpts, client.CheckLarkResponse); err != nil {
if err := client.PaginateWithJq(ctx, ac, request, jqExpr, out, pagOpts, ac.CheckResponse); err != nil {
return output.MarkRaw(err)
}
return nil
@@ -277,9 +290,9 @@ func apiPaginate(ctx context.Context, ac *client.APIClient, request client.RawAp
pf.FormatPage(items)
}, pagOpts)
if err != nil {
return output.MarkRaw(output.ErrNetwork("API call failed: %v", err))
return output.MarkRaw(err)
}
if apiErr := client.CheckLarkResponse(result); apiErr != nil {
if apiErr := ac.CheckResponse(result, pagOpts.Identity); apiErr != nil {
output.FormatValue(out, result, output.FormatJSON)
return output.MarkRaw(apiErr)
}
@@ -291,9 +304,9 @@ func apiPaginate(ctx context.Context, ac *client.APIClient, request client.RawAp
default:
result, err := ac.PaginateAll(ctx, request, pagOpts)
if err != nil {
return output.MarkRaw(output.ErrNetwork("API call failed: %v", err))
return output.MarkRaw(err)
}
if apiErr := client.CheckLarkResponse(result); apiErr != nil {
if apiErr := ac.CheckResponse(result, pagOpts.Identity); apiErr != nil {
output.FormatValue(out, result, output.FormatJSON)
return output.MarkRaw(apiErr)
}

View File

@@ -4,7 +4,6 @@
package api
import (
"errors"
"os"
"sort"
"strings"
@@ -13,7 +12,6 @@ import (
"github.com/larksuite/cli/internal/cmdutil"
"github.com/larksuite/cli/internal/core"
"github.com/larksuite/cli/internal/httpmock"
"github.com/larksuite/cli/internal/output"
"github.com/spf13/cobra"
)
@@ -399,154 +397,6 @@ func TestNormalisePath_StripsQueryAndFragment(t *testing.T) {
}
}
func TestApiCmd_APIError_IsRaw(t *testing.T) {
f, _, stderr, reg := cmdutil.TestFactory(t, &core.CliConfig{
AppID: "test-app-raw", AppSecret: "test-secret-raw", Brand: core.BrandFeishu,
})
// Return a permission error from the API
reg.Register(&httpmock.Stub{
URL: "/open-apis/test/perm",
Body: map[string]interface{}{
"code": 99991672,
"msg": "scope not enabled for this app",
"error": map[string]interface{}{
"permission_violations": []interface{}{
map[string]interface{}{"subject": "calendar:calendar:readonly"},
},
},
},
})
cmd := NewCmdApi(f, nil)
cmd.SetArgs([]string{"GET", "/open-apis/test/perm", "--as", "bot"})
err := cmd.Execute()
if err == nil {
t.Fatal("expected error for permission denied API response")
}
// Error should be marked Raw
var exitErr *output.ExitError
if !errors.As(err, &exitErr) {
t.Fatalf("expected *output.ExitError, got %T", err)
}
if !exitErr.Raw {
t.Error("expected API error from api command to be marked Raw")
}
// Note: stderr envelope output is tested at the root level (TestHandleRootError_*)
// since WriteErrorEnvelope is called by handleRootError, not by cobra's Execute.
_ = stderr
}
func TestApiCmd_APIError_PreservesOriginalMessage(t *testing.T) {
f, _, _, reg := cmdutil.TestFactory(t, &core.CliConfig{
AppID: "test-app-origmsg", AppSecret: "test-secret-origmsg", Brand: core.BrandFeishu,
})
reg.Register(&httpmock.Stub{
URL: "/open-apis/test/origmsg",
Body: map[string]interface{}{
"code": 99991672,
"msg": "scope not enabled for this app",
"error": map[string]interface{}{
"permission_violations": []interface{}{
map[string]interface{}{"subject": "im:message:readonly"},
},
},
},
})
cmd := NewCmdApi(f, nil)
cmd.SetArgs([]string{"GET", "/open-apis/test/origmsg", "--as", "bot"})
err := cmd.Execute()
if err == nil {
t.Fatal("expected error")
}
var exitErr *output.ExitError
if !errors.As(err, &exitErr) {
t.Fatalf("expected *output.ExitError, got %T", err)
}
// The message should NOT have been enriched (no "App scope not enabled" replacement)
if strings.Contains(exitErr.Error(), "App scope not enabled") {
t.Error("expected original message, not enriched message")
}
// Detail should still contain the raw API error detail
if exitErr.Detail == nil {
t.Fatal("expected non-nil Detail")
}
if exitErr.Detail.Detail == nil {
t.Error("expected raw Detail.Detail to be preserved (not cleared by enrichment)")
}
}
func TestApiCmd_InvalidJSONResponse_ShowsDiagnostic(t *testing.T) {
f, _, _, reg := cmdutil.TestFactory(t, &core.CliConfig{
AppID: "test-app-invalidjson", AppSecret: "test-secret-invalidjson", Brand: core.BrandFeishu,
})
reg.Register(&httpmock.Stub{
URL: "/open-apis/test/invalidjson",
RawBody: []byte{},
ContentType: "application/json",
})
cmd := NewCmdApi(f, nil)
cmd.SetArgs([]string{"GET", "/open-apis/test/invalidjson", "--as", "bot"})
err := cmd.Execute()
if err == nil {
t.Fatal("expected error")
}
var exitErr *output.ExitError
if !errors.As(err, &exitErr) {
t.Fatalf("expected *output.ExitError, got %T", err)
}
if exitErr.Code != output.ExitAPI {
t.Fatalf("expected ExitAPI, got %d", exitErr.Code)
}
if exitErr.Detail == nil {
t.Fatal("expected detail on exit error")
}
if !strings.Contains(exitErr.Detail.Message, "invalid JSON response") &&
!strings.Contains(exitErr.Detail.Message, "empty JSON response body") {
t.Fatalf("expected JSON diagnostic, got %q", exitErr.Detail.Message)
}
if !strings.Contains(exitErr.Detail.Hint, "--output") {
t.Fatalf("expected hint to mention --output, got %q", exitErr.Detail.Hint)
}
}
func TestApiCmd_PageAll_APIError_IsRaw(t *testing.T) {
f, _, _, reg := cmdutil.TestFactory(t, &core.CliConfig{
AppID: "test-app-rawpage", AppSecret: "test-secret-rawpage", Brand: core.BrandFeishu,
})
reg.Register(&httpmock.Stub{
URL: "/open-apis/test/rawpage",
Body: map[string]interface{}{
"code": 99991672,
"msg": "scope not enabled",
},
})
cmd := NewCmdApi(f, nil)
cmd.SetArgs([]string{"GET", "/open-apis/test/rawpage", "--as", "bot", "--page-all"})
err := cmd.Execute()
if err == nil {
t.Fatal("expected error")
}
var exitErr *output.ExitError
if !errors.As(err, &exitErr) {
t.Fatalf("expected *output.ExitError, got %T", err)
}
if !exitErr.Raw {
t.Error("expected paginated API error to be marked Raw")
}
}
func TestApiCmd_JqFlag_Parsing(t *testing.T) {
f, _, _, _ := cmdutil.TestFactory(t, &core.CliConfig{
AppID: "test-app", AppSecret: "test-secret", Brand: core.BrandFeishu,