chore: import upstream snapshot with attribution
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
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
This commit is contained in:
@@ -0,0 +1,491 @@
|
||||
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package errclass
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"net/url"
|
||||
"strings"
|
||||
|
||||
"github.com/larksuite/cli/errs"
|
||||
"github.com/larksuite/cli/internal/core"
|
||||
)
|
||||
|
||||
// ClassifyContext is the contextual data BuildAPIError uses to populate
|
||||
// identity-aware fields on typed errors (PermissionError.Identity / ConsoleURL).
|
||||
// Brand and Identity are plain strings at this boundary; ConsoleURL normalizes
|
||||
// Brand through core.ParseBrand, so callers can pass a raw brand string without
|
||||
// coupling this contract to core's brand enum.
|
||||
type ClassifyContext struct {
|
||||
Brand string // "feishu" | "lark" — drives console_url host
|
||||
AppID string // placed in console_url
|
||||
Identity string // "user" / "bot" / "" — caller converts core.Identity at the boundary
|
||||
LarkCmd string // e.g. "drive +delete" — used as Action fallback on CategoryConfirmation arm
|
||||
}
|
||||
|
||||
// BuildAPIError consumes a parsed Lark API response and returns a typed error.
|
||||
// Returns nil when resp is nil or resp["code"] is 0.
|
||||
//
|
||||
// Routing by Category:
|
||||
//
|
||||
// Authorization → *errs.PermissionError (with MissingScopes / Identity / ConsoleURL)
|
||||
// Authentication → *errs.AuthenticationError
|
||||
// Config → *errs.ConfigError
|
||||
// Policy → *errs.SecurityPolicyError
|
||||
// Validation → *errs.ValidationError
|
||||
// Network → *errs.NetworkError
|
||||
// Internal → *errs.InternalError
|
||||
// Confirmation → *errs.ConfirmationRequiredError
|
||||
// default (CategoryAPI) → *errs.APIError (catch-all for classified Lark business errors)
|
||||
//
|
||||
// Unknown Lark codes (LookupCodeMeta returns false) fall back to
|
||||
// CategoryAPI + SubtypeUnknown.
|
||||
func BuildAPIError(resp map[string]any, cc ClassifyContext) error {
|
||||
if resp == nil {
|
||||
return nil
|
||||
}
|
||||
code := intFromAny(resp["code"])
|
||||
if code == 0 {
|
||||
return nil
|
||||
}
|
||||
msg, _ := resp["msg"].(string)
|
||||
if msg == "" {
|
||||
// Upstream omitted or sent non-string msg. Keep Problem.Message non-empty
|
||||
// so the typed wire envelope still carries a human-readable signal.
|
||||
msg = fmt.Sprintf("API error: [%d]", code)
|
||||
}
|
||||
// Lark API responses sometimes carry log_id at the top level
|
||||
// ({"code":..., "log_id":"..."}) and sometimes nested under "error"
|
||||
// ({"code":..., "error":{"log_id":"..."}}). Prefer top level and fall
|
||||
// back to the nested location so log_id always surfaces on the typed
|
||||
// envelope.
|
||||
logID, _ := resp["log_id"].(string)
|
||||
if logID == "" {
|
||||
if errBlock, ok := resp["error"].(map[string]any); ok {
|
||||
if nested, ok := errBlock["log_id"].(string); ok {
|
||||
logID = nested
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
meta, ok := LookupCodeMeta(code)
|
||||
if !ok {
|
||||
meta = CodeMeta{Category: errs.CategoryAPI, Subtype: errs.SubtypeUnknown}
|
||||
}
|
||||
|
||||
base := errs.Problem{
|
||||
Category: meta.Category,
|
||||
Subtype: meta.Subtype,
|
||||
Code: code,
|
||||
Message: msg,
|
||||
LogID: logID,
|
||||
Retryable: meta.Retryable,
|
||||
}
|
||||
// Upstream-provided diagnostic URL (resp.error.troubleshooter). Lifted
|
||||
// universally before the category switch so every classified typed
|
||||
// error surfaces it when present. The remaining contents of resp["error"]
|
||||
// (permission_violations.subject, data.challenge_url, data.hint) are
|
||||
// either lifted into category-specific typed extension fields below or
|
||||
// intentionally dropped as redundant with the typed envelope.
|
||||
if errBlock, ok := resp["error"].(map[string]any); ok {
|
||||
if ts, _ := errBlock["troubleshooter"].(string); ts != "" {
|
||||
base.Troubleshooter = ts
|
||||
}
|
||||
}
|
||||
// Upstream-provided field-level reasons (resp.error.details[].value). Lark
|
||||
// returns these as free-text reason strings with no machine-readable field
|
||||
// name (verified for code 190014:
|
||||
// {"error":{"details":[{"value":"end_time should be later than start_time"}]}}),
|
||||
// so they are lifted into Problem.Hint — the sanctioned free-text recovery
|
||||
// prompt — rather than fabricated structured params. Lifted before the
|
||||
// category switch so any classified arm inherits it; the CategoryAPI arm
|
||||
// below prefers this server detail over the context-free APIHint default.
|
||||
detailHint := liftErrorDetailValues(resp)
|
||||
if detailHint != "" {
|
||||
base.Hint = detailHint
|
||||
}
|
||||
|
||||
switch meta.Category {
|
||||
case errs.CategoryAuthorization:
|
||||
return buildPermissionError(base, resp, cc)
|
||||
case errs.CategoryAuthentication:
|
||||
return &errs.AuthenticationError{Problem: base}
|
||||
case errs.CategoryConfig:
|
||||
return buildConfigError(base)
|
||||
case errs.CategoryPolicy:
|
||||
return buildSecurityPolicyError(base, resp)
|
||||
case errs.CategoryValidation:
|
||||
return &errs.ValidationError{Problem: base}
|
||||
case errs.CategoryNetwork:
|
||||
return &errs.NetworkError{Problem: base}
|
||||
case errs.CategoryInternal:
|
||||
return &errs.InternalError{Problem: base}
|
||||
case errs.CategoryConfirmation:
|
||||
// Risk + Action are non-omitempty wire fields. Derive from
|
||||
// CodeMeta when available; otherwise emit RiskUnknown +
|
||||
// ctx.LarkCmd placeholder so the envelope is never wire-invalid.
|
||||
risk := meta.Risk
|
||||
if risk == "" {
|
||||
risk = errs.RiskUnknown
|
||||
}
|
||||
action := meta.Action
|
||||
if action == "" {
|
||||
action = cc.LarkCmd
|
||||
}
|
||||
if action == "" {
|
||||
action = "unknown"
|
||||
}
|
||||
return &errs.ConfirmationRequiredError{
|
||||
Problem: base,
|
||||
Risk: risk,
|
||||
Action: action,
|
||||
}
|
||||
case errs.CategoryAPI:
|
||||
// A server-supplied detail (lifted into base.Hint above) wins over the
|
||||
// context-free APIHint default; only fall back to APIHint when absent.
|
||||
if base.Hint == "" {
|
||||
base.Hint = APIHint(base.Subtype) // "" for subtypes without a context-free default
|
||||
}
|
||||
return &errs.APIError{Problem: base}
|
||||
default:
|
||||
// Fail closed: an unrecognized Category routes to InternalError
|
||||
// instead of emitting an empty Problem on the wire.
|
||||
return &errs.InternalError{
|
||||
Problem: errs.Problem{
|
||||
Category: errs.CategoryInternal,
|
||||
Subtype: errs.SubtypeSDKError,
|
||||
Code: base.Code,
|
||||
Message: fmt.Sprintf("unrecognized Category %q for code %d", base.Category, base.Code),
|
||||
LogID: base.LogID,
|
||||
},
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// buildSecurityPolicyError extracts challenge_url and the hint from a Lark API
|
||||
// response's data block, so the typed SecurityPolicyError carries the same
|
||||
// browser-challenge information that internal/auth/transport.go surfaces at
|
||||
// the HTTP layer.
|
||||
//
|
||||
// Data shapes accepted (whichever the upstream sends):
|
||||
//
|
||||
// {"code": 21000, "msg": "...", "data": {"challenge_url": "...", "hint"|"cli_hint": "..."}}
|
||||
// {"code": 21000, "error": {"data": {"challenge_url": "...", "hint"|"cli_hint": "..."}}}
|
||||
//
|
||||
// challenge_url is dropped (set to "") if it is not an https:// URL — same
|
||||
// validation policy as internal/auth/transport.go.isValidChallengeURL.
|
||||
// Hint is read from `data.hint` first and falls back to `data.cli_hint` so
|
||||
// either spelling surfaces, matching the transport layer.
|
||||
func buildSecurityPolicyError(p errs.Problem, resp map[string]any) *errs.SecurityPolicyError {
|
||||
dataMap, _ := resp["data"].(map[string]any)
|
||||
if dataMap == nil {
|
||||
if errBlock, ok := resp["error"].(map[string]any); ok {
|
||||
dataMap, _ = errBlock["data"].(map[string]any)
|
||||
}
|
||||
}
|
||||
if dataMap == nil {
|
||||
return &errs.SecurityPolicyError{Problem: p}
|
||||
}
|
||||
|
||||
challengeURL := strings.Trim(stringFromAny(dataMap["challenge_url"]), " `")
|
||||
if challengeURL != "" && !isHTTPSURL(challengeURL) {
|
||||
challengeURL = ""
|
||||
}
|
||||
|
||||
hint := stringFromAny(dataMap["hint"])
|
||||
if hint == "" {
|
||||
hint = stringFromAny(dataMap["cli_hint"])
|
||||
}
|
||||
if hint != "" {
|
||||
p.Hint = hint
|
||||
}
|
||||
|
||||
return &errs.SecurityPolicyError{
|
||||
Problem: p,
|
||||
ChallengeURL: challengeURL,
|
||||
}
|
||||
}
|
||||
|
||||
// isHTTPSURL is the local-to-errclass duplicate of internal/auth/transport.go's
|
||||
// isValidChallengeURL. Kept local to avoid coupling errclass to internal/auth;
|
||||
// the two collapse once the auth transport adopts BuildAPIError directly.
|
||||
func isHTTPSURL(rawURL string) bool {
|
||||
if rawURL == "" {
|
||||
return false
|
||||
}
|
||||
u, err := url.Parse(rawURL)
|
||||
if err != nil {
|
||||
return false
|
||||
}
|
||||
return u.Scheme == "https"
|
||||
}
|
||||
|
||||
// stringFromAny coerces a map value to string when it is a string, returning "" otherwise.
|
||||
func stringFromAny(v any) string {
|
||||
s, _ := v.(string)
|
||||
return s
|
||||
}
|
||||
|
||||
// buildConfigError enriches a typed ConfigError with the canonical
|
||||
// per-subtype recovery hint before returning it, so the wire envelope
|
||||
// emitted via BuildAPIError always carries a hint for known config subtypes.
|
||||
func buildConfigError(p errs.Problem) *errs.ConfigError {
|
||||
// Config categories have authoritative recovery guidance, so the curated
|
||||
// ConfigHint deliberately overrides any server detail lifted into p.Hint
|
||||
// (the opposite precedence from the CategoryAPI arm, where the lifted
|
||||
// detail wins).
|
||||
p.Hint = ConfigHint(p.Subtype)
|
||||
return &errs.ConfigError{Problem: p}
|
||||
}
|
||||
|
||||
// ConfigHint returns the canonical per-subtype recovery hint for a typed
|
||||
// ConfigError emitted via BuildAPIError.
|
||||
func ConfigHint(subtype errs.Subtype) string {
|
||||
switch subtype {
|
||||
case errs.SubtypeInvalidClient:
|
||||
return "run `lark-cli config init` to set valid app_id and app_secret"
|
||||
case errs.SubtypeNotConfigured:
|
||||
return "run `lark-cli config init` to set up app_id and app_secret"
|
||||
case errs.SubtypeInvalidConfig:
|
||||
return "check the config file for syntax errors; rerun `lark-cli config init` to reset"
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
// APIHint returns the canonical per-subtype recovery hint for a typed APIError
|
||||
// emitted via BuildAPIError, for API subtypes whose recovery is context-free.
|
||||
// Context-specific guidance (e.g. a command's flags, an API's own quota) is
|
||||
// layered on by the caller after BuildAPIError returns and overrides this.
|
||||
func APIHint(subtype errs.Subtype) string {
|
||||
switch subtype {
|
||||
case errs.SubtypeConflict:
|
||||
return "retry later and avoid concurrent duplicate requests on the same resource"
|
||||
case errs.SubtypeCrossTenant:
|
||||
return "operate on source and target within the same tenant and region/unit"
|
||||
case errs.SubtypeCrossBrand:
|
||||
return "operate on source and target within the same brand environment"
|
||||
case errs.SubtypeQuotaExceeded:
|
||||
return "reduce the request volume or free quota, then retry after the relevant quota resets"
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
func buildPermissionError(p errs.Problem, resp map[string]any, cc ClassifyContext) *errs.PermissionError {
|
||||
missing := extractMissingScopes(resp)
|
||||
identity := cc.Identity
|
||||
if identity == "" {
|
||||
identity = "user"
|
||||
}
|
||||
consoleURL := ConsoleURL(cc.Brand, cc.AppID, missing)
|
||||
p.Message = CanonicalPermissionMessage(p.Subtype, cc.AppID, missing, p.Message)
|
||||
// Permission categories have authoritative recovery guidance (scopes to
|
||||
// grant, console URL), so the curated PermissionHint deliberately overrides
|
||||
// any server detail lifted into p.Hint (the opposite precedence from the
|
||||
// CategoryAPI arm, where the lifted detail wins).
|
||||
p.Hint = PermissionHint(missing, identity, p.Subtype, consoleURL)
|
||||
permErr := &errs.PermissionError{
|
||||
Problem: p,
|
||||
MissingScopes: missing,
|
||||
Identity: identity,
|
||||
}
|
||||
// ConsoleURL is the developer-console deep-link an app developer follows to
|
||||
// apply for a missing scope. That action only resolves SubtypeAppScopeNotApplied,
|
||||
// which is bot-perspective. The other authorization subtypes route to a
|
||||
// different actor: SubtypeMissingScope / SubtypeTokenScopeInsufficient /
|
||||
// SubtypeUserUnauthorized recover via `lark-cli auth login`; SubtypeAppUnavailable
|
||||
// / SubtypeAppDisabled require tenant admin. Carrying ConsoleURL on those
|
||||
// envelopes is dead weight and risks pointing an end user at a console they
|
||||
// cannot modify; the URL is still computed so the hint composer can use it
|
||||
// where appropriate.
|
||||
if p.Subtype == errs.SubtypeAppScopeNotApplied {
|
||||
permErr.ConsoleURL = consoleURL
|
||||
}
|
||||
return permErr
|
||||
}
|
||||
|
||||
// CanonicalPermissionMessage returns the CLI-side canonical wording for a
|
||||
// typed PermissionError, preserving the Lark official-API phrasing
|
||||
// ("access denied" / "unauthorized" / "token has no permission") and
|
||||
// enhancing it with CLI context (app ID, missing scope list). Subtypes
|
||||
// outside the known set fall through to fallback so the upstream message
|
||||
// is preserved.
|
||||
func CanonicalPermissionMessage(subtype errs.Subtype, appID string, missing []string, fallback string) string {
|
||||
switch subtype {
|
||||
case errs.SubtypeAppScopeNotApplied:
|
||||
if len(missing) > 0 {
|
||||
scopes := strings.Join(missing, ", ")
|
||||
if appID != "" {
|
||||
return fmt.Sprintf("access denied: app %s has not applied for the required scope(s): %s", appID, scopes)
|
||||
}
|
||||
return fmt.Sprintf("access denied: app has not applied for the required scope(s): %s", scopes)
|
||||
}
|
||||
if appID != "" {
|
||||
return fmt.Sprintf("access denied: app %s has not applied for the required scope(s)", appID)
|
||||
}
|
||||
return "access denied: app has not applied for the required scope(s)"
|
||||
case errs.SubtypeMissingScope:
|
||||
if len(missing) > 0 {
|
||||
return fmt.Sprintf("unauthorized: user authorization does not cover the required scope(s): %s", strings.Join(missing, ", "))
|
||||
}
|
||||
return "unauthorized: user authorization does not cover the required scope"
|
||||
case errs.SubtypeTokenScopeInsufficient:
|
||||
return "token has no permission for this operation; required scope is missing"
|
||||
case errs.SubtypeUserUnauthorized:
|
||||
return "access denied for this operation; possible causes: missing scope, missing user authorization, or restricted by tenant policy"
|
||||
case errs.SubtypeAppUnavailable:
|
||||
if appID != "" {
|
||||
return fmt.Sprintf("unauthorized app: app %s is not properly installed in this tenant", appID)
|
||||
}
|
||||
return "unauthorized app: app is not properly installed in this tenant"
|
||||
case errs.SubtypeAppDisabled:
|
||||
if appID != "" {
|
||||
return fmt.Sprintf("app %s is not in use in this tenant (currently disabled)", appID)
|
||||
}
|
||||
return "app is not in use in this tenant (currently disabled)"
|
||||
case errs.SubtypePermissionDenied:
|
||||
return "user lacks permission for the requested resource"
|
||||
}
|
||||
return fallback
|
||||
}
|
||||
|
||||
// PermissionHint returns the canonical per-subtype recovery hint for a typed
|
||||
// PermissionError. The hint distinguishes authorization subtypes routing
|
||||
// to different recovery paths: developer console for app_scope_not_applied,
|
||||
// user re-login for missing_scope / token_scope_insufficient / user_unauthorized,
|
||||
// and tenant admin for app_unavailable / app_disabled. The subtype
|
||||
// argument is the primary discriminator; identity is retained for the
|
||||
// generic permission_denied fallback so callers that do not yet route on
|
||||
// subtype still get a sensible hint.
|
||||
//
|
||||
// Exported so direct construction sites (cmd/service/service.go's
|
||||
// checkServiceScopes) can produce hints that match the dispatcher path
|
||||
// byte-for-byte instead of hand-rolling divergent strings.
|
||||
func PermissionHint(missing []string, identity string, subtype errs.Subtype, consoleURL string) string {
|
||||
switch subtype {
|
||||
case errs.SubtypeAppScopeNotApplied:
|
||||
if consoleURL != "" {
|
||||
return fmt.Sprintf("the app developer must apply for the required scope(s) at the developer console: %s", consoleURL)
|
||||
}
|
||||
return "the app developer must apply for the required scope(s) at the developer console"
|
||||
case errs.SubtypeMissingScope:
|
||||
if len(missing) > 0 {
|
||||
return fmt.Sprintf("run `lark-cli auth login --scope \"%s\"` to re-authorize the user with the updated scope set", strings.Join(missing, " "))
|
||||
}
|
||||
return "run `lark-cli auth login` to re-authorize the user with the updated scope set"
|
||||
case errs.SubtypeTokenScopeInsufficient:
|
||||
return "check the token's granted scopes; run `lark-cli auth login` to refresh if the scope was added after the token was issued"
|
||||
case errs.SubtypeUserUnauthorized:
|
||||
return "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"
|
||||
case errs.SubtypeAppUnavailable:
|
||||
return "ask the tenant admin to check the app's install status in the Lark admin console"
|
||||
case errs.SubtypeAppDisabled:
|
||||
return "ask the tenant admin to re-enable the app in the Lark admin console"
|
||||
case errs.SubtypePermissionDenied:
|
||||
who := "this user"
|
||||
if identity == "bot" {
|
||||
who = "this bot"
|
||||
}
|
||||
return fmt.Sprintf("check the resource owner has granted access to %s", who)
|
||||
}
|
||||
return "check the calling identity has the required scope"
|
||||
}
|
||||
|
||||
// liftErrorDetailValues collects the non-empty resp.error.details[].value reason
|
||||
// strings and joins them with "; ". Returns "" when the structure is absent or
|
||||
// carries no non-empty value. The shape (verified for code 190014) is
|
||||
// {"error":{"details":[{"value":"<reason>"}]}}.
|
||||
func liftErrorDetailValues(resp map[string]any) string {
|
||||
errBlock, ok := resp["error"].(map[string]any)
|
||||
if !ok {
|
||||
return ""
|
||||
}
|
||||
details, ok := errBlock["details"].([]any)
|
||||
if !ok || len(details) == 0 {
|
||||
return ""
|
||||
}
|
||||
var values []string
|
||||
for _, d := range details {
|
||||
m, ok := d.(map[string]any)
|
||||
if !ok {
|
||||
continue
|
||||
}
|
||||
if v, _ := m["value"].(string); v != "" {
|
||||
values = append(values, v)
|
||||
}
|
||||
}
|
||||
return strings.Join(values, "; ")
|
||||
}
|
||||
|
||||
// extractMissingScopes walks resp["error"]["permission_violations"][].subject.
|
||||
// Returns nil when the structure is absent.
|
||||
func extractMissingScopes(resp map[string]any) []string {
|
||||
errBlock, ok := resp["error"].(map[string]any)
|
||||
if !ok {
|
||||
return nil
|
||||
}
|
||||
raw, ok := errBlock["permission_violations"].([]any)
|
||||
if !ok || len(raw) == 0 {
|
||||
return nil
|
||||
}
|
||||
seen := map[string]bool{}
|
||||
var out []string
|
||||
for _, v := range raw {
|
||||
m, ok := v.(map[string]any)
|
||||
if !ok {
|
||||
continue
|
||||
}
|
||||
s, _ := m["subject"].(string)
|
||||
if s == "" || seen[s] {
|
||||
continue
|
||||
}
|
||||
seen[s] = true
|
||||
out = append(out, s)
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// ConsoleURL composes the Feishu/Lark open-platform application-scope apply
|
||||
// page URL (the official open-pages `/page/scope-apply` entry), suitable for
|
||||
// PermissionError.ConsoleURL. Empty appID → empty string. Empty scopes list
|
||||
// returns the page carrying only clientID; otherwise scopes are joined with
|
||||
// commas in the `scopes` query parameter so the console can pre-select them.
|
||||
//
|
||||
// brand is "feishu" or "lark"; unknown values default to feishu.
|
||||
func ConsoleURL(brand, appID string, scopes []string) string {
|
||||
if appID == "" {
|
||||
return ""
|
||||
}
|
||||
// QueryEscape both values — clientID and scopes both sit in the query
|
||||
// string, and untrusted content must not be able to inject extra query
|
||||
// parameters via `&`/`#`. The brand→host mapping is owned by core so the
|
||||
// open-platform base URL stays a single source of truth.
|
||||
base := fmt.Sprintf("%s/page/scope-apply?clientID=%s",
|
||||
core.ResolveOpenBaseURL(core.ParseBrand(brand)), url.QueryEscape(appID))
|
||||
if len(scopes) == 0 {
|
||||
return base
|
||||
}
|
||||
return base + "&scopes=" + url.QueryEscape(strings.Join(scopes, ","))
|
||||
}
|
||||
|
||||
func intFromAny(v any) int {
|
||||
switch n := v.(type) {
|
||||
case int:
|
||||
return n
|
||||
case int64:
|
||||
return int(n)
|
||||
case float64:
|
||||
return int(n)
|
||||
case json.Number:
|
||||
i, err := n.Int64()
|
||||
if err == nil {
|
||||
return int(i)
|
||||
}
|
||||
f, err := n.Float64()
|
||||
if err == nil {
|
||||
return int(f)
|
||||
}
|
||||
}
|
||||
return 0
|
||||
}
|
||||
@@ -0,0 +1,136 @@
|
||||
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package errclass
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/larksuite/cli/errs"
|
||||
)
|
||||
|
||||
// TestBuildAPIError_CategoryConfirmationFillsRiskAction pins fail-closed
|
||||
// behaviour: a code mapped to CategoryConfirmation MUST yield a
|
||||
// ConfirmationRequiredError whose Risk + Action are non-empty even when the
|
||||
// CodeMeta itself carries no Risk/Action hints. Risk falls back to
|
||||
// RiskUnknown; Action falls back to ctx.LarkCmd.
|
||||
func TestBuildAPIError_CategoryConfirmationFillsRiskAction(t *testing.T) {
|
||||
const stubCode = 99999991
|
||||
codeMeta[stubCode] = CodeMeta{
|
||||
Category: errs.CategoryConfirmation,
|
||||
Subtype: errs.SubtypeConfirmationRequired,
|
||||
}
|
||||
t.Cleanup(func() { delete(codeMeta, stubCode) })
|
||||
|
||||
resp := map[string]any{"code": stubCode, "msg": "confirmation required"}
|
||||
ctx := ClassifyContext{
|
||||
Brand: "feishu",
|
||||
AppID: "cli_test",
|
||||
Identity: "user",
|
||||
LarkCmd: "drive +delete",
|
||||
}
|
||||
err := BuildAPIError(resp, ctx)
|
||||
var confirmErr *errs.ConfirmationRequiredError
|
||||
if !errors.As(err, &confirmErr) {
|
||||
t.Fatalf("expected *ConfirmationRequiredError, got %T: %v", err, err)
|
||||
}
|
||||
if confirmErr.Risk == "" {
|
||||
t.Error("Risk empty; arm must fail-closed with RiskUnknown")
|
||||
}
|
||||
if confirmErr.Risk != errs.RiskUnknown {
|
||||
t.Errorf("Risk = %q, want %q (CodeMeta carried no Risk hint)",
|
||||
confirmErr.Risk, errs.RiskUnknown)
|
||||
}
|
||||
if confirmErr.Action == "" {
|
||||
t.Error("Action empty; arm must fail-closed with command name from ClassifyContext")
|
||||
}
|
||||
if confirmErr.Action != "drive +delete" {
|
||||
t.Errorf("Action = %q, want %q (ctx.LarkCmd fallback)",
|
||||
confirmErr.Action, "drive +delete")
|
||||
}
|
||||
}
|
||||
|
||||
// TestBuildAPIError_CategoryConfirmationPrefersCodeMetaHints pins that when
|
||||
// CodeMeta carries explicit Risk + Action, the dispatcher uses them rather
|
||||
// than falling back to RiskUnknown / ctx.LarkCmd.
|
||||
func TestBuildAPIError_CategoryConfirmationPrefersCodeMetaHints(t *testing.T) {
|
||||
const stubCode = 99999992
|
||||
codeMeta[stubCode] = CodeMeta{
|
||||
Category: errs.CategoryConfirmation,
|
||||
Subtype: errs.SubtypeConfirmationRequired,
|
||||
Risk: errs.RiskHighRiskWrite,
|
||||
Action: "wiki:delete-space",
|
||||
}
|
||||
t.Cleanup(func() { delete(codeMeta, stubCode) })
|
||||
|
||||
resp := map[string]any{"code": stubCode, "msg": "confirmation required"}
|
||||
ctx := ClassifyContext{LarkCmd: "drive +delete"}
|
||||
err := BuildAPIError(resp, ctx)
|
||||
var confirmErr *errs.ConfirmationRequiredError
|
||||
if !errors.As(err, &confirmErr) {
|
||||
t.Fatalf("expected *ConfirmationRequiredError, got %T: %v", err, err)
|
||||
}
|
||||
if confirmErr.Risk != errs.RiskHighRiskWrite {
|
||||
t.Errorf("Risk = %q, want %q (CodeMeta hint should win)",
|
||||
confirmErr.Risk, errs.RiskHighRiskWrite)
|
||||
}
|
||||
if confirmErr.Action != "wiki:delete-space" {
|
||||
t.Errorf("Action = %q, want %q (CodeMeta hint should win)",
|
||||
confirmErr.Action, "wiki:delete-space")
|
||||
}
|
||||
}
|
||||
|
||||
// TestBuildAPIError_UnknownCategoryRoutesToInternalError pins fail-closed
|
||||
// behaviour: an unrecognized Category routes to InternalError instead of
|
||||
// emitting an empty Problem on the wire.
|
||||
func TestBuildAPIError_UnknownCategoryRoutesToInternalError(t *testing.T) {
|
||||
const stubCode = 99999993
|
||||
codeMeta[stubCode] = CodeMeta{
|
||||
Category: errs.Category("totally_unknown_category"),
|
||||
Subtype: errs.SubtypeUnknown,
|
||||
}
|
||||
t.Cleanup(func() { delete(codeMeta, stubCode) })
|
||||
|
||||
resp := map[string]any{"code": stubCode, "msg": "weird"}
|
||||
err := BuildAPIError(resp, ClassifyContext{})
|
||||
var ie *errs.InternalError
|
||||
if !errors.As(err, &ie) {
|
||||
t.Fatalf("expected *InternalError, got %T: %v", err, err)
|
||||
}
|
||||
if ie.Category != errs.CategoryInternal {
|
||||
t.Errorf("Category = %q, want %q", ie.Category, errs.CategoryInternal)
|
||||
}
|
||||
if ie.Subtype != errs.SubtypeSDKError {
|
||||
t.Errorf("Subtype = %q, want %q", ie.Subtype, errs.SubtypeSDKError)
|
||||
}
|
||||
if ie.Code != stubCode {
|
||||
t.Errorf("Code = %d, want %d (raw Lark code should propagate)", ie.Code, stubCode)
|
||||
}
|
||||
}
|
||||
|
||||
// TestBuildAPIError_ConfigInvalidClient_HasHint pins that when a
|
||||
// CategoryConfig response (Lark code 10014 — "app secret invalid") flows
|
||||
// through BuildAPIError, the resulting *ConfigError MUST carry the canonical
|
||||
// recovery hint pointing the user at `lark-cli config init`.
|
||||
func TestBuildAPIError_ConfigInvalidClient_HasHint(t *testing.T) {
|
||||
const code = 10014
|
||||
resp := map[string]any{"code": code, "msg": "app secret invalid"}
|
||||
ctx := ClassifyContext{Brand: "feishu", AppID: "cli_test", Identity: "bot"}
|
||||
|
||||
err := BuildAPIError(resp, ctx)
|
||||
var cfgErr *errs.ConfigError
|
||||
if !errors.As(err, &cfgErr) {
|
||||
t.Fatalf("expected *ConfigError, got %T: %v", err, err)
|
||||
}
|
||||
if cfgErr.Subtype != errs.SubtypeInvalidClient {
|
||||
t.Errorf("Subtype = %q, want %q", cfgErr.Subtype, errs.SubtypeInvalidClient)
|
||||
}
|
||||
if cfgErr.Hint == "" {
|
||||
t.Errorf("Hint is empty; canonical hint required for invalid_client")
|
||||
}
|
||||
if !strings.Contains(cfgErr.Hint, "lark-cli config init") {
|
||||
t.Errorf("Hint should reference `lark-cli config init`; got %q", cfgErr.Hint)
|
||||
}
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,94 @@
|
||||
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package errclass
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
|
||||
"github.com/larksuite/cli/errs"
|
||||
)
|
||||
|
||||
// CodeMeta is the classification metadata attached to a Lark numeric code.
|
||||
// It does NOT carry Message or Hint — those are derived at the dispatcher
|
||||
// (see BuildAPIError).
|
||||
//
|
||||
// Risk + Action are populated only for codes that route to CategoryConfirmation;
|
||||
// the dispatcher falls back to RiskUnknown + ctx.LarkCmd when either is empty
|
||||
// so the envelope is never wire-invalid.
|
||||
type CodeMeta struct {
|
||||
Category errs.Category
|
||||
Subtype errs.Subtype
|
||||
Retryable bool
|
||||
Risk string // CategoryConfirmation arm only; empty otherwise
|
||||
Action string // CategoryConfirmation arm only; empty otherwise
|
||||
}
|
||||
|
||||
// codeMeta is the central registry. Top-level entries (auth/authorization/api/
|
||||
// policy/config codes shared across services) live here; service-specific
|
||||
// sub-tables (e.g. task) live in dedicated files like codemeta_task.go and
|
||||
// merge into this map via init().
|
||||
//
|
||||
// Go language guarantees package-level vars initialize before init() functions,
|
||||
// so sub-tables registering via init() can always assume codeMeta is non-nil.
|
||||
var codeMeta = map[int]CodeMeta{
|
||||
// CategoryAuthentication
|
||||
99991661: {Category: errs.CategoryAuthentication, Subtype: errs.SubtypeTokenMissing}, // Authorization header missing
|
||||
99991671: {Category: errs.CategoryAuthentication, Subtype: errs.SubtypeTokenInvalid}, // token format error (must start with t- / u-)
|
||||
99991668: {Category: errs.CategoryAuthentication, Subtype: errs.SubtypeTokenInvalid}, // UAT invalid/expired (server does not distinguish)
|
||||
99991663: {Category: errs.CategoryAuthentication, Subtype: errs.SubtypeTokenInvalid}, // access_token invalid
|
||||
99991677: {Category: errs.CategoryAuthentication, Subtype: errs.SubtypeTokenExpired}, // UAT expired
|
||||
20026: {Category: errs.CategoryAuthentication, Subtype: errs.SubtypeRefreshTokenInvalid}, // refresh_token v1 legacy format
|
||||
20037: {Category: errs.CategoryAuthentication, Subtype: errs.SubtypeRefreshTokenExpired}, // refresh_token expired
|
||||
20064: {Category: errs.CategoryAuthentication, Subtype: errs.SubtypeRefreshTokenRevoked}, // refresh_token revoked
|
||||
20073: {Category: errs.CategoryAuthentication, Subtype: errs.SubtypeRefreshTokenReused}, // refresh_token already used
|
||||
20050: {Category: errs.CategoryAuthentication, Subtype: errs.SubtypeRefreshServerError, Retryable: true}, // refresh endpoint transient error
|
||||
|
||||
// CategoryAuthorization
|
||||
99991672: {Category: errs.CategoryAuthorization, Subtype: errs.SubtypeAppScopeNotApplied},
|
||||
99991676: {Category: errs.CategoryAuthorization, Subtype: errs.SubtypeTokenScopeInsufficient},
|
||||
99991679: {Category: errs.CategoryAuthorization, Subtype: errs.SubtypeMissingScope}, // user authorized app but did not grant this scope
|
||||
230027: {Category: errs.CategoryAuthorization, Subtype: errs.SubtypeUserUnauthorized}, // user never authorized the app
|
||||
99991673: {Category: errs.CategoryAuthorization, Subtype: errs.SubtypeAppUnavailable}, // app status unavailable
|
||||
99991662: {Category: errs.CategoryAuthorization, Subtype: errs.SubtypeAppDisabled}, // app currently disabled in tenant
|
||||
|
||||
// CategoryAPI
|
||||
99991400: {Category: errs.CategoryAPI, Subtype: errs.SubtypeRateLimit, Retryable: true},
|
||||
1061045: {Category: errs.CategoryAPI, Subtype: errs.SubtypeConflict, Retryable: true},
|
||||
131009: {Category: errs.CategoryAPI, Subtype: errs.SubtypeConflict, Retryable: true}, // wiki write-path lock contention; retryable with backoff
|
||||
1064510: {Category: errs.CategoryAPI, Subtype: errs.SubtypeCrossTenant},
|
||||
1064511: {Category: errs.CategoryAPI, Subtype: errs.SubtypeCrossBrand},
|
||||
1310246: {Category: errs.CategoryAPI, Subtype: errs.SubtypeInvalidParameters},
|
||||
1063006: {Category: errs.CategoryAPI, Subtype: errs.SubtypeRateLimit}, // drive perm-apply quota; 5/day, not short-term retryable
|
||||
1063007: {Category: errs.CategoryAPI, Subtype: errs.SubtypeInvalidParameters},
|
||||
231205: {Category: errs.CategoryAPI, Subtype: errs.SubtypeOwnershipMismatch},
|
||||
|
||||
// CategoryConfig
|
||||
99991543: {Category: errs.CategoryConfig, Subtype: errs.SubtypeInvalidClient}, // RFC 6749 §5.2 — app_id / app_secret incorrect (Open API)
|
||||
10014: {Category: errs.CategoryConfig, Subtype: errs.SubtypeInvalidClient}, // legacy TAT endpoint — "app secret invalid" (pre-v3 variant of 99991543; CLI now reports invalid_client)
|
||||
|
||||
// CategoryPolicy
|
||||
21000: {Category: errs.CategoryPolicy, Subtype: errs.SubtypeChallengeRequired},
|
||||
21001: {Category: errs.CategoryPolicy, Subtype: errs.SubtypeAccessDenied},
|
||||
}
|
||||
|
||||
// LookupCodeMeta is the single lookup entry. Returns ok=false for unknown codes —
|
||||
// the caller (BuildAPIError) is responsible for falling back to
|
||||
// CategoryAPI/SubtypeUnknown.
|
||||
func LookupCodeMeta(code int) (CodeMeta, bool) {
|
||||
m, ok := codeMeta[code]
|
||||
return m, ok
|
||||
}
|
||||
|
||||
// mergeCodeMeta is invoked by sub-table init() functions to merge service-specific
|
||||
// codes into the central registry. Panics on duplicate code so a misregistration
|
||||
// fails fast at startup rather than producing silently-inconsistent classification.
|
||||
func mergeCodeMeta(src map[int]CodeMeta, owner string) {
|
||||
for code, meta := range src {
|
||||
if existing, dup := codeMeta[code]; dup {
|
||||
panic(fmt.Sprintf("codeMeta dup: code %d already mapped %+v; %s wants %+v",
|
||||
code, existing, owner, meta))
|
||||
}
|
||||
codeMeta[code] = meta
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,16 @@
|
||||
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package errclass
|
||||
|
||||
import "github.com/larksuite/cli/errs"
|
||||
|
||||
// calendarCodeMeta holds calendar-service Lark code → CodeMeta mappings.
|
||||
// Only codes whose meaning is verifiable from repo evidence are registered;
|
||||
// ambiguous codes fall back to CategoryAPI via BuildAPIError.
|
||||
// BuildAPIError consumes this map via mergeCodeMeta + LookupCodeMeta.
|
||||
var calendarCodeMeta = map[int]CodeMeta{
|
||||
190014: {Category: errs.CategoryAPI, Subtype: errs.SubtypeInvalidParameters}, // invalid params (carries a field-level detail lifted into Hint)
|
||||
}
|
||||
|
||||
func init() { mergeCodeMeta(calendarCodeMeta, "calendar") }
|
||||
@@ -0,0 +1,39 @@
|
||||
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package errclass
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"testing"
|
||||
|
||||
"github.com/larksuite/cli/errs"
|
||||
)
|
||||
|
||||
// TestLookupCodeMeta_CalendarCodes pins each calendar-service code registered
|
||||
// via the codemeta_calendar.go init() merge to its expected
|
||||
// Category/Subtype/Retryable.
|
||||
func TestLookupCodeMeta_CalendarCodes(t *testing.T) {
|
||||
cases := []struct {
|
||||
code int
|
||||
wantCat errs.Category
|
||||
wantSubtype errs.Subtype
|
||||
wantRetry bool
|
||||
}{
|
||||
// 190014: calendar "invalid params" with a field-level detail
|
||||
// (error.details[].value) lifted into Hint by BuildAPIError.
|
||||
{190014, errs.CategoryAPI, errs.SubtypeInvalidParameters, false},
|
||||
}
|
||||
for _, tc := range cases {
|
||||
t.Run(fmt.Sprintf("%d", tc.code), func(t *testing.T) {
|
||||
meta, ok := LookupCodeMeta(tc.code)
|
||||
if !ok {
|
||||
t.Fatalf("code %d not registered in codeMeta", tc.code)
|
||||
}
|
||||
if meta.Category != tc.wantCat || meta.Subtype != tc.wantSubtype || meta.Retryable != tc.wantRetry {
|
||||
t.Errorf("code %d: got %+v, want Category=%v Subtype=%v Retryable=%v",
|
||||
tc.code, meta, tc.wantCat, tc.wantSubtype, tc.wantRetry)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,32 @@
|
||||
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package errclass
|
||||
|
||||
import "github.com/larksuite/cli/errs"
|
||||
|
||||
// driveCodeMeta holds drive/docs-service Lark code → CodeMeta mappings.
|
||||
// Only codes whose meaning is verifiable from repo evidence are registered;
|
||||
// ambiguous codes fall back to CategoryAPI via BuildAPIError.
|
||||
// BuildAPIError consumes this map via mergeCodeMeta + LookupCodeMeta.
|
||||
var driveCodeMeta = map[int]CodeMeta{
|
||||
1061001: {Category: errs.CategoryAPI, Subtype: errs.SubtypeServerError, Retryable: true}, // Drive "unknown error"
|
||||
1061002: {Category: errs.CategoryAPI, Subtype: errs.SubtypeInvalidParameters}, // params error
|
||||
1061004: {Category: errs.CategoryAuthorization, Subtype: errs.SubtypePermissionDenied}, // forbidden
|
||||
1061007: {Category: errs.CategoryAPI, Subtype: errs.SubtypeNotFound}, // file has been deleted
|
||||
1061043: {Category: errs.CategoryAPI, Subtype: errs.SubtypeQuotaExceeded}, // file size beyond limit
|
||||
1061044: {Category: errs.CategoryAPI, Subtype: errs.SubtypeNotFound}, // parent folder does not exist (upload)
|
||||
1061101: {Category: errs.CategoryAPI, Subtype: errs.SubtypeQuotaExceeded}, // file quota exceeded
|
||||
1062507: {Category: errs.CategoryAPI, Subtype: errs.SubtypeQuotaExceeded}, // parent folder child count limit exceeded
|
||||
1062009: {Category: errs.CategoryAPI, Subtype: errs.SubtypeInvalidParameters}, // actual size inconsistent with declared size
|
||||
1063001: {Category: errs.CategoryAPI, Subtype: errs.SubtypeInvalidParameters}, // secure label invalid parameter
|
||||
1063002: {Category: errs.CategoryAuthorization, Subtype: errs.SubtypePermissionDenied}, // secure label permission denied
|
||||
1063013: {Category: errs.CategoryValidation, Subtype: errs.SubtypeFailedPrecondition}, // secure label downgrade requires approval
|
||||
1069302: {Category: errs.CategoryAPI, Subtype: errs.SubtypeInvalidParameters}, // comment endpoint "Invalid or missing parameters"
|
||||
99992402: {Category: errs.CategoryAPI, Subtype: errs.SubtypeInvalidParameters}, // platform field validation failed
|
||||
9499: {Category: errs.CategoryAPI, Subtype: errs.SubtypeInvalidParameters}, // invalid parameter type in JSON field
|
||||
2200: {Category: errs.CategoryAPI, Subtype: errs.SubtypeServerError, Retryable: true}, // Drive tenant/internal errors
|
||||
233523001: {Category: errs.CategoryAPI, Subtype: errs.SubtypeServerError, Retryable: true}, // Drive/docs transient server error
|
||||
}
|
||||
|
||||
func init() { mergeCodeMeta(driveCodeMeta, "drive") }
|
||||
@@ -0,0 +1,50 @@
|
||||
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package errclass
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"testing"
|
||||
|
||||
"github.com/larksuite/cli/errs"
|
||||
)
|
||||
|
||||
// TestLookupCodeMeta_DriveCodes pins each drive-service code registered via the
|
||||
// codemeta_drive.go init() merge to its expected Category/Subtype/Retryable.
|
||||
// Each case traces to repo evidence (see codemeta_drive.go comments).
|
||||
func TestLookupCodeMeta_DriveCodes(t *testing.T) {
|
||||
cases := []struct {
|
||||
code int
|
||||
wantCat errs.Category
|
||||
wantSubtype errs.Subtype
|
||||
wantRetry bool
|
||||
}{
|
||||
// 1061044: upload with a nonexistent parent folder token. The drive E2E
|
||||
// (tests_e2e/drive/2026_06_01_errs_migrate_drive_test.go) drives this
|
||||
// producer via a nonexistent parent folder → referenced resource missing.
|
||||
{1061044, errs.CategoryAPI, errs.SubtypeNotFound, false},
|
||||
// 1069302: comment endpoint's opaque "Invalid or missing parameters"
|
||||
// (shortcuts/drive/drive_add_comment.go) → API-side parameter rejection.
|
||||
{1069302, errs.CategoryAPI, errs.SubtypeInvalidParameters, false},
|
||||
// Secure label endpoint codes observed from drive +secure-label-update
|
||||
// failure telemetry.
|
||||
{1063001, errs.CategoryAPI, errs.SubtypeInvalidParameters, false},
|
||||
{1063002, errs.CategoryAuthorization, errs.SubtypePermissionDenied, false},
|
||||
{1063013, errs.CategoryValidation, errs.SubtypeFailedPrecondition, false},
|
||||
{99992402, errs.CategoryAPI, errs.SubtypeInvalidParameters, false},
|
||||
{9499, errs.CategoryAPI, errs.SubtypeInvalidParameters, false},
|
||||
}
|
||||
for _, tc := range cases {
|
||||
t.Run(fmt.Sprintf("%d", tc.code), func(t *testing.T) {
|
||||
meta, ok := LookupCodeMeta(tc.code)
|
||||
if !ok {
|
||||
t.Fatalf("code %d not registered in codeMeta", tc.code)
|
||||
}
|
||||
if meta.Category != tc.wantCat || meta.Subtype != tc.wantSubtype || meta.Retryable != tc.wantRetry {
|
||||
t.Errorf("code %d: got %+v, want Category=%v Subtype=%v Retryable=%v",
|
||||
tc.code, meta, tc.wantCat, tc.wantSubtype, tc.wantRetry)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,20 @@
|
||||
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package errclass
|
||||
|
||||
import "github.com/larksuite/cli/errs"
|
||||
|
||||
// mailCodeMeta holds mail-service Lark code -> CodeMeta mappings.
|
||||
// Only codes whose meaning is verifiable from repo evidence are registered;
|
||||
// ambiguous codes fall back to CategoryAPI via BuildAPIError.
|
||||
var mailCodeMeta = map[int]CodeMeta{
|
||||
1234013: {Category: errs.CategoryAPI, Subtype: errs.SubtypeNotFound}, // mailbox not found or not active
|
||||
1236007: {Category: errs.CategoryAPI, Subtype: errs.SubtypeQuotaExceeded}, // user daily send count exceeded
|
||||
1236008: {Category: errs.CategoryAPI, Subtype: errs.SubtypeQuotaExceeded}, // user daily external recipient count exceeded
|
||||
1236009: {Category: errs.CategoryAPI, Subtype: errs.SubtypeQuotaExceeded}, // tenant daily external recipient count exceeded
|
||||
1236010: {Category: errs.CategoryAPI, Subtype: errs.SubtypeQuotaExceeded}, // mail quota limit
|
||||
1236013: {Category: errs.CategoryAPI, Subtype: errs.SubtypeQuotaExceeded}, // tenant storage limit exceeded
|
||||
}
|
||||
|
||||
func init() { mergeCodeMeta(mailCodeMeta, "mail") }
|
||||
@@ -0,0 +1,18 @@
|
||||
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package errclass
|
||||
|
||||
import "github.com/larksuite/cli/errs"
|
||||
|
||||
// minutesCodeMeta holds minutes-service Lark code → CodeMeta mappings.
|
||||
// Only codes whose meaning is stable across minutes endpoints are registered;
|
||||
// endpoint-specific codes fall back to CategoryAPI via BuildAPIError.
|
||||
// Command-specific messages, hints, and subtypes are layered on top via
|
||||
// per-command enrichment.
|
||||
// BuildAPIError consumes this map via mergeCodeMeta + LookupCodeMeta.
|
||||
var minutesCodeMeta = map[int]CodeMeta{
|
||||
2091005: {Category: errs.CategoryAuthorization, Subtype: errs.SubtypePermissionDenied}, // caller lacks edit/read permission for the minute
|
||||
}
|
||||
|
||||
func init() { mergeCodeMeta(minutesCodeMeta, "minutes") }
|
||||
@@ -0,0 +1,25 @@
|
||||
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package errclass
|
||||
|
||||
import "github.com/larksuite/cli/errs"
|
||||
|
||||
// taskCodeMeta holds task-service Lark code → CodeMeta mappings.
|
||||
// All Subtypes are framework-shared (errs.SubtypeXxx) — task does not declare
|
||||
// service-specific Subtypes because none of these codes carry semantics beyond
|
||||
// the cross-service taxonomy (NotFound / QuotaExceeded / etc.).
|
||||
// BuildAPIError consumes this map via mergeCodeMeta + LookupCodeMeta.
|
||||
var taskCodeMeta = map[int]CodeMeta{
|
||||
1470400: {Category: errs.CategoryAPI, Subtype: errs.SubtypeInvalidParameters}, // invalid_params
|
||||
1470403: {Category: errs.CategoryAuthorization, Subtype: errs.SubtypePermissionDenied}, // permission_denied (resource-level)
|
||||
1470404: {Category: errs.CategoryAPI, Subtype: errs.SubtypeNotFound}, // not_found
|
||||
1470422: {Category: errs.CategoryAPI, Subtype: errs.SubtypeConflict, Retryable: true}, // conflict (retryable)
|
||||
1470500: {Category: errs.CategoryAPI, Subtype: errs.SubtypeServerError, Retryable: true}, // server_error (retryable)
|
||||
1470610: {Category: errs.CategoryAPI, Subtype: errs.SubtypeQuotaExceeded}, // assignee_limit
|
||||
1470611: {Category: errs.CategoryAPI, Subtype: errs.SubtypeQuotaExceeded}, // follower_limit
|
||||
1470612: {Category: errs.CategoryAPI, Subtype: errs.SubtypeQuotaExceeded}, // tasklist_member_limit
|
||||
1470613: {Category: errs.CategoryAPI, Subtype: errs.SubtypeAlreadyExists}, // reminder_exists
|
||||
}
|
||||
|
||||
func init() { mergeCodeMeta(taskCodeMeta, "task") }
|
||||
@@ -0,0 +1,221 @@
|
||||
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package errclass
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/larksuite/cli/errs"
|
||||
)
|
||||
|
||||
func TestLookupCodeMeta_CredentialCodes(t *testing.T) {
|
||||
cases := []struct {
|
||||
code int
|
||||
wantCat errs.Category
|
||||
wantSubtype errs.Subtype
|
||||
wantRetry bool
|
||||
}{
|
||||
{99991661, errs.CategoryAuthentication, errs.SubtypeTokenMissing, false},
|
||||
{99991671, errs.CategoryAuthentication, errs.SubtypeTokenInvalid, false},
|
||||
{99991668, errs.CategoryAuthentication, errs.SubtypeTokenInvalid, false},
|
||||
{99991663, errs.CategoryAuthentication, errs.SubtypeTokenInvalid, false},
|
||||
{99991677, errs.CategoryAuthentication, errs.SubtypeTokenExpired, false},
|
||||
{20026, errs.CategoryAuthentication, errs.SubtypeRefreshTokenInvalid, false},
|
||||
{20037, errs.CategoryAuthentication, errs.SubtypeRefreshTokenExpired, false},
|
||||
{20064, errs.CategoryAuthentication, errs.SubtypeRefreshTokenRevoked, false},
|
||||
{20073, errs.CategoryAuthentication, errs.SubtypeRefreshTokenReused, false},
|
||||
{20050, errs.CategoryAuthentication, errs.SubtypeRefreshServerError, true},
|
||||
}
|
||||
for _, tc := range cases {
|
||||
t.Run(fmt.Sprintf("%d", tc.code), func(t *testing.T) {
|
||||
meta, ok := LookupCodeMeta(tc.code)
|
||||
if !ok {
|
||||
t.Fatalf("code %d not registered in codeMeta", tc.code)
|
||||
}
|
||||
if meta.Category != tc.wantCat || meta.Subtype != tc.wantSubtype || meta.Retryable != tc.wantRetry {
|
||||
t.Errorf("code %d: got %+v, want Category=%v Subtype=%v Retryable=%v",
|
||||
tc.code, meta, tc.wantCat, tc.wantSubtype, tc.wantRetry)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestLookupCodeMeta_MissingScope(t *testing.T) {
|
||||
got, ok := LookupCodeMeta(99991679)
|
||||
if !ok {
|
||||
t.Fatalf("LookupCodeMeta(99991679) ok=false, want true")
|
||||
}
|
||||
want := CodeMeta{Category: errs.CategoryAuthorization, Subtype: errs.SubtypeMissingScope, Retryable: false}
|
||||
if got != want {
|
||||
t.Fatalf("LookupCodeMeta(99991679) = %+v, want %+v", got, want)
|
||||
}
|
||||
}
|
||||
|
||||
func TestLookupCodeMeta_TaskPermissionDenied_MergedViaInit(t *testing.T) {
|
||||
got, ok := LookupCodeMeta(1470403)
|
||||
if !ok {
|
||||
t.Fatalf("LookupCodeMeta(1470403) ok=false, want true (task sub-table init merge)")
|
||||
}
|
||||
if got.Category != errs.CategoryAuthorization {
|
||||
t.Errorf("Category = %q, want %q", got.Category, errs.CategoryAuthorization)
|
||||
}
|
||||
if got.Subtype != errs.SubtypePermissionDenied {
|
||||
t.Errorf("Subtype = %q, want %q", got.Subtype, errs.SubtypePermissionDenied)
|
||||
}
|
||||
if got.Retryable {
|
||||
t.Errorf("Retryable = true, want false")
|
||||
}
|
||||
}
|
||||
|
||||
func TestLookupCodeMeta_MinutesEndpointSpecificCode_NotGlobal(t *testing.T) {
|
||||
if got, ok := LookupCodeMeta(2091001); ok {
|
||||
t.Fatalf("LookupCodeMeta(2091001) = %+v, want unregistered; minutes endpoints use this code for different failures", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestLookupCodeMeta_RetryableAuthCode(t *testing.T) {
|
||||
got, ok := LookupCodeMeta(20050)
|
||||
if !ok {
|
||||
t.Fatalf("LookupCodeMeta(20050) ok=false, want true")
|
||||
}
|
||||
if !got.Retryable {
|
||||
t.Errorf("LookupCodeMeta(20050).Retryable = false, want true (sole retryable refresh code)")
|
||||
}
|
||||
if got.Category != errs.CategoryAuthentication {
|
||||
t.Errorf("Category = %q, want %q", got.Category, errs.CategoryAuthentication)
|
||||
}
|
||||
}
|
||||
|
||||
func TestLookupCodeMeta_RetryableRateLimit(t *testing.T) {
|
||||
got, ok := LookupCodeMeta(99991400)
|
||||
if !ok {
|
||||
t.Fatalf("LookupCodeMeta(99991400) ok=false, want true")
|
||||
}
|
||||
if !got.Retryable {
|
||||
t.Errorf("LookupCodeMeta(99991400).Retryable = false, want true (rate_limit retryable)")
|
||||
}
|
||||
if got.Subtype != errs.SubtypeRateLimit {
|
||||
t.Errorf("Subtype = %q, want %q", got.Subtype, errs.SubtypeRateLimit)
|
||||
}
|
||||
}
|
||||
|
||||
func TestLookupCodeMeta_DrivePushCodes(t *testing.T) {
|
||||
cases := []struct {
|
||||
code int
|
||||
wantCat errs.Category
|
||||
wantSubtype errs.Subtype
|
||||
wantRetry bool
|
||||
}{
|
||||
{1061001, errs.CategoryAPI, errs.SubtypeServerError, true},
|
||||
{1061002, errs.CategoryAPI, errs.SubtypeInvalidParameters, false},
|
||||
{1061004, errs.CategoryAuthorization, errs.SubtypePermissionDenied, false},
|
||||
{1061007, errs.CategoryAPI, errs.SubtypeNotFound, false},
|
||||
{1061043, errs.CategoryAPI, errs.SubtypeQuotaExceeded, false},
|
||||
{1061101, errs.CategoryAPI, errs.SubtypeQuotaExceeded, false},
|
||||
{1062009, errs.CategoryAPI, errs.SubtypeInvalidParameters, false},
|
||||
{2200, errs.CategoryAPI, errs.SubtypeServerError, true},
|
||||
{233523001, errs.CategoryAPI, errs.SubtypeServerError, true},
|
||||
}
|
||||
for _, tc := range cases {
|
||||
t.Run(fmt.Sprintf("%d", tc.code), func(t *testing.T) {
|
||||
got, ok := LookupCodeMeta(tc.code)
|
||||
if !ok {
|
||||
t.Fatalf("LookupCodeMeta(%d) ok=false, want true", tc.code)
|
||||
}
|
||||
if got.Category != tc.wantCat || got.Subtype != tc.wantSubtype || got.Retryable != tc.wantRetry {
|
||||
t.Fatalf("LookupCodeMeta(%d) = %+v, want Category=%v Subtype=%v Retryable=%v",
|
||||
tc.code, got, tc.wantCat, tc.wantSubtype, tc.wantRetry)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestLookupCodeMeta_WikiCodes(t *testing.T) {
|
||||
cases := []struct {
|
||||
code int
|
||||
wantCat errs.Category
|
||||
wantSubtype errs.Subtype
|
||||
wantRetry bool
|
||||
}{
|
||||
{131002, errs.CategoryAPI, errs.SubtypeInvalidParameters, false},
|
||||
{131005, errs.CategoryAPI, errs.SubtypeNotFound, false},
|
||||
{131006, errs.CategoryAuthorization, errs.SubtypePermissionDenied, false},
|
||||
}
|
||||
for _, tc := range cases {
|
||||
t.Run(fmt.Sprintf("%d", tc.code), func(t *testing.T) {
|
||||
got, ok := LookupCodeMeta(tc.code)
|
||||
if !ok {
|
||||
t.Fatalf("LookupCodeMeta(%d) ok=false, want true", tc.code)
|
||||
}
|
||||
if got.Category != tc.wantCat || got.Subtype != tc.wantSubtype || got.Retryable != tc.wantRetry {
|
||||
t.Fatalf("LookupCodeMeta(%d) = %+v, want Category=%v Subtype=%v Retryable=%v",
|
||||
tc.code, got, tc.wantCat, tc.wantSubtype, tc.wantRetry)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestLookupCodeMeta_Unknown(t *testing.T) {
|
||||
_, ok := LookupCodeMeta(999999)
|
||||
if ok {
|
||||
t.Fatalf("LookupCodeMeta(999999) ok=true, want false for unknown code")
|
||||
}
|
||||
}
|
||||
|
||||
// TestLookupCodeMeta_ConfigCode_99991543 pins the Lark "app_id or app_secret
|
||||
// is incorrect" code to CategoryConfig / SubtypeInvalidClient. The CLI cannot
|
||||
// retry around a wrong app credential — the operator has to edit the local
|
||||
// config — so this MUST stay non-retryable and live in the config category
|
||||
// (not the API category it was originally classed under).
|
||||
func TestLookupCodeMeta_ConfigCode_99991543(t *testing.T) {
|
||||
meta, ok := LookupCodeMeta(99991543)
|
||||
if !ok {
|
||||
t.Fatal("99991543 not registered in codeMeta")
|
||||
}
|
||||
if meta.Category != errs.CategoryConfig {
|
||||
t.Errorf("category = %v, want %v", meta.Category, errs.CategoryConfig)
|
||||
}
|
||||
if meta.Subtype != errs.SubtypeInvalidClient {
|
||||
t.Errorf("subtype = %v, want %v", meta.Subtype, errs.SubtypeInvalidClient)
|
||||
}
|
||||
if meta.Retryable {
|
||||
t.Errorf("Retryable = true, want false (wrong app credential is operator-fix)")
|
||||
}
|
||||
}
|
||||
|
||||
func TestLookupCodeMeta_PolicyChallengeRequired(t *testing.T) {
|
||||
got, ok := LookupCodeMeta(21000)
|
||||
if !ok {
|
||||
t.Fatalf("LookupCodeMeta(21000) ok=false, want true")
|
||||
}
|
||||
if got.Category != errs.CategoryPolicy {
|
||||
t.Errorf("Category = %q, want %q", got.Category, errs.CategoryPolicy)
|
||||
}
|
||||
if got.Subtype != errs.Subtype("challenge_required") {
|
||||
t.Errorf("Subtype = %q, want %q", got.Subtype, "challenge_required")
|
||||
}
|
||||
}
|
||||
|
||||
func TestMergeCodeMeta_PanicsOnDuplicate(t *testing.T) {
|
||||
defer func() {
|
||||
r := recover()
|
||||
if r == nil {
|
||||
t.Fatalf("mergeCodeMeta with duplicate code did not panic")
|
||||
}
|
||||
msg, ok := r.(string)
|
||||
if !ok {
|
||||
t.Fatalf("panic value is not a string: %T (%v)", r, r)
|
||||
}
|
||||
for _, needle := range []string{"1470403", "permission_denied", "intruder", "test"} {
|
||||
if !strings.Contains(msg, needle) {
|
||||
t.Errorf("panic message %q missing substring %q", msg, needle)
|
||||
}
|
||||
}
|
||||
}()
|
||||
mergeCodeMeta(map[int]CodeMeta{
|
||||
1470403: {Category: errs.CategoryAPI, Subtype: errs.Subtype("intruder")},
|
||||
}, "test")
|
||||
}
|
||||
@@ -0,0 +1,19 @@
|
||||
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package errclass
|
||||
|
||||
import "github.com/larksuite/cli/errs"
|
||||
|
||||
// vcCodeMeta holds vc-service Lark code → CodeMeta mappings.
|
||||
// Only codes whose meaning is verifiable from repo evidence are registered;
|
||||
// ambiguous codes (e.g. 124002 "recording still generating", which has no
|
||||
// precise taxonomy fit) fall back to CategoryAPI via BuildAPIError and rely on
|
||||
// per-command enrichment for a retry hint.
|
||||
// BuildAPIError consumes this map via mergeCodeMeta + LookupCodeMeta.
|
||||
var vcCodeMeta = map[int]CodeMeta{
|
||||
121004: {Category: errs.CategoryAPI, Subtype: errs.SubtypeNotFound}, // meeting has no minute file
|
||||
121005: {Category: errs.CategoryAuthorization, Subtype: errs.SubtypePermissionDenied}, // caller is not a participant / lacks view permission
|
||||
}
|
||||
|
||||
func init() { mergeCodeMeta(vcCodeMeta, "vc") }
|
||||
@@ -0,0 +1,17 @@
|
||||
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package errclass
|
||||
|
||||
import "github.com/larksuite/cli/errs"
|
||||
|
||||
// wikiCodeMeta holds wiki-service Lark code -> CodeMeta mappings observed from
|
||||
// wiki shortcut failure telemetry. Keep these to wiki-wide meanings only; add
|
||||
// command-specific recovery guidance at the shortcut layer.
|
||||
var wikiCodeMeta = map[int]CodeMeta{
|
||||
131002: {Category: errs.CategoryAPI, Subtype: errs.SubtypeInvalidParameters}, // param err: space_id is not int / invalid page_token
|
||||
131005: {Category: errs.CategoryAPI, Subtype: errs.SubtypeNotFound}, // wiki node / space not found
|
||||
131006: {Category: errs.CategoryAuthorization, Subtype: errs.SubtypePermissionDenied}, // wiki space/node read permission denied
|
||||
}
|
||||
|
||||
func init() { mergeCodeMeta(wikiCodeMeta, "wiki") }
|
||||
Reference in New Issue
Block a user