Files
larksuite-cli/internal/output/lark_errors.go
evandance 99e314fe0b feat(errs): typed envelope contract for auth-domain errors (#1135)
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.
2026-05-30 19:08:41 +08:00

232 lines
11 KiB
Go

// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
package output
import (
"github.com/larksuite/cli/errs"
"github.com/larksuite/cli/internal/errclass"
)
// Lark API generic error code constants.
// ref: https://open.feishu.cn/document/server-docs/api-call-guide/generic-error-code
//
// Kept as exported identifiers because external shortcut packages reference
// them by name (e.g. LarkErrOwnershipMismatch). The canonical Category /
// Subtype / Retryable metadata for each code lives in internal/errclass and
// must remain the single source of truth — ClassifyLarkError below resolves
// classification through errclass.LookupCodeMeta.
const (
// Auth: token missing / invalid / expired.
LarkErrTokenMissing = 99991661 // Authorization header missing or empty
LarkErrTokenBadFmt = 99991671 // token format error (must start with "t-" or "u-")
LarkErrTokenInvalid = 99991668 // user_access_token invalid or expired
LarkErrATInvalid = 99991663 // access_token invalid (generic)
LarkErrTokenExpired = 99991677 // user_access_token expired, refresh to obtain a new one
// Permission: scope not granted.
LarkErrAppScopeNotEnabled = 99991672 // app has not applied for the required API scope
LarkErrTokenNoPermission = 99991676 // token lacks the required scope
LarkErrUserScopeInsufficient = 99991679 // user has not granted the required scope
LarkErrUserNotAuthorized = 230027 // user not authorized
// App credential / status.
LarkErrAppCredInvalid = 99991543 // app_id or app_secret is incorrect (Open API)
LarkErrAppNotInUse = 99991662 // app is disabled in this tenant
LarkErrAppUnauthorized = 99991673 // app status unavailable; check installation
// TAT-endpoint variant of the "wrong app credentials" condition.
// /open-apis/auth/v3/tenant_access_token/internal returns code 10014
// ("app secret invalid") instead of 99991543 when the secret is wrong.
LarkErrTATInvalidSecret = 10014
// Rate limit.
LarkErrRateLimit = 99991400 // request frequency limit exceeded
// Refresh token errors (authn service).
LarkErrRefreshInvalid = 20026 // refresh_token invalid or v1 format
LarkErrRefreshExpired = 20037 // refresh_token expired
LarkErrRefreshRevoked = 20064 // refresh_token revoked
LarkErrRefreshAlreadyUsed = 20073 // refresh_token already consumed (single-use rotation)
// Drive shortcut / cross-space constraints.
LarkErrDriveResourceContention = 1061045 // resource contention occurred, please retry
LarkErrDriveCrossTenantUnit = 1064510 // cross tenant and unit not support
LarkErrDriveCrossBrand = 1064511 // cross brand not support
// Wiki write-path lock contention (e.g. concurrent wiki +node-create under the
// same parent). Server-side write lock; transient, safe to retry with backoff.
LarkErrWikiLockContention = 131009
// Sheets float image: width/height/offset out of range or invalid.
LarkErrSheetsFloatImageInvalidDims = 1310246
// Drive permission apply: per-user-per-document submission limit (5/day) reached.
LarkErrDrivePermApplyRateLimit = 1063006
// Drive permission apply: request is not applicable for this document
// (e.g. the document is configured to disallow access requests, or the
// caller already holds the requested permission, or the target type does
// not accept apply operations).
LarkErrDrivePermApplyNotApplicable = 1063007
// IM resource ownership mismatch.
LarkErrOwnershipMismatch = 231205
// Mail send: account / mailbox-level failures returned by
// POST /open-apis/mail/v1/user_mailboxes/:user_mailbox_id/drafts/:draft_id/send.
// Mail v1 uses service-scoped 123xxxx codes; keep the full upstream code
// because ErrAPI preserves Detail.Code exactly as returned by the server.
// These codes indicate the entire batch will keep failing identically and
// are consumed by shortcuts/mail.isFatalSendErr to abort early.
LarkErrMailboxNotFound = 1234013 // mailbox not found or not active
LarkErrMailSendQuotaUser = 1236007 // user daily send count exceeded
LarkErrMailSendQuotaUserExt = 1236008 // user daily external recipient count exceeded
LarkErrMailSendQuotaTenantExt = 1236009 // tenant daily external recipient count exceeded
LarkErrMailQuota = 1236010 // mail quota limit
LarkErrTenantStorageLimit = 1236013 // tenant storage limit exceeded
)
// legacyHints supplies the per-code actionable hint string for the legacy
// (exitCode, errType, hint) tuple returned by ClassifyLarkError. Hint
// composition is not yet centralized in errclass (the canonical
// PermissionHint lives there but the long-form per-code hints below are
// still wire-stable strings), so this small lookup remains here. Codes
// absent from this map fall back to "".
var legacyHints = map[int]string{
LarkErrTokenMissing: "run: lark-cli auth login to re-authorize",
LarkErrTokenBadFmt: "run: lark-cli auth login to re-authorize",
LarkErrTokenInvalid: "run: lark-cli auth login to re-authorize",
LarkErrATInvalid: "run: lark-cli auth login to re-authorize",
LarkErrTokenExpired: "run: lark-cli auth login to re-authorize",
LarkErrAppScopeNotEnabled: "the app developer must apply for the required scope(s) at the developer console",
LarkErrTokenNoPermission: "check the token's granted scopes; run `lark-cli auth login` to refresh if the scope was added after the token was issued",
LarkErrUserScopeInsufficient: "run `lark-cli auth login` to re-authorize the user with the updated scope set",
LarkErrUserNotAuthorized: "run `lark-cli auth login` to re-authorize this user; if re-auth does not help, the operation may be blocked by external-chat or admin policy",
LarkErrAppCredInvalid: "run `lark-cli config init` to set valid app_id and app_secret",
LarkErrTATInvalidSecret: "run `lark-cli config init` to set valid app_id and app_secret",
LarkErrAppNotInUse: "ask the tenant admin to re-enable the app in the Lark admin console",
LarkErrAppUnauthorized: "ask the tenant admin to check the app's install status in the Lark admin console",
LarkErrRateLimit: "please try again later",
LarkErrDriveResourceContention: "please retry later and avoid concurrent duplicate requests",
LarkErrWikiLockContention: "wiki write lock contention on this parent node; retry with exponential backoff or serialize sibling-node writes",
LarkErrDriveCrossTenantUnit: "operate on source and target within the same tenant and region/unit",
LarkErrDriveCrossBrand: "operate on source and target within the same brand environment",
LarkErrSheetsFloatImageInvalidDims: "check --width / --height / --offset-x / --offset-y: " +
"width/height must be >= 20 px; offsets must be >= 0 and less than the anchor cell's width/height",
LarkErrDrivePermApplyRateLimit: "permission-apply quota reached: each user may request access on the same document at most 5 times per day; wait or ask the owner directly",
LarkErrDrivePermApplyNotApplicable: "this document does not accept a permission-apply request (common causes: the document is configured to disallow access requests, the caller already holds the permission, or the target type does not support apply); contact the owner directly",
}
// ClassifyLarkError maps a Lark API error code + message to the legacy
// (exitCode, errType, hint) tuple consumed by the *ExitError path.
//
// Classification is sourced from errclass.LookupCodeMeta (the single source
// of truth). exitCode follows legacyExitCode below, which differs from
// ExitCodeForCategory in two preserved-legacy quirks: Authorization +
// permission subtypes return ExitAPI (legacy treated "permission" as
// exit 1), and Config returns ExitAuth (legacy bundled "check
// app_id/secret" under exit 3). errType maps to a legacy short string;
// unknown subtypes fall back to "api_error". Unknown codes classify as
// (ExitAPI, "api_error", "").
//
// Deprecated: route Lark API responses through errclass.BuildAPIError,
// which emits a typed *errs.XxxError with Category, Subtype, and
// identity-aware extension fields populated at the source.
func ClassifyLarkError(code int, msg string) (int, string, string) {
meta, ok := errclass.LookupCodeMeta(code)
if !ok {
return ExitAPI, "api_error", ""
}
exitCode := legacyExitCode(meta.Category, meta.Subtype)
errType := legacyErrType(meta.Category, meta.Subtype)
hint := legacyHints[code]
// IM ownership mismatch keeps its dynamic recovery hint.
if code == LarkErrOwnershipMismatch {
hint = buildOwnershipRecoveryHint()
}
return exitCode, errType, hint
}
// legacyExitCode maps (Category, Subtype) to the legacy *ExitError exit
// code. It diverges from ExitCodeForCategory in two places to preserve the
// historic wire:
//
// - CategoryAuthorization with a "permission" subtype (missing_scope,
// app_scope_not_enabled, token_no_permission) → ExitAPI (1), not
// ExitAuth (3). Legacy considered permission failures a generic API
// refusal.
// - CategoryConfig → ExitAuth (3). Legacy bundled "check app_id/secret"
// under the auth bucket.
func legacyExitCode(cat errs.Category, sub errs.Subtype) int {
switch cat {
case errs.CategoryAuthentication:
return ExitAuth
case errs.CategoryAuthorization:
switch sub {
case errs.SubtypeMissingScope,
errs.SubtypeUserUnauthorized,
errs.SubtypeAppScopeNotApplied,
errs.SubtypeTokenScopeInsufficient:
return ExitAPI
case errs.SubtypeAppUnavailable,
errs.SubtypeAppDisabled:
return ExitAuth
}
return ExitAPI
case errs.CategoryConfig:
return ExitAuth
}
return ExitAPI
}
// legacyErrType maps (Category, Subtype) to the legacy *ExitError errType
// string (e.g. "permission", "rate_limit"). Subtypes outside the
// historically-classified set fall back to "api_error", matching the prior
// default-case behavior.
func legacyErrType(cat errs.Category, sub errs.Subtype) string {
switch cat {
case errs.CategoryAuthentication:
return "auth"
case errs.CategoryAuthorization:
switch sub {
case errs.SubtypeMissingScope,
errs.SubtypeUserUnauthorized,
errs.SubtypeAppScopeNotApplied,
errs.SubtypeTokenScopeInsufficient:
return "permission"
case errs.SubtypeAppUnavailable,
errs.SubtypeAppDisabled:
return "app_status"
}
return "permission"
case errs.CategoryConfig:
switch sub {
case errs.SubtypeInvalidClient,
errs.SubtypeNotConfigured,
errs.SubtypeInvalidConfig:
return "config"
}
return "config"
case errs.CategoryAPI:
switch sub {
case errs.SubtypeRateLimit:
return "rate_limit"
case errs.SubtypeConflict:
return "conflict"
case errs.SubtypeCrossTenant:
return "cross_tenant"
case errs.SubtypeCrossBrand:
return "cross_brand"
case errs.SubtypeInvalidParameters:
return "invalid_parameters"
case errs.SubtypeOwnershipMismatch:
return "ownership_mismatch"
}
return "api_error"
}
return "api_error"
}