mirror of
https://github.com/multica-ai/multica.git
synced 2026-07-24 02:39:42 +02:00
* feat(cli): add central error translation layer (PR1) Introduce server/internal/cli/errors.go, a single user-facing error translation layer that collapses raw transport errors, HTTP status errors, and internal verb-wrapped chains into clear, localized messages. - ErrorKind classification (network timeout/DNS/refused/TLS/offline, 401/403/404/409/400+422/429/5xx, unknown) - NetworkError wraps transport errors and strips the raw URL from the user-facing message; classifyNetworkError categorizes via errors.As/Is with string fallbacks - HTTPError.Kind() maps status codes onto ErrorKind - FormatError: bilingual output (English default, auto-switch to Chinese on a zh LC_ALL/LC_MESSAGES/LANG locale), validation errors surface the server message; --debug / MULTICA_DEBUG appends the full raw chain - ExitCodeFor: tiered exit codes (network=2, auth=3, 404=4, validation=5, other=1) - client.go: default HTTP timeout 15s -> 30s, overridable via MULTICA_HTTP_TIMEOUT; wrap every transport Do() error as *NetworkError - main.go: route errors through FormatError + ExitCodeFor, add persistent --debug flag Unit tests cover every ErrorKind, classification, language detection, exit codes, server-message extraction, and timeout parsing. Refs MUL-3104. PR1 of 3; PR2/PR3 (status-code copy refinement and per-command customization) follow separately. Co-authored-by: multica-agent <github@multica.ai> * fix(cli): address review — unify command timeouts and classify all helper errors Must-fix 1: command-level contexts no longer truncate MULTICA_HTTP_TIMEOUT. Added cli.APITimeout/AtLeastAPITimeout/APIContext (budget = transport timeout + small grace, honoring MULTICA_HTTP_TIMEOUT) and replaced the hardcoded 15s context.WithTimeout in every API command (14 files, 92 sites) with cli.APIContext. The issue-create/comment path now uses APITimeout() with a 60s floor for attachment uploads. Must-fix 2: all API helpers now return *HTTPError on status >= 400. Added a shared newHTTPError(method, path, resp) and routed GetJSON, GetJSONWithHeaders, PostJSON, PutJSON, PatchJSON, DeleteJSON, DeleteJSONWithBody, UploadFile, UploadFileWithURL, DownloadFile (and HealthCheck) through it, so issue update/status/metadata (PUT), comment list (GetJSONWithHeaders), project/label/ comment delete (DELETE) and agent/workspace/autopilot update (PUT/PATCH) all get HTTPError.Kind() classification, friendly copy, and the tiered exit code instead of the raw string + exit 1. Tests: new errors_integration_test.go drives the real helpers against a fake server and asserts FormatError copy + ExitCodeFor for 401/403/404/422/500 across all 10 helpers, plus a slow-server test proving the command context does not cancel before the transport timeout. Updated the UploadFileWithURL assertion to check for *HTTPError. Refs MUL-3104, PR #3892. Co-authored-by: multica-agent <github@multica.ai> * fix(cli): make remaining fixed-timeout API commands honor MULTICA_HTTP_TIMEOUT Closes out the timeout work: the last API command paths still used a hardcoded context deadline that capped MULTICA_HTTP_TIMEOUT. Converted them to cli.AtLeastAPITimeout(<original floor>) so the env override scales them up while preserving each original lower bound: - cmd_autopilot.go autopilot trigger 30s -> AtLeastAPITimeout(30s) - cmd_attachment.go attachment download 60s -> AtLeastAPITimeout(60s) - cmd_agent.go avatar upload 60s -> AtLeastAPITimeout(60s) - cmd_skill.go skill import / search 60s -> AtLeastAPITimeout(60s) - cmd_runtime.go runtime update 150s -> AtLeastAPITimeout(150s) - cmd_login.go workspace-creation poll 10s -> AtLeastAPITimeout(10s) The login poll keeps a short 10s floor to stay responsive within its 5-minute loop, but it is NOT a silent exception: AtLeastAPITimeout means it still scales with MULTICA_HTTP_TIMEOUT. Documented in code and covered by a new subtest in TestAPITimeoutRespectsEnv. Refs MUL-3104, PR #3892. Co-authored-by: multica-agent <github@multica.ai> * style(cli): gofmt cmd_attachment.go to unblock backend CI Co-authored-by: multica-agent <github@multica.ai> --------- Co-authored-by: multica-agent <github@multica.ai>
415 lines
13 KiB
Go
415 lines
13 KiB
Go
package cli
|
||
|
||
import (
|
||
"context"
|
||
"crypto/tls"
|
||
"crypto/x509"
|
||
"encoding/json"
|
||
"errors"
|
||
"fmt"
|
||
"net"
|
||
"net/http"
|
||
"os"
|
||
"strings"
|
||
"syscall"
|
||
)
|
||
|
||
// ErrorKind is a coarse, user-facing classification of an error. The CLI's
|
||
// many internal error strings ("resolve issue: ...", raw net/http messages,
|
||
// JSON bodies) are not meaningful to end users; FormatError collapses them
|
||
// into one of these kinds and renders a friendly, localized message.
|
||
//
|
||
// The zero value is intentionally KindNetworkTimeout-adjacent only by index;
|
||
// always classify explicitly rather than relying on the zero value.
|
||
type ErrorKind int
|
||
|
||
const (
|
||
// Network / transport layer (errors returned by http.Client.Do).
|
||
KindNetworkTimeout ErrorKind = iota // context deadline exceeded / i/o timeout
|
||
KindNetworkDNS // no such host
|
||
KindNetworkRefused // connection refused
|
||
KindNetworkTLS // x509 / tls handshake failures
|
||
KindNetworkOffline // catch-all: host unreachable, reset, etc.
|
||
|
||
// HTTP status layer.
|
||
KindAuthRequired // 401
|
||
KindForbidden // 403
|
||
KindNotFound // 404
|
||
KindConflict // 409
|
||
KindValidation // 400 / 422
|
||
KindRateLimited // 429
|
||
KindServerError // 5xx
|
||
|
||
// Anything we could not classify.
|
||
KindUnknown
|
||
)
|
||
|
||
// Tiered process exit codes. Stable so users can branch on them in scripts.
|
||
const (
|
||
ExitGeneric = 1 // anything not covered below
|
||
ExitNetwork = 2 // any KindNetwork*
|
||
ExitAuth = 3 // 401 / 403
|
||
ExitNotFound = 4 // 404
|
||
ExitValidation = 5 // 400 / 422
|
||
)
|
||
|
||
// IsNetwork reports whether the kind is a transport-layer failure.
|
||
func (k ErrorKind) IsNetwork() bool {
|
||
switch k {
|
||
case KindNetworkTimeout, KindNetworkDNS, KindNetworkRefused, KindNetworkTLS, KindNetworkOffline:
|
||
return true
|
||
default:
|
||
return false
|
||
}
|
||
}
|
||
|
||
// NetworkError wraps a transport-layer error (the error returned by
|
||
// http.Client.Do, before any HTTP status is available). It strips the raw
|
||
// URL out of the user-facing message while preserving the original error for
|
||
// --debug output and errors.Is/As inspection.
|
||
type NetworkError struct {
|
||
Kind ErrorKind
|
||
Op string // e.g. "GET /api/issues/abc" — shown only in --debug
|
||
Err error // the original net/http error
|
||
}
|
||
|
||
func (e *NetworkError) Error() string {
|
||
if e.Op != "" {
|
||
return fmt.Sprintf("%s: %s", e.Op, e.Err.Error())
|
||
}
|
||
return e.Err.Error()
|
||
}
|
||
|
||
func (e *NetworkError) Unwrap() error { return e.Err }
|
||
|
||
// Kind maps an HTTPError's status code onto an ErrorKind.
|
||
func (e *HTTPError) Kind() ErrorKind {
|
||
switch e.StatusCode {
|
||
case 401:
|
||
return KindAuthRequired
|
||
case 403:
|
||
return KindForbidden
|
||
case 404:
|
||
return KindNotFound
|
||
case 409:
|
||
return KindConflict
|
||
case 400, 422:
|
||
return KindValidation
|
||
case 429:
|
||
return KindRateLimited
|
||
default:
|
||
if e.StatusCode >= 500 {
|
||
return KindServerError
|
||
}
|
||
return KindUnknown
|
||
}
|
||
}
|
||
|
||
// classifyNetworkError inspects a transport-layer error and returns the
|
||
// matching network ErrorKind. It prefers typed inspection (errors.As /
|
||
// errors.Is) and falls back to string matching for cases the standard library
|
||
// does not expose as distinct types.
|
||
func classifyNetworkError(err error) ErrorKind {
|
||
if err == nil {
|
||
return KindUnknown
|
||
}
|
||
|
||
// Timeouts (context deadline or socket i/o timeout).
|
||
if errors.Is(err, context.DeadlineExceeded) {
|
||
return KindNetworkTimeout
|
||
}
|
||
var netErr net.Error
|
||
if errors.As(err, &netErr) && netErr.Timeout() {
|
||
return KindNetworkTimeout
|
||
}
|
||
|
||
// DNS resolution failures.
|
||
var dnsErr *net.DNSError
|
||
if errors.As(err, &dnsErr) {
|
||
return KindNetworkDNS
|
||
}
|
||
|
||
// TLS / certificate failures.
|
||
var certVerifyErr *tls.CertificateVerificationError
|
||
if errors.As(err, &certVerifyErr) {
|
||
return KindNetworkTLS
|
||
}
|
||
var unknownAuthorityErr x509.UnknownAuthorityError
|
||
if errors.As(err, &unknownAuthorityErr) {
|
||
return KindNetworkTLS
|
||
}
|
||
var hostnameErr x509.HostnameError
|
||
if errors.As(err, &hostnameErr) {
|
||
return KindNetworkTLS
|
||
}
|
||
var certInvalidErr x509.CertificateInvalidError
|
||
if errors.As(err, &certInvalidErr) {
|
||
return KindNetworkTLS
|
||
}
|
||
|
||
// Connection refused.
|
||
if errors.Is(err, syscall.ECONNREFUSED) {
|
||
return KindNetworkRefused
|
||
}
|
||
|
||
// String fallbacks for anything not surfaced as a typed error.
|
||
msg := strings.ToLower(err.Error())
|
||
switch {
|
||
case strings.Contains(msg, "context deadline exceeded"), strings.Contains(msg, "timeout"), strings.Contains(msg, "timed out"):
|
||
return KindNetworkTimeout
|
||
case strings.Contains(msg, "no such host"), strings.Contains(msg, "server misbehaving"), strings.Contains(msg, "name resolution"):
|
||
return KindNetworkDNS
|
||
case strings.Contains(msg, "connection refused"):
|
||
return KindNetworkRefused
|
||
case strings.Contains(msg, "x509"), strings.Contains(msg, "certificate"), strings.Contains(msg, "tls"):
|
||
return KindNetworkTLS
|
||
}
|
||
return KindNetworkOffline
|
||
}
|
||
|
||
// wrapTransport converts a raw transport error returned by http.Client.Do
|
||
// into a *NetworkError. It returns nil when err is nil so call sites can
|
||
// reassign unconditionally:
|
||
//
|
||
// resp, err := c.HTTPClient.Do(req)
|
||
// err = wrapTransport(req, err)
|
||
// if err != nil { return err }
|
||
func wrapTransport(req *http.Request, err error) error {
|
||
if err == nil {
|
||
return nil
|
||
}
|
||
op := ""
|
||
if req != nil && req.URL != nil {
|
||
op = req.Method + " " + req.URL.Path
|
||
}
|
||
return &NetworkError{Kind: classifyNetworkError(err), Op: op, Err: err}
|
||
}
|
||
|
||
// Language is the language FormatError renders messages in.
|
||
type Language int
|
||
|
||
const (
|
||
LangEN Language = iota
|
||
LangZH
|
||
)
|
||
|
||
// DetectLanguage chooses the output language from the environment. English is
|
||
// the default (matching the CLI's help output); a Chinese locale in LC_ALL,
|
||
// LC_MESSAGES, or LANG (in that precedence order) switches to Chinese.
|
||
func DetectLanguage() Language {
|
||
for _, key := range []string{"LC_ALL", "LC_MESSAGES", "LANG"} {
|
||
v := strings.ToLower(strings.TrimSpace(os.Getenv(key)))
|
||
if v == "" {
|
||
continue
|
||
}
|
||
if strings.HasPrefix(v, "zh") {
|
||
return LangZH
|
||
}
|
||
// First locale variable that is set wins; if it is not Chinese we
|
||
// fall through to English without consulting lower-precedence vars.
|
||
return LangEN
|
||
}
|
||
return LangEN
|
||
}
|
||
|
||
// kindMessages holds the {English, Chinese} user-facing message for each kind.
|
||
var kindMessages = map[ErrorKind][2]string{
|
||
KindNetworkTimeout: {
|
||
"Request timed out: the server did not respond in time. Check your network connection or try again later. You can raise the limit with MULTICA_HTTP_TIMEOUT.",
|
||
"请求超时:服务器未在规定时间内响应。请检查网络连接或稍后重试。可通过 MULTICA_HTTP_TIMEOUT 调高超时时间。",
|
||
},
|
||
KindNetworkDNS: {
|
||
"Could not resolve the Multica server address. Check your network connection or the --server-url setting.",
|
||
"无法解析 Multica 服务器地址。请检查网络连接或 --server-url 配置。",
|
||
},
|
||
KindNetworkRefused: {
|
||
"Could not connect to the Multica server. Make sure the server address is correct and reachable.",
|
||
"无法连接到 Multica 服务器。请确认服务器地址正确且网络可达。",
|
||
},
|
||
KindNetworkTLS: {
|
||
"Could not establish a secure connection to the Multica server (TLS/certificate error). Check your system clock and CA certificates.",
|
||
"无法与 Multica 服务器建立安全连接(TLS/证书错误)。请检查系统时间和 CA 证书。",
|
||
},
|
||
KindNetworkOffline: {
|
||
"Could not reach the Multica server. Check your network connection.",
|
||
"无法访问 Multica 服务器。请检查网络连接。",
|
||
},
|
||
KindAuthRequired: {
|
||
"Your session has expired or you are not signed in. Run `multica login` to sign in again.",
|
||
"登录已过期或尚未登录。请运行 `multica login` 重新登录。",
|
||
},
|
||
KindForbidden: {
|
||
"You do not have permission to access this resource.",
|
||
"无权访问该资源。",
|
||
},
|
||
KindNotFound: {
|
||
"The requested resource was not found. The ID may not exist or may belong to a different workspace.",
|
||
"未找到请求的资源。该 ID 可能不存在,或不属于当前 workspace。",
|
||
},
|
||
KindConflict: {
|
||
"The request conflicts with the current state of the resource.",
|
||
"请求与资源的当前状态冲突。",
|
||
},
|
||
KindValidation: {
|
||
"The request was invalid.",
|
||
"请求无效。",
|
||
},
|
||
KindRateLimited: {
|
||
"Too many requests. Please wait a moment and try again.",
|
||
"请求过于频繁。请稍后重试。",
|
||
},
|
||
KindServerError: {
|
||
"The Multica service is temporarily unavailable. Please try again later; contact support if the problem persists.",
|
||
"Multica 服务暂时不可用。请稍后重试,如持续出现请联系支持。",
|
||
},
|
||
KindUnknown: {
|
||
"An unexpected error occurred.",
|
||
"发生未知错误。",
|
||
},
|
||
}
|
||
|
||
// messageFor returns the localized message for a kind.
|
||
func messageFor(kind ErrorKind, lang Language) string {
|
||
m, ok := kindMessages[kind]
|
||
if !ok {
|
||
m = kindMessages[KindUnknown]
|
||
}
|
||
if lang == LangZH {
|
||
return m[1]
|
||
}
|
||
return m[0]
|
||
}
|
||
|
||
// FormatError translates an error into a single user-facing line (or a
|
||
// detailed multi-line block when debug is set). It is the only user-facing
|
||
// translation entry point and is meant to be called once, at the top level
|
||
// (main.go), on the error bubbling up from a command.
|
||
//
|
||
// When debug is false it skips the internal verb chain ("resolve issue: ...")
|
||
// and the raw URL/JSON body, showing only the friendly message. When debug is
|
||
// true (or MULTICA_DEBUG is set) it additionally prints the full original
|
||
// error chain for troubleshooting.
|
||
func FormatError(err error, debug bool) string {
|
||
if err == nil {
|
||
return ""
|
||
}
|
||
lang := DetectLanguage()
|
||
base := userMessage(err, lang)
|
||
if debug || debugEnabled() {
|
||
return base + "\n\n" + debugDetail(err)
|
||
}
|
||
return base
|
||
}
|
||
|
||
// userMessage produces the friendly message for the root cause of err.
|
||
func userMessage(err error, lang Language) string {
|
||
// Transport-layer failure.
|
||
var netErr *NetworkError
|
||
if errors.As(err, &netErr) {
|
||
return messageFor(netErr.Kind, lang)
|
||
}
|
||
|
||
// HTTP status failure.
|
||
var httpErr *HTTPError
|
||
if errors.As(err, &httpErr) {
|
||
kind := httpErr.Kind()
|
||
// Validation errors usually carry a useful server-provided message;
|
||
// surface it instead of the generic line.
|
||
if kind == KindValidation {
|
||
if serverMsg := extractServerMessage(httpErr.Body); serverMsg != "" {
|
||
if lang == LangZH {
|
||
return "请求无效:" + serverMsg
|
||
}
|
||
return "Invalid request: " + serverMsg
|
||
}
|
||
}
|
||
return messageFor(kind, lang)
|
||
}
|
||
|
||
// Not a recognized typed error: this is typically a local/business error
|
||
// whose message is already meant for the user (e.g. a missing argument or
|
||
// a validation message constructed in a command). Show it as-is.
|
||
return strings.TrimSpace(err.Error())
|
||
}
|
||
|
||
// extractServerMessage tries to pull a human-readable message out of a JSON
|
||
// error body like {"error":"..."} or {"message":"..."}. Returns "" if the
|
||
// body is not JSON or has no recognizable message field.
|
||
func extractServerMessage(body string) string {
|
||
body = strings.TrimSpace(body)
|
||
if body == "" || body[0] != '{' {
|
||
return ""
|
||
}
|
||
var parsed map[string]any
|
||
if err := json.Unmarshal([]byte(body), &parsed); err != nil {
|
||
return ""
|
||
}
|
||
for _, key := range []string{"error", "message", "detail", "title"} {
|
||
if v, ok := parsed[key]; ok {
|
||
if s, ok := v.(string); ok && strings.TrimSpace(s) != "" {
|
||
return strings.TrimSpace(s)
|
||
}
|
||
}
|
||
}
|
||
return ""
|
||
}
|
||
|
||
// debugDetail renders the full original error chain plus any structured
|
||
// details from typed errors, for --debug / MULTICA_DEBUG output.
|
||
func debugDetail(err error) string {
|
||
var sb strings.Builder
|
||
sb.WriteString("[debug] ")
|
||
sb.WriteString(err.Error())
|
||
|
||
var netErr *NetworkError
|
||
if errors.As(err, &netErr) {
|
||
fmt.Fprintf(&sb, "\n[debug] network: op=%q kind=%d cause=%v", netErr.Op, netErr.Kind, netErr.Err)
|
||
}
|
||
var httpErr *HTTPError
|
||
if errors.As(err, &httpErr) {
|
||
fmt.Fprintf(&sb, "\n[debug] http: %s %s status=%d body=%s",
|
||
httpErr.Method, httpErr.Path, httpErr.StatusCode, strings.TrimSpace(httpErr.Body))
|
||
}
|
||
return sb.String()
|
||
}
|
||
|
||
// debugEnabled reports whether MULTICA_DEBUG requests debug output.
|
||
func debugEnabled() bool {
|
||
switch strings.ToLower(strings.TrimSpace(os.Getenv("MULTICA_DEBUG"))) {
|
||
case "", "0", "false", "no", "off":
|
||
return false
|
||
default:
|
||
return true
|
||
}
|
||
}
|
||
|
||
// ExitCodeFor maps an error onto a tiered process exit code so callers can
|
||
// branch in scripts: network=2, auth(401/403)=3, not-found(404)=4,
|
||
// validation(400/422)=5, everything else=1.
|
||
func ExitCodeFor(err error) int {
|
||
if err == nil {
|
||
return 0
|
||
}
|
||
|
||
var netErr *NetworkError
|
||
if errors.As(err, &netErr) {
|
||
return ExitNetwork
|
||
}
|
||
|
||
var httpErr *HTTPError
|
||
if errors.As(err, &httpErr) {
|
||
switch httpErr.Kind() {
|
||
case KindAuthRequired, KindForbidden:
|
||
return ExitAuth
|
||
case KindNotFound:
|
||
return ExitNotFound
|
||
case KindValidation:
|
||
return ExitValidation
|
||
default:
|
||
return ExitGeneric
|
||
}
|
||
}
|
||
|
||
return ExitGeneric
|
||
}
|