Skip to content

Commit 361b8bb

Browse files
committed
feat(apps): emit typed error envelopes across the apps domain
Apps command errors previously surfaced as legacy output.Err* envelopes or untyped helper errors, so agent and script callers had to recover validation, API, subprocess, credential, and file failures from prose. Every error producer in the apps domain now emits typed errs.* errors with stable type/subtype/param/hint/code/log_id/retryable metadata: - Flag and input validation carries validation/invalid_argument with param; state preconditions (missing git/npx binary, login mismatch, foreign git helper) carry validation/failed_precondition with recovery hints. - A new internal/external_tool subtype classifies runtime failures of tools the CLI shells out to (git clone/push, npx scaffold, git config): the tool output is carried in the message so agents act on it instead of retrying with different flags. - Standard apps API calls use runtime.CallAPITyped; the multipart HTML publish and the git-credential issue endpoint classify responses through the shared classifier, so generic codes (missing scope) and HTTP 5xx keep their canonical category/subtype/retryable classification. - Local credential/state storage failures classify as internal/storage; malformed subprocess and API payloads as internal/invalid_response; defense-in-depth invariant guards report internal errors instead of blaming user input. - +db-execute statement failures report via the partial-failure contract: per-statement results stay machine-readable on stdout (ok:false) with a non-zero exit, in both json and pretty formats. - shortcuts/apps/ (including gitcred/) is locked into the golangci and errscontract migrated-path guards; the full tree is lint-clean. Validation: gofmt, go vet, go build, go test ./shortcuts/apps/... ./errs and the lint module, errscontract repo-wide clean, golangci-lint clean both diff-scoped and full-tree, make unit-test.
1 parent e64610f commit 361b8bb

45 files changed

Lines changed: 848 additions & 471 deletions

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

.golangci.yml

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -73,20 +73,20 @@ linters:
7373
- forbidigo
7474
# errs-typed-only enforced on paths already migrated to errs.NewXxxError.
7575
# Add a path when its migration is complete.
76-
- path-except: (internal/auth/|internal/errcompat/|internal/errclass/|internal/client/|internal/cmdutil/factory\.go|cmd/auth/|cmd/config/|cmd/service/|shortcuts/common/mcp_client\.go|shortcuts/base/|shortcuts/calendar/|shortcuts/contact/|shortcuts/doc/|shortcuts/drive/|shortcuts/im/|shortcuts/mail/|shortcuts/markdown/|shortcuts/minutes/|shortcuts/okr/|shortcuts/sheets/|shortcuts/slides/|shortcuts/task/|shortcuts/vc/|shortcuts/whiteboard/|shortcuts/wiki/|internal/event/consume/|cmd/event/|events/|shortcuts/event/)
76+
- path-except: (internal/auth/|internal/errcompat/|internal/errclass/|internal/client/|internal/cmdutil/factory\.go|cmd/auth/|cmd/config/|cmd/service/|shortcuts/common/mcp_client\.go|shortcuts/apps/|shortcuts/base/|shortcuts/calendar/|shortcuts/contact/|shortcuts/doc/|shortcuts/drive/|shortcuts/im/|shortcuts/mail/|shortcuts/markdown/|shortcuts/minutes/|shortcuts/okr/|shortcuts/sheets/|shortcuts/slides/|shortcuts/task/|shortcuts/vc/|shortcuts/whiteboard/|shortcuts/wiki/|internal/event/consume/|cmd/event/|events/|shortcuts/event/)
7777
text: errs-typed-only
7878
linters:
7979
- forbidigo
8080
# errs-no-bare-wrap enforced on paths fully migrated to typed final
8181
# errors. Scoped separately from errs-typed-only because cmd/auth/,
8282
# cmd/config/ still have residual fmt.Errorf and must not be caught.
83-
- path-except: (shortcuts/base/|shortcuts/calendar/|shortcuts/contact/|shortcuts/doc/|shortcuts/drive/|shortcuts/im/|shortcuts/mail/|shortcuts/markdown/|shortcuts/minutes/|shortcuts/okr/|shortcuts/sheets/|shortcuts/slides/|shortcuts/task/|shortcuts/vc/|shortcuts/whiteboard/|shortcuts/wiki/|shortcuts/common/mcp_client\.go|cmd/event/|events/|shortcuts/event/)
83+
- path-except: (shortcuts/apps/|shortcuts/base/|shortcuts/calendar/|shortcuts/contact/|shortcuts/doc/|shortcuts/drive/|shortcuts/im/|shortcuts/mail/|shortcuts/markdown/|shortcuts/minutes/|shortcuts/okr/|shortcuts/sheets/|shortcuts/slides/|shortcuts/task/|shortcuts/vc/|shortcuts/whiteboard/|shortcuts/wiki/|shortcuts/common/mcp_client\.go|cmd/event/|events/|shortcuts/event/)
8484
text: errs-no-bare-wrap
8585
linters:
8686
- forbidigo
8787
# errs-no-legacy-helper enforced on domains whose shared validation/save
8888
# helpers have migrated to typed final errors.
89-
- path-except: (shortcuts/base/|shortcuts/calendar/|shortcuts/contact/|shortcuts/doc/|shortcuts/drive/|shortcuts/im/|shortcuts/mail/|shortcuts/markdown/|shortcuts/minutes/|shortcuts/okr/|shortcuts/sheets/|shortcuts/slides/|shortcuts/task/|shortcuts/vc/|shortcuts/whiteboard/|shortcuts/wiki/|cmd/event/|events/|shortcuts/event/)
89+
- path-except: (shortcuts/apps/|shortcuts/base/|shortcuts/calendar/|shortcuts/contact/|shortcuts/doc/|shortcuts/drive/|shortcuts/im/|shortcuts/mail/|shortcuts/markdown/|shortcuts/minutes/|shortcuts/okr/|shortcuts/sheets/|shortcuts/slides/|shortcuts/task/|shortcuts/vc/|shortcuts/whiteboard/|shortcuts/wiki/|cmd/event/|events/|shortcuts/event/)
9090
text: errs-no-legacy-helper
9191
linters:
9292
- forbidigo

