Files
larksuite-cli/errs/types.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

752 lines
19 KiB
Go

// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
package errs
import (
"fmt"
"slices"
)
// formatMessage applies fmt.Sprintf only when args are present, so a
// caller passing a literal message with a stray "%" (e.g. "disk 100% full")
// is not rendered as "%!(NOVERB)". `go vet -printf` catches most accidental
// format misuse upstream; this guard makes the constructor safe even when
// the message string is dynamically composed.
func formatMessage(format string, args []any) string {
if len(args) == 0 {
return format
}
return fmt.Sprintf(format, args...)
}
// Typed error types and their builder APIs.
//
// Each typed error has:
// - A struct embedding Problem, with type-specific extension fields
// - A nil-safe Unwrap() method when the struct carries a Cause field
// - A NewXxxError(subtype, format, args...) constructor — Category locked
// by the function name, Subtype + Message positional and required
// - Chainable WithX(...) setters that return the concrete *XxxError pointer
// so type-specific setters remain reachable to the end of the chain
//
// Preferred shape for new code:
//
// return errs.NewValidationError(errs.SubtypeInvalidArgument,
// "invalid --start: %v", err).
// WithHint("expected RFC3339, e.g. 2026-05-26T10:00:00Z").
// WithParam("--start")
//
// Category is locked by the constructor name — it can never be mis-specified
// at the call site. Subtype + Message are required positional arguments so the
// compiler refuses to build a typed error missing either identity field.
// Subtype well-formedness is enforced at PR time by the lint guard
// CheckDeclaredSubtype (`lint/errscontract`), not at runtime, to avoid
// coupling the typed package to a registry. ad_hoc_* subtypes are accepted
// at runtime; CheckAdHocSubtype emits a follow-up warning.
// TypedError is implemented by all typed errors in this package.
// It identifies a value as a typed envelope producer to the dispatcher,
// which uses it to short-circuit promotion when the outer error is
// already typed (avoiding overwrite of producer-set Subtype/Hint).
type TypedError interface {
error
ProblemDetail() *Problem
}
// ============================== ValidationError ==============================
// ValidationError is the typed error for CategoryValidation.
// Cause preserves an optional wrapped sentinel for errors.Is / errors.Unwrap;
// it is intentionally not serialized.
type ValidationError struct {
Problem
Param string `json:"param,omitempty"`
Cause error `json:"-"`
}
// Unwrap exposes the wrapped cause so errors.Unwrap / errors.Is can traverse
// it. A nil typed-pointer held inside an error interface is treated as
// "no cause" so callers cannot panic on `errors.Unwrap(err)`.
func (e *ValidationError) Unwrap() error {
if e == nil {
return nil
}
return e.Cause
}
// Error returns the typed error message. Nil-safe — falls back to "" when the
// receiver is a typed nil pointer, mirroring the embedded Problem.Error() guard
// that promote-through-value-embed would otherwise bypass.
func (e *ValidationError) Error() string {
if e == nil {
return ""
}
return e.Problem.Error()
}
// NewValidationError constructs a *ValidationError with Category locked to
// CategoryValidation and Message formatted via fmt.Sprintf(format, args...).
func NewValidationError(subtype Subtype, format string, args ...any) *ValidationError {
return &ValidationError{
Problem: Problem{
Category: CategoryValidation,
Subtype: subtype,
Message: formatMessage(format, args),
},
}
}
func (e *ValidationError) WithHint(format string, args ...any) *ValidationError {
e.Hint = formatMessage(format, args)
return e
}
func (e *ValidationError) WithLogID(logID string) *ValidationError {
e.LogID = logID
return e
}
func (e *ValidationError) WithCode(code int) *ValidationError {
e.Code = code
return e
}
func (e *ValidationError) WithRetryable() *ValidationError {
e.Retryable = true
return e
}
func (e *ValidationError) WithParam(param string) *ValidationError {
e.Param = param
return e
}
func (e *ValidationError) WithCause(cause error) *ValidationError {
e.Cause = cause
return e
}
// =========================== AuthenticationError =============================
// AuthenticationError is the typed error for CategoryAuthentication.
// Cause preserves an optional wrapped sentinel for errors.Is / errors.Unwrap;
// it is intentionally not serialized.
type AuthenticationError struct {
Problem
UserOpenID string `json:"user_open_id,omitempty"`
Cause error `json:"-"`
}
// Unwrap is nil-receiver safe; see ValidationError.Unwrap.
func (e *AuthenticationError) Unwrap() error {
if e == nil {
return nil
}
return e.Cause
}
// Error is nil-receiver safe; see ValidationError.Error.
func (e *AuthenticationError) Error() string {
if e == nil {
return ""
}
return e.Problem.Error()
}
func NewAuthenticationError(subtype Subtype, format string, args ...any) *AuthenticationError {
return &AuthenticationError{
Problem: Problem{
Category: CategoryAuthentication,
Subtype: subtype,
Message: formatMessage(format, args),
},
}
}
func (e *AuthenticationError) WithHint(format string, args ...any) *AuthenticationError {
e.Hint = formatMessage(format, args)
return e
}
func (e *AuthenticationError) WithLogID(logID string) *AuthenticationError {
e.LogID = logID
return e
}
func (e *AuthenticationError) WithCode(code int) *AuthenticationError {
e.Code = code
return e
}
func (e *AuthenticationError) WithRetryable() *AuthenticationError {
e.Retryable = true
return e
}
func (e *AuthenticationError) WithUserOpenID(id string) *AuthenticationError {
e.UserOpenID = id
return e
}
func (e *AuthenticationError) WithCause(cause error) *AuthenticationError {
e.Cause = cause
return e
}
// ============================= PermissionError ===============================
// PermissionError is the typed error for CategoryAuthorization.
// Cause preserves an optional wrapped sentinel for errors.Is / errors.Unwrap;
// it is intentionally not serialized.
type PermissionError struct {
Problem
MissingScopes []string `json:"missing_scopes,omitempty"`
RequestedScopes []string `json:"requested_scopes,omitempty"`
GrantedScopes []string `json:"granted_scopes,omitempty"`
Identity string `json:"identity,omitempty"`
ConsoleURL string `json:"console_url,omitempty"`
Cause error `json:"-"`
}
// Unwrap is nil-receiver safe; see ValidationError.Unwrap.
func (e *PermissionError) Unwrap() error {
if e == nil {
return nil
}
return e.Cause
}
// Error is nil-receiver safe; see ValidationError.Error.
func (e *PermissionError) Error() string {
if e == nil {
return ""
}
return e.Problem.Error()
}
func NewPermissionError(subtype Subtype, format string, args ...any) *PermissionError {
return &PermissionError{
Problem: Problem{
Category: CategoryAuthorization,
Subtype: subtype,
Message: formatMessage(format, args),
},
}
}
func (e *PermissionError) WithHint(format string, args ...any) *PermissionError {
e.Hint = formatMessage(format, args)
return e
}
func (e *PermissionError) WithLogID(logID string) *PermissionError {
e.LogID = logID
return e
}
func (e *PermissionError) WithCode(code int) *PermissionError {
e.Code = code
return e
}
func (e *PermissionError) WithRetryable() *PermissionError {
e.Retryable = true
return e
}
func (e *PermissionError) WithMissingScopes(scopes ...string) *PermissionError {
e.MissingScopes = slices.Clone(scopes)
return e
}
func (e *PermissionError) WithRequestedScopes(scopes ...string) *PermissionError {
e.RequestedScopes = slices.Clone(scopes)
return e
}
func (e *PermissionError) WithGrantedScopes(scopes ...string) *PermissionError {
e.GrantedScopes = slices.Clone(scopes)
return e
}
func (e *PermissionError) WithIdentity(identity string) *PermissionError {
e.Identity = identity
return e
}
func (e *PermissionError) WithConsoleURL(url string) *PermissionError {
e.ConsoleURL = url
return e
}
func (e *PermissionError) WithCause(cause error) *PermissionError {
e.Cause = cause
return e
}
// =============================== ConfigError =================================
// ConfigError is the typed error for CategoryConfig. Cause preserves an
// optional wrapped sentinel for errors.Is / errors.Unwrap; it is
// intentionally not serialized.
type ConfigError struct {
Problem
Field string `json:"field,omitempty"`
Cause error `json:"-"`
}
// Unwrap is nil-receiver safe; see ValidationError.Unwrap.
func (e *ConfigError) Unwrap() error {
if e == nil {
return nil
}
return e.Cause
}
// Error is nil-receiver safe; see ValidationError.Error.
func (e *ConfigError) Error() string {
if e == nil {
return ""
}
return e.Problem.Error()
}
func NewConfigError(subtype Subtype, format string, args ...any) *ConfigError {
return &ConfigError{
Problem: Problem{
Category: CategoryConfig,
Subtype: subtype,
Message: formatMessage(format, args),
},
}
}
func (e *ConfigError) WithHint(format string, args ...any) *ConfigError {
e.Hint = formatMessage(format, args)
return e
}
func (e *ConfigError) WithLogID(logID string) *ConfigError {
e.LogID = logID
return e
}
func (e *ConfigError) WithCode(code int) *ConfigError {
e.Code = code
return e
}
func (e *ConfigError) WithRetryable() *ConfigError {
e.Retryable = true
return e
}
func (e *ConfigError) WithField(field string) *ConfigError {
e.Field = field
return e
}
func (e *ConfigError) WithCause(cause error) *ConfigError {
e.Cause = cause
return e
}
// =============================== NetworkError ================================
// NetworkError is the typed error for CategoryNetwork. The Subtype carries
// the failure taxonomy: timeout / tls / dns / server_error, with transport
// as the fallback. Cause preserves an optional wrapped sentinel for
// errors.Is / errors.Unwrap; it is intentionally not serialized.
type NetworkError struct {
Problem
Cause error `json:"-"`
}
// Unwrap is nil-receiver safe; see ValidationError.Unwrap.
func (e *NetworkError) Unwrap() error {
if e == nil {
return nil
}
return e.Cause
}
// Error is nil-receiver safe; see ValidationError.Error.
func (e *NetworkError) Error() string {
if e == nil {
return ""
}
return e.Problem.Error()
}
func NewNetworkError(subtype Subtype, format string, args ...any) *NetworkError {
return &NetworkError{
Problem: Problem{
Category: CategoryNetwork,
Subtype: subtype,
Message: formatMessage(format, args),
},
}
}
func (e *NetworkError) WithHint(format string, args ...any) *NetworkError {
e.Hint = formatMessage(format, args)
return e
}
func (e *NetworkError) WithLogID(logID string) *NetworkError {
e.LogID = logID
return e
}
func (e *NetworkError) WithCode(code int) *NetworkError {
e.Code = code
return e
}
func (e *NetworkError) WithRetryable() *NetworkError {
e.Retryable = true
return e
}
func (e *NetworkError) WithCause(cause error) *NetworkError {
e.Cause = cause
return e
}
// ================================ APIError ===================================
// APIError is the typed error for CategoryAPI (catch-all for classified Lark
// API business errors). Cause preserves an optional wrapped sentinel for
// errors.Is / errors.Unwrap; it is intentionally not serialized.
type APIError struct {
Problem
Cause error `json:"-"`
}
// Unwrap is nil-receiver safe; see ValidationError.Unwrap.
func (e *APIError) Unwrap() error {
if e == nil {
return nil
}
return e.Cause
}
// Error is nil-receiver safe; see ValidationError.Error.
func (e *APIError) Error() string {
if e == nil {
return ""
}
return e.Problem.Error()
}
func NewAPIError(subtype Subtype, format string, args ...any) *APIError {
return &APIError{
Problem: Problem{
Category: CategoryAPI,
Subtype: subtype,
Message: formatMessage(format, args),
},
}
}
func (e *APIError) WithHint(format string, args ...any) *APIError {
e.Hint = formatMessage(format, args)
return e
}
func (e *APIError) WithLogID(logID string) *APIError {
e.LogID = logID
return e
}
func (e *APIError) WithCode(code int) *APIError {
e.Code = code
return e
}
func (e *APIError) WithRetryable() *APIError {
e.Retryable = true
return e
}
func (e *APIError) WithCause(cause error) *APIError {
e.Cause = cause
return e
}
// =========================== SecurityPolicyError =============================
// SecurityPolicyError is the typed error for CategoryPolicy security-policy subtypes.
// Subtype is "challenge_required" or "access_denied"; Code is 21000 or 21001.
type SecurityPolicyError struct {
Problem
ChallengeURL string `json:"challenge_url,omitempty"`
Cause error `json:"-"`
}
// Unwrap is nil-receiver safe; see ValidationError.Unwrap.
func (e *SecurityPolicyError) Unwrap() error {
if e == nil {
return nil
}
return e.Cause
}
// Error is nil-receiver safe; see ValidationError.Error.
func (e *SecurityPolicyError) Error() string {
if e == nil {
return ""
}
return e.Problem.Error()
}
func NewSecurityPolicyError(subtype Subtype, format string, args ...any) *SecurityPolicyError {
return &SecurityPolicyError{
Problem: Problem{
Category: CategoryPolicy,
Subtype: subtype,
Message: formatMessage(format, args),
},
}
}
func (e *SecurityPolicyError) WithHint(format string, args ...any) *SecurityPolicyError {
e.Hint = formatMessage(format, args)
return e
}
func (e *SecurityPolicyError) WithLogID(logID string) *SecurityPolicyError {
e.LogID = logID
return e
}
func (e *SecurityPolicyError) WithCode(code int) *SecurityPolicyError {
e.Code = code
return e
}
func (e *SecurityPolicyError) WithRetryable() *SecurityPolicyError {
e.Retryable = true
return e
}
func (e *SecurityPolicyError) WithChallengeURL(url string) *SecurityPolicyError {
e.ChallengeURL = url
return e
}
func (e *SecurityPolicyError) WithCause(cause error) *SecurityPolicyError {
e.Cause = cause
return e
}
// ============================ ContentSafetyError =============================
// ContentSafetyError is the typed error for CategoryPolicy content-safety subtypes.
// Cause preserves an optional wrapped sentinel for errors.Is / errors.Unwrap;
// it is intentionally not serialized.
type ContentSafetyError struct {
Problem
Rules []string `json:"rules,omitempty"`
Cause error `json:"-"`
}
// Unwrap is nil-receiver safe; see ValidationError.Unwrap.
func (e *ContentSafetyError) Unwrap() error {
if e == nil {
return nil
}
return e.Cause
}
// Error is nil-receiver safe; see ValidationError.Error.
func (e *ContentSafetyError) Error() string {
if e == nil {
return ""
}
return e.Problem.Error()
}
func NewContentSafetyError(subtype Subtype, format string, args ...any) *ContentSafetyError {
return &ContentSafetyError{
Problem: Problem{
Category: CategoryPolicy,
Subtype: subtype,
Message: formatMessage(format, args),
},
}
}
func (e *ContentSafetyError) WithHint(format string, args ...any) *ContentSafetyError {
e.Hint = formatMessage(format, args)
return e
}
func (e *ContentSafetyError) WithLogID(logID string) *ContentSafetyError {
e.LogID = logID
return e
}
func (e *ContentSafetyError) WithCode(code int) *ContentSafetyError {
e.Code = code
return e
}
func (e *ContentSafetyError) WithRetryable() *ContentSafetyError {
e.Retryable = true
return e
}
func (e *ContentSafetyError) WithRules(rules ...string) *ContentSafetyError {
e.Rules = slices.Clone(rules)
return e
}
func (e *ContentSafetyError) WithCause(cause error) *ContentSafetyError {
e.Cause = cause
return e
}
// =============================== InternalError ===============================
// InternalError is the typed error for CategoryInternal. Cause is preserved
// for logging but not emitted on the wire.
type InternalError struct {
Problem
Cause error `json:"-"`
}
// Unwrap is nil-receiver safe; see ValidationError.Unwrap.
func (e *InternalError) Unwrap() error {
if e == nil {
return nil
}
return e.Cause
}
// Error is nil-receiver safe; see ValidationError.Error.
func (e *InternalError) Error() string {
if e == nil {
return ""
}
return e.Problem.Error()
}
func NewInternalError(subtype Subtype, format string, args ...any) *InternalError {
return &InternalError{
Problem: Problem{
Category: CategoryInternal,
Subtype: subtype,
Message: formatMessage(format, args),
},
}
}
func (e *InternalError) WithHint(format string, args ...any) *InternalError {
e.Hint = formatMessage(format, args)
return e
}
func (e *InternalError) WithLogID(logID string) *InternalError {
e.LogID = logID
return e
}
func (e *InternalError) WithCode(code int) *InternalError {
e.Code = code
return e
}
func (e *InternalError) WithRetryable() *InternalError {
e.Retryable = true
return e
}
func (e *InternalError) WithCause(cause error) *InternalError {
e.Cause = cause
return e
}
// ========================= ConfirmationRequiredError =========================
// Risk classifies the impact of a confirmation-required operation. Every
// ConfirmationRequiredError MUST populate Risk; callers without a known
// risk level use RiskUnknown so the envelope is never wire-invalid.
const (
RiskRead = "read"
RiskWrite = "write"
RiskHighRiskWrite = "high-risk-write"
RiskUnknown = "unknown"
)
// ConfirmationRequiredError is the typed error for CategoryConfirmation.
// Risk is one of: "read" | "write" | "high-risk-write" | "unknown".
// Cause preserves an optional wrapped sentinel for errors.Is / errors.Unwrap;
// it is intentionally not serialized.
type ConfirmationRequiredError struct {
Problem
Risk string `json:"risk"`
Action string `json:"action"`
Cause error `json:"-"`
}
// Unwrap is nil-receiver safe; see ValidationError.Unwrap.
func (e *ConfirmationRequiredError) Unwrap() error {
if e == nil {
return nil
}
return e.Cause
}
// Error is nil-receiver safe; see ValidationError.Error.
func (e *ConfirmationRequiredError) Error() string {
if e == nil {
return ""
}
return e.Problem.Error()
}
// NewConfirmationRequiredError constructs a *ConfirmationRequiredError.
// Risk + Action are wire-required (non-omitempty). Empty inputs are
// normalized at the constructor boundary so callers cannot build a
// wire-invalid envelope: risk falls back to RiskUnknown, action to
// "unknown". risk is one of: "read" | "write" | "high-risk-write".
func NewConfirmationRequiredError(risk, action, format string, args ...any) *ConfirmationRequiredError {
if risk == "" {
risk = RiskUnknown
}
if action == "" {
action = "unknown"
}
return &ConfirmationRequiredError{
Problem: Problem{
Category: CategoryConfirmation,
Subtype: SubtypeConfirmationRequired,
Message: formatMessage(format, args),
},
Risk: risk,
Action: action,
}
}
func (e *ConfirmationRequiredError) WithHint(format string, args ...any) *ConfirmationRequiredError {
e.Hint = formatMessage(format, args)
return e
}
func (e *ConfirmationRequiredError) WithLogID(logID string) *ConfirmationRequiredError {
e.LogID = logID
return e
}
func (e *ConfirmationRequiredError) WithCode(code int) *ConfirmationRequiredError {
e.Code = code
return e
}
func (e *ConfirmationRequiredError) WithCause(cause error) *ConfirmationRequiredError {
e.Cause = cause
return e
}