errs/subtypes.go

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -80,6 +80,7 @@ const (
8080
SubtypeSDKError Subtype = "sdk_error" // lark SDK Do() returned an unexpected error
8181
SubtypeInvalidResponse Subtype = "invalid_response" // SDK response body not parsable as JSON
8282
SubtypeFileIO Subtype = "file_io" // local file I/O failure (mkdir / write / read)
83+
SubtypeExternalTool Subtype = "external_tool" // an external tool the CLI shells out to (git, npx) failed at runtime; the tool output is in the message
8384
SubtypeStorage Subtype = "storage" // local persistence failure (e.g. config file save)
8485
// Generic untyped error lifted to InternalError uses SubtypeUnknown.
8586
)

lint/errscontract/rule_no_legacy_common_helper_call.go

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -18,6 +18,7 @@ var migratedCommonHelperPaths = []string{
1818
"cmd/event/",
1919
"events/",
2020
"internal/event/consume/",
21+
"shortcuts/apps/",
2122
"shortcuts/base/",
2223
"shortcuts/calendar/",
2324
"shortcuts/contact/",

lint/errscontract/rule_no_legacy_envelope_literal.go

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -19,6 +19,7 @@ var migratedEnvelopePaths = []string{
1919
"cmd/event/",
2020
"events/",
2121
"internal/event/consume/",
22+
"shortcuts/apps/",
2223
"shortcuts/base/",
2324
"shortcuts/calendar/",
2425
"shortcuts/contact/",

shortcuts/apps/apps_access_scope_get.go

Lines changed: 1 addition & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -9,7 +9,6 @@ import (
99
"io"
1010
"strings"
1111

12-
"github.com/larksuite/cli/internal/output"
1312
"github.com/larksuite/cli/internal/validate"
1413
"github.com/larksuite/cli/shortcuts/common"
1514
)
@@ -32,7 +31,7 @@ var AppsAccessScopeGet = common.Shortcut{
3231
},
3332
Validate: func(ctx context.Context, rctx *common.RuntimeContext) error {
3433
if strings.TrimSpace(rctx.Str("app-id")) == "" {
35-
return output.ErrValidation("--app-id is required")
34+
return appsValidationParamError("--app-id", "--app-id is required")
3635
}
3736
return nil
3837
},

shortcuts/apps/apps_access_scope_set.go

Lines changed: 22 additions & 17 deletions
Original file line numberDiff line numberDiff line change
@@ -10,7 +10,6 @@ import (
1010
"io"
1111
"strings"
1212

13-
"github.com/larksuite/cli/internal/output"
1413
"github.com/larksuite/cli/internal/validate"
1514
"github.com/larksuite/cli/shortcuts/common"
1615
)
@@ -45,7 +44,7 @@ var AppsAccessScopeSet = common.Shortcut{
4544
},
4645
Validate: func(ctx context.Context, rctx *common.RuntimeContext) error {
4746
if strings.TrimSpace(rctx.Str("app-id")) == "" {
48-
return output.ErrValidation("--app-id is required")
47+
return appsValidationParamError("--app-id", "--app-id is required")
4948
}
5049
return validateAccessScopeFlags(rctx)
5150
},
@@ -90,55 +89,61 @@ func validateAccessScopeFlags(rctx *common.RuntimeContext) error {
9089
switch scope {
9190
case "specific":
9291
if targets == "" {
93-
return output.ErrValidation("--targets is required when --scope=specific")
92+
return appsValidationParamError("--targets", "--targets is required when --scope=specific")
9493
}
9594
if err := validateTargetsJSON(targets); err != nil {
9695
return err
9796
}
9897
if approver != "" && !applyEnabled {
99-
return output.ErrValidation("--approver requires --apply-enabled")
98+
return appsValidationParamError("--approver", "--approver requires --apply-enabled")
10099
}
101100
if requireLogin {
102-
return output.ErrValidation("--require-login is not allowed when --scope=specific")
101+
return appsValidationParamError("--require-login", "--require-login is not allowed when --scope=specific")
103102
}
104103
case "public":
105104
if targets != "" {
106-
return output.ErrValidation("--targets is not allowed when --scope=public")
105+
return appsValidationParamError("--targets", "--targets is not allowed when --scope=public")
107106
}
108107
if applyEnabled {
109-
return output.ErrValidation("--apply-enabled is not allowed when --scope=public")
108+
return appsValidationParamError("--apply-enabled", "--apply-enabled is not allowed when --scope=public")
110109
}
111110
if approver != "" {
112-
return output.ErrValidation("--approver is not allowed when --scope=public")
111+
return appsValidationParamError("--approver", "--approver is not allowed when --scope=public")
113112
}
114113
if !rctx.Cmd.Flags().Changed("require-login") {
115-
return output.ErrValidation("--require-login is required when --scope=public (pass true or false explicitly; do not rely on the default)")
114+
return appsValidationParamError("--require-login", "--require-login is required when --scope=public (pass true or false explicitly; do not rely on the default)")
116115
}
117116
case "tenant":
118117
if targets != "" || applyEnabled || approver != "" || requireLogin {
119-
return output.ErrValidation("no extra flags allowed when --scope=tenant")
118+
return appsValidationError("no extra flags allowed when --scope=tenant").
119+
WithParams(
120+
appsInvalidParam("--targets", "not allowed when --scope=tenant"),
121+
appsInvalidParam("--apply-enabled", "not allowed when --scope=tenant"),
122+
appsInvalidParam("--approver", "not allowed when --scope=tenant"),
123+
appsInvalidParam("--require-login", "not allowed when --scope=tenant"),
124+
)
120125
}
121126
default:
122-
return output.ErrValidation("--scope must be specific / public / tenant")
127+
return appsValidationParamError("--scope", "--scope must be specific / public / tenant")
123128
}
124129
return nil
125130
}
126131

127132
func validateTargetsJSON(targetsJSON string) error {
128133
var items []map[string]interface{}
129134
if err := json.Unmarshal([]byte(targetsJSON), &items); err != nil {
130-
return output.ErrValidation("--targets is not valid JSON: %v", err)
135+
return appsValidationParamError("--targets", "--targets is not valid JSON: %v", err).WithCause(err)
131136
}
132137
if len(items) == 0 {
133-
return output.ErrValidation("--targets must contain at least one entry; specific scope requires concrete user/department/chat ids")
138+
return appsValidationParamError("--targets", "--targets must contain at least one entry; specific scope requires concrete user/department/chat ids")
134139
}
135140
for i, t := range items {
136141
typ, _ := t["type"].(string)
137142
if !allowedAccessTargetTypes[typ] {
138-
return output.ErrValidation("--targets[%d].type %q must be one of: user / department / chat", i, typ)
143+
return appsValidationParamError("--targets", "--targets[%d].type %q must be one of: user / department / chat", i, typ)
139144
}
140145
if id, _ := t["id"].(string); strings.TrimSpace(id) == "" {
141-
return output.ErrValidation("--targets[%d].id is empty", i)
146+
return appsValidationParamError("--targets", "--targets[%d].id is empty", i)
142147
}
143148
}
144149
return nil
@@ -157,7 +162,7 @@ func buildAccessScopeBody(rctx *common.RuntimeContext) (map[string]interface{},
157162
scope := rctx.Str("scope")
158163
enum, ok := scopeStringToServerEnum[scope]
159164
if !ok {
160-
return nil, output.ErrValidation("--scope must be specific / public / tenant, got %q", scope)
165+
return nil, appsValidationParamError("--scope", "--scope must be specific / public / tenant, got %q", scope)
161166
}
162167
body := map[string]interface{}{"scope": enum}
163168

@@ -166,7 +171,7 @@ func buildAccessScopeBody(rctx *common.RuntimeContext) (map[string]interface{},
166171
// 用户传统一格式 [{type:user|department|chat, id:...}],body 里拆 3 个并列数组发后端。
167172
var targets []map[string]interface{}
168173
if err := json.Unmarshal([]byte(rctx.Str("targets")), &targets); err != nil {
169-
return nil, output.ErrValidation("--targets is not valid JSON: %v", err)
174+
return nil, appsValidationParamError("--targets", "--targets is not valid JSON: %v", err).WithCause(err)
170175
}
171176
users, departments, chats := splitAccessScopeTargets(targets)
172177
if len(users) > 0 {

shortcuts/apps/apps_chat.go

Lines changed: 3 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -9,7 +9,6 @@ import (
99
"io"
1010
"strings"
1111

12-
"github.com/larksuite/cli/internal/output"
1312
"github.com/larksuite/cli/shortcuts/common"
1413
)
1514

@@ -43,14 +42,14 @@ var AppsChat = common.Shortcut{
4342
},
4443
Validate: func(ctx context.Context, rctx *common.RuntimeContext) error {
4544
if strings.TrimSpace(rctx.Str("app-id")) == "" {
46-
return output.ErrValidation("--app-id is required")
45+
return appsValidationParamError("--app-id", "--app-id is required")
4746
}
4847
if strings.TrimSpace(rctx.Str("session-id")) == "" {
49-
return output.ErrValidation("--session-id is required")
48+
return appsValidationParamError("--session-id", "--session-id is required")
5049
}
5150
// Do not echo --message content in the error (spec §4 redaction).
5251
if strings.TrimSpace(rctx.Str("message")) == "" {
53-
return output.ErrValidation("--message is required")
52+
return appsValidationParamError("--message", "--message is required")
5453
}
5554
return nil
5655
},

shortcuts/apps/apps_create.go

Lines changed: 1 addition & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -9,7 +9,6 @@ import (
99
"io"
1010
"strings"
1111

12-
"github.com/larksuite/cli/internal/output"
1312
"github.com/larksuite/cli/shortcuts/common"
1413
)
1514

@@ -36,7 +35,7 @@ var AppsCreate = common.Shortcut{
3635
},
3736
Validate: func(ctx context.Context, rctx *common.RuntimeContext) error {
3837
if strings.TrimSpace(rctx.Str("name")) == "" {
39-
return output.ErrValidation("--name is required")
38+
return appsValidationParamError("--name", "--name is required")
4039
}
4140
return nil
4241
},

shortcuts/apps/apps_create_test.go

Lines changed: 26 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,7 @@ import (
1212

1313
"github.com/spf13/cobra"
1414

15+
"github.com/larksuite/cli/errs"
1516
"github.com/larksuite/cli/internal/cmdutil"
1617
"github.com/larksuite/cli/internal/core"
1718
"github.com/larksuite/cli/internal/httpmock"
@@ -47,6 +48,31 @@ func runAppsShortcut(t *testing.T, sc common.Shortcut, args []string, factory *c
4748
return parent.ExecuteContext(context.Background())
4849
}
4950

51+
func requireAppsProblem(t *testing.T, err error, category errs.Category) *errs.Problem {
52+
t.Helper()
53+
if err == nil {
54+
t.Fatalf("expected error")
55+
}
56+
p, ok := errs.ProblemOf(err)
57+
if !ok {
58+
t.Fatalf("expected typed problem, got %T: %v", err, err)
59+
}
60+
if p.Category != category {
61+
t.Fatalf("error category = %q, want %q", p.Category, category)
62+
}
63+
return p
64+
}
65+
66+
func requireAppsValidationProblem(t *testing.T, err error) *errs.Problem {
67+
t.Helper()
68+
return requireAppsProblem(t, err, errs.CategoryValidation)
69+
}
70+
71+
func requireAppsAPIProblem(t *testing.T, err error) *errs.Problem {
72+
t.Helper()
73+
return requireAppsProblem(t, err, errs.CategoryAPI)
74+
}
75+
5076
// +create 测试
5177

5278
func TestAppsCreate_Success(t *testing.T) {

shortcuts/apps/apps_db_execute.go

Lines changed: 34 additions & 26 deletions
Original file line numberDiff line numberDiff line change
@@ -31,8 +31,9 @@ import (
3131
// - 多语句部分失败:`Statement K: ✗ <message> [<code>]` + 末尾「前序语句已落地」提示
3232
//
3333
// 失败语义:server 多语句失败仍返 code:0,把失败语句标成 ERROR 哨兵塞进 result。Execute 检测到哨兵
34-
// 后升级成 typed api_error(exit 非 0、detail 带 statement_index / completed / rolled_back),
35-
// 避免 agent 误判 ok:true 假成功。CLI 永远 DBA 模式(transactional=false),失败前的语句已 auto-commit
34+
// 后按 partial failure 上报(exit 非 0):stdout 输出 ok:false 数据,带 results /
35+
// statement_index / error_code / error_message / rolled_back / note,避免 agent 误判
36+
// ok:true 假成功。CLI 永远 DBA 模式(transactional=false),失败前的语句已 auto-commit
3637
// 落地,故 rolled_back=false(真机 boe 实证)。
3738
//
3839
// JSON envelope(成功路径):CLI 把 server 返的 result 字符串解出来放进 `data.results` 数组。
@@ -68,19 +69,27 @@ var AppsDBExecute = common.Shortcut{
6869
sql := strings.TrimSpace(rctx.Str("sql"))
6970
file := strings.TrimSpace(rctx.Str("file"))
7071
if sql != "" && file != "" {
71-
return output.ErrValidation("--sql and --file are mutually exclusive")
72+
return appsValidationError("--sql and --file are mutually exclusive").
73+
WithParams(
74+
appsInvalidParam("--sql", "mutually exclusive with --file"),
75+
appsInvalidParam("--file", "mutually exclusive with --sql"),
76+
)
7277
}
7378
if file != "" {
7479
data, err := cmdutil.ReadInputFile(rctx.FileIO(), file)
7580
if err != nil {
76-
return output.ErrValidation("--file: %v", err)
81+
return appsValidationParamError("--file", "--file: %v", err).WithCause(err)
7782
}
7883
// 归一化:把文件内容写回 --sql,下游(DryRun/Execute)统一从 sql 取。
7984
rctx.Cmd.Flags().Set("sql", string(data))
8085
sql = strings.TrimSpace(string(data))
8186
}
8287
if sql == "" {
83-
return output.ErrValidation("one of --sql or --file is required (use --sql - to read stdin)")
88+
return appsValidationError("one of --sql or --file is required (use --sql - to read stdin)").
89+
WithParams(
90+
appsInvalidParam("--sql", "one of --sql or --file is required"),
91+
appsInvalidParam("--file", "one of --sql or --file is required"),
92+
)
8493
}
8594
return nil
8695
},
@@ -113,13 +122,15 @@ var AppsDBExecute = common.Shortcut{
113122
data := map[string]interface{}{"results": stmts}
114123

115124
// 多语句 / 单语句失败:server 仍返 code:0,把失败语句标成 ERROR 哨兵塞进 result。
116-
// 升级成 typed api_error(exit 非 0),别让 agent 误判 ok:true 假成功。
117-
// pretty 模式仍把逐条 ✓/✗ 摘要打到 stdout(人看),再返回 error(envelope→stderr)。
125+
// 已落地的前序语句 + 失败语句构成 partial failure:逐条结果作为 ok:false 数据
126+
// 留在 stdout(机器可读)+ 非零退出信号,别让 agent 误判 ok:true 假成功。
127+
// pretty 模式 stdout 只打逐条 ✓/✗ 摘要(不再叠一份 JSON envelope),仅返回退出信号。
118128
if errIdx, errStmt, failed := findErrorSentinel(stmts); failed {
119129
if rctx.Format == "pretty" {
120130
renderSQLPretty(rctx.IO().Out, stmts)
131+
return output.PartialFailure(output.ExitAPI)
121132
}
122-
return sqlStatementError(stmts, errIdx, errStmt)
133+
return rctx.OutPartialFailure(sqlStatementFailurePayload(stmts, errIdx, errStmt), nil)
123134
}
124135

125136
rctx.OutFormat(data, nil, func(w io.Writer) {
@@ -140,31 +151,28 @@ func findErrorSentinel(stmts []map[string]interface{}) (int, map[string]interfac
140151
return 0, nil, false
141152
}
142153

143-
// sqlStatementError 把 ERROR 哨兵升级成 typed api_error
154+
// sqlStatementFailurePayload 把 ERROR 哨兵整理成 partial-failure 的 stdout 数据
144155
//
145156
// CLI 永远 DBA 模式(transactional=false),真机 boe 实证:失败语句之前的语句已逐条 auto-commit
146-
// 落地,不存在外层事务回滚。因此 rolled_back=false、completed 列出已落地的前序语句,hint 提示用户
147-
// 别整批重跑(否则会重复写入)。
148-
func sqlStatementError(stmts []map[string]interface{}, errIdx int, errStmt map[string]interface{}) error {
157+
// 落地,不存在外层事务回滚。因此 rolled_back=false、results 含全部逐条结果(ERROR 哨兵在
158+
// 失败位置),note 提示用户别整批重跑(否则会重复写入)。
159+
func sqlStatementFailurePayload(stmts []map[string]interface{}, errIdx int, errStmt map[string]interface{}) map[string]interface{} {
149160
code, msg := parseErrorSentinel(common.GetString(errStmt, "data"))
150161
stmtNo := errIdx + 1 // 1-based 给人看
151-
fullMsg := fmt.Sprintf("%s (at statement %d of %d)", msg, stmtNo, len(stmts))
152-
153-
apiErr := output.ErrAPI(code, fullMsg, map[string]interface{}{
162+
note := "no statements were applied; fix the SQL and re-run."
163+
if errIdx > 0 {
164+
note = fmt.Sprintf(
165+
"statements 1-%d were already applied (DBA mode auto-commits each statement); fix statement %d and re-run only the remaining statements.",
166+
errIdx, stmtNo)
167+
}
168+
return map[string]interface{}{
169+
"results": stmts,
154170
"statement_index": errIdx,
155-
"completed": stmts[:errIdx],
171+
"error_code": code,
172+
"error_message": fmt.Sprintf("%s (at statement %d of %d)", msg, stmtNo, len(stmts)),
156173
"rolled_back": false,
157-
})
158-
if apiErr.Detail != nil {
159-
if errIdx > 0 {
160-
apiErr.Detail.Hint = fmt.Sprintf(
161-
"statements 1-%d were already applied (DBA mode auto-commits each statement); fix statement %d and re-run only the remaining statements.",
162-
errIdx, stmtNo)
163-
} else {
164-
apiErr.Detail.Hint = "no statements were applied; fix the SQL and re-run."
165-
}
174+
"note": note,
166175
}
167-
return apiErr
168176
}
169177

170178
// parseErrorSentinel 解析 ERROR 哨兵的 data(`{code,message}` JSON),返回数值 code 与 message。

0 commit comments

Comments
 (0)