Files
multica/server/internal/cli/errors.go
Bohan Jiangandmultica-agent 8355c7aeca fix(cli): name the TLS handshake timeout and stop masking it on login (MUL-7572, GH #8654) (#8744)
A Windows user whose network dropped the two-packet TLS ClientHello that
Go 1.24+ sends by default (post-quantum key share, ~1.5 KB) saw only
"Sign-in did not complete: the server could not issue an access token"
and, on the --token path, "make sure it is valid and not expired". Neither
mentioned the network, and the generic timeout copy pointed at
MULTICA_HTTP_TIMEOUT, which does not govern the handshake.

- classify net/http's "TLS handshake timeout" as its own kind, with copy
  that names the GODEBUG=tlsmlkem=0 check in both languages
- let `multica login` keep the transport copy for transport failures; its
  sign-in copy now only overrides HTTP refusals
- troubleshooting entry (en/zh/ja/ko)

Co-authored-by: multica-agent <[email protected]>
2026-09-23 16:45:36 +08:00

714 lines
26 KiB
Go

package cli
import (
"context"
"crypto/tls"
"crypto/x509"
"encoding/json"
"errors"
"fmt"
"io"
"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.
// KindNetworkStalled is a transfer that stopped producing bytes (see
// StallError). Distinct from KindNetworkTimeout because the remedy is
// different: a timeout says "this took too long", a stall says "this went
// quiet", and only the latter is unaffected by raising a time limit.
KindNetworkStalled
// KindNetworkTLSHandshakeTimeout is a TLS handshake that never completed
// after the TCP connection opened. Distinct from KindNetworkTimeout
// because the remedy is different: the request budget
// (MULTICA_HTTP_TIMEOUT) does not govern the handshake, and the usual
// cause is a network path that drops a ClientHello spanning two TCP
// packets — which every Go client sends by default since Go 1.24 (the
// post-quantum key share makes it ~1.5 KB) while curl on the same machine
// gets through (GH #8654).
KindNetworkTLSHandshakeTimeout
// HTTP status layer.
KindAuthRequired // 401
// KindTaskTokenRejected is a 401 on a task-scoped token. HTTPError.Kind()
// never returns it — the status code alone cannot tell the two apart — so
// it is selected in userMessage, where the credential the request actually
// sent is known. Exit classification is unchanged: still an auth failure.
KindTaskTokenRejected
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, KindNetworkStalled, KindNetworkTLSHandshakeTimeout:
return true
default:
return false
}
}
// String returns a stable, snake_case identifier for the kind. It is used in
// --debug output and is safe to log or branch on; it is not user-facing copy
// (see kindMessages / messageFor for that).
func (k ErrorKind) String() string {
switch k {
case KindNetworkTimeout:
return "network_timeout"
case KindNetworkDNS:
return "network_dns"
case KindNetworkRefused:
return "network_refused"
case KindNetworkTLS:
return "network_tls"
case KindNetworkOffline:
return "network_offline"
case KindNetworkStalled:
return "network_stalled"
case KindNetworkTLSHandshakeTimeout:
return "network_tls_handshake_timeout"
case KindAuthRequired:
return "auth_required"
case KindTaskTokenRejected:
return "task_token_rejected"
case KindForbidden:
return "forbidden"
case KindNotFound:
return "not_found"
case KindConflict:
return "conflict"
case KindValidation:
return "validation"
case KindRateLimited:
return "rate_limited"
case KindServerError:
return "server_error"
case KindUnknown:
return "unknown"
default:
return fmt.Sprintf("ErrorKind(%d)", int(k))
}
}
// 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 }
// UserMessageError attaches a command-specific, user-facing message to an
// underlying error. FormatError shows Msg verbatim (in preference to the
// generic kind-based copy it would otherwise derive from a wrapped
// *NetworkError / *HTTPError), so command-level guidance — e.g. a `multica
// login` failure that is more helpful than the generic 401/timeout line — is
// visible in the default (non-debug) output.
//
// It preserves Unwrap(), so ExitCodeFor still classifies by the underlying
// typed error and --debug still prints the full original chain.
type UserMessageError struct {
Msg string
Err error
}
func (e *UserMessageError) Error() string {
if e.Err != nil {
return e.Msg + ": " + e.Err.Error()
}
return e.Msg
}
func (e *UserMessageError) Unwrap() error { return e.Err }
// WithUserMessage wraps err with a user-facing message that FormatError will
// surface by default. It returns nil when err is nil so it can be used inline
// in a `return` without an extra check.
func WithUserMessage(msg string, err error) error {
if err == nil {
return nil
}
return &UserMessageError{Msg: msg, Err: err}
}
// WithUserMessageUnlessNetwork is WithUserMessage for a command whose custom
// copy explains an HTTP-level refusal — a rejected token, a server that would
// not issue one — and would be wrong for a transport failure. When err is a
// *NetworkError it is returned unchanged, so FormatError renders the
// kind-based copy, which is the only place that names the actual remedy
// (DNS, proxy, TLS handshake).
//
// `multica login` needs this: with WithUserMessage, a TLS handshake that
// never completed was reported as "the server could not issue an access
// token" and "make sure the token is valid and not expired", and nothing in
// the default output pointed at the network (GH #8654).
func WithUserMessageUnlessNetwork(msg string, err error) error {
if err == nil {
return nil
}
var netErr *NetworkError
if errors.As(err, &netErr) {
return err
}
return &UserMessageError{Msg: msg, Err: 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
}
// A stalled transfer is checked first: the guard implements it by
// canceling the request context, so the underlying error would otherwise
// read as a generic cancellation and lose the reason.
var stalled *StallError
if errors.As(err, &stalled) {
return KindNetworkStalled
}
// 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() {
// net/http reports a TLS handshake that never completed with an
// unexported type that also satisfies Timeout(), so only its message
// tells it apart from a socket timeout. The remedy differs (see
// KindNetworkTLSHandshakeTimeout), so it must not be folded into the
// generic timeout. Error() is only consulted here, on a timeout: the
// typed x509 checks below must keep running before any message is
// rendered, because a hostname error renders its certificate.
if isTLSHandshakeTimeout(err) {
return KindNetworkTLSHandshakeTimeout
}
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, "tls handshake timeout"):
return KindNetworkTLSHandshakeTimeout
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
}
// isTLSHandshakeTimeout reports whether err is net/http's TLS handshake
// timeout, which is only identifiable by its message.
func isTLSHandshakeTimeout(err error) bool {
return strings.Contains(strings.ToLower(err.Error()), "tls handshake timeout")
}
// 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}
}
// wrapBodyRead classifies an error raised while reading or decoding a
// response body.
//
// wrapTransport only sees errors from http.Client.Do, which returns as soon as
// the response headers arrive. Everything that goes wrong afterwards — the
// case #7498 actually reports, a body that never finishes arriving — surfaces
// out of the JSON decoder instead, and used to reach the user as a raw
// "context deadline exceeded ... while reading body". A transport failure is
// still a transport failure when the decoder is the one that notices it; a
// genuine malformed-JSON error is returned unchanged.
//
// io.ErrUnexpectedEOF is deliberately on the network side of that line. It is
// what the decoder reports for a body that simply stops — a dropped
// connection, a proxy cutting the response — which is vastly the more common
// cause than a server that emits syntactically truncated JSON. Well-formed
// nonsense (the ordinary server bug) raises *json.SyntaxError and is left
// alone.
func wrapBodyRead(req *http.Request, err error) error {
if err == nil {
return nil
}
var stalled *StallError
if errors.As(err, &stalled) {
return wrapTransport(req, err)
}
var netErr net.Error
if errors.As(err, &netErr) ||
errors.Is(err, context.DeadlineExceeded) ||
errors.Is(err, context.Canceled) ||
errors.Is(err, io.ErrUnexpectedEOF) {
return wrapTransport(req, err)
}
return 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 调高超时时间。",
},
KindNetworkTLSHandshakeTimeout: {
"TLS handshake timed out: the connection to the Multica server opened, but the secure handshake never completed. Something on this network path (security software, a VPN, a router, or a firewall) is probably dropping large TLS handshakes; curl or a browser on the same machine may still work. Retry with the environment variable GODEBUG=tlsmlkem=0 set, and keep it set for the CLI and the daemon if that fixes it. MULTICA_HTTP_TIMEOUT does not affect the handshake.",
"TLS 握手超时:已连上 Multica 服务器,但安全握手一直没有完成。通常是网络路径上的安全软件、VPN、路由器或防火墙丢弃了较大的 TLS 握手包,同一台机器上的 curl 或浏览器可能仍然正常。请设置环境变量 GODEBUG=tlsmlkem=0 后重试;若因此恢复,请为 CLI 和守护进程长期保留该设置。MULTICA_HTTP_TIMEOUT 对握手无效。",
},
KindNetworkStalled: {
"Transfer stalled: the connection stopped sending data before the response was complete. Check your network connection or try again. You can raise the no-progress budget with MULTICA_HTTP_STALL_TIMEOUT.",
"传输中断:响应尚未接收完毕,连接就停止发送数据。请检查网络连接或重试。可通过 MULTICA_HTTP_STALL_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. On a self-hosted or non-OAuth setup, ask your administrator for valid credentials.",
"登录已过期或尚未登录。请运行 `multica login` 重新登录。自托管或非 OAuth 场景请联系管理员获取有效凭证。",
},
KindTaskTokenRejected: {
"This task token was rejected and is no longer usable. Stop here: do not retry, and do not fall back to a profile or member credential, because anything done with one would run as that person rather than as this task. Only the runtime that started this task can supply a valid task token.",
"这个 task token 已被拒绝,不再可用。请到此为止:不要重试,也不要改用 profile 或成员凭证 —— 用它们执行的任何操作都会以那个成员的身份运行,而不是以这次 task 的身份运行。只有启动这次 task 的运行时才能提供有效的 task token。",
},
KindForbidden: {
"You do not have permission to access this resource. Check that you are in the right workspace, or ask an administrator to grant access.",
"无权访问该资源。请确认当前 workspace 是否正确,或联系管理员授予权限。",
},
KindNotFound: {
"The requested resource was not found. Check the ID, or run the matching `list` command to see what exists in this workspace.",
"未找到请求的资源。请核对 ID,或运行对应的 list 命令查看当前 workspace 中已有的内容。",
},
KindConflict: {
"The request conflicts with the current state of the resource (it may already exist or have changed since you last fetched it). Re-fetch the latest state and try again.",
"请求与资源的当前状态冲突(可能已存在,或自上次获取后已被修改)。请重新获取最新状态后再试。",
},
KindValidation: {
"The request was invalid. Check the values you provided; run the command with --help to see the expected format.",
"请求无效。请检查所填写的参数;可用 --help 查看期望的格式。",
},
KindRateLimited: {
"Too many requests. Please wait a moment and try again; if this keeps happening, reduce how frequently you call the API.",
"请求过于频繁。请稍候重试;若持续出现,请降低 API 调用频率。",
},
KindServerError: {
"The Multica service is temporarily unavailable (server error). Please try again later; if it persists, contact support. Re-run with --debug to see the raw server response.",
"Multica 服务暂时不可用(服务器错误)。请稍后重试;若持续出现请联系支持。可加 --debug 查看服务器原始响应。",
},
KindUnknown: {
"An unexpected error occurred.",
"发生未知错误。",
},
}
// serverMessagePrefixes lists the kinds whose response body carries a
// hand-written, actionable message worth showing instead of the generic
// template, with the {English, Chinese} lead-in used to introduce it.
//
// 409 belongs here because the generic conflict copy is not merely vague, it
// points the wrong way: it reads as a transient race ("re-fetch the latest
// state and try again") while every conflict this API returns is a
// deterministic refusal that names its own fix ("a skill with this name
// already exists", "set parent_id (--parent) to <id>"). Agents took the retry
// hint literally and burned hours re-sending an unchanged request (GH #6264,
// GH #5948), and the server-side wording added in MUL-4417 never reached them.
var serverMessagePrefixes = map[ErrorKind][2]string{
KindValidation: {"Invalid request: ", "请求无效:"},
KindConflict: {"Request conflict: ", "请求冲突:"},
}
// 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 {
// A command-supplied user-facing message takes precedence over the generic
// kind-based copy, so command-specific guidance (e.g. sign-in failures) is
// visible by default. Unwrap() is preserved, so ExitCodeFor and --debug
// still see the underlying typed error.
var um *UserMessageError
if errors.As(err, &um) {
return um.Msg
}
// 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()
// A 401 on a task token is not a login problem, and the generic copy
// below is the wrong instruction for whoever reads it: it says to sign
// in again or ask an administrator for valid credentials. An
// autonomous agent can act on that, and one did — after its task token
// stopped working mid-run it read the daemon owner's profile PAT and
// kept going under the member's identity (GH #7522).
//
// What the copy must not do is guess *why*. The usual cause is the
// task reaching a terminal state, but the same 401 covers a malformed
// token, one sent to the wrong server, and one dropped by an unrelated
// cleanup. "Stop" is true in every one of those cases; "the task
// finished" is not.
if kind == KindAuthRequired && httpErr.TaskScoped {
return messageFor(KindTaskTokenRejected, lang)
}
// Validation and conflict errors carry a useful server-provided
// message; surface it instead of the generic line. A body we cannot
// recognize still falls back to the template, so this never dumps a
// raw response at the user.
if prefix, ok := serverMessagePrefixes[kind]; ok {
if serverMsg := extractServerMessage(httpErr.Body); serverMsg != "" {
if lang == LangZH {
return prefix[1] + serverMsg
}
return prefix[0] + 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.
//
// A few endpoints put a stable machine code in "error" and the prose in
// "message" (the issue-table cursor responses do this). Prose always wins;
// a bare code is kept only as a last resort so the user still gets something
// greppable when no sentence is on offer.
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 ""
}
var code string
for _, key := range []string{"error", "message", "detail", "title"} {
v, ok := parsed[key]
if !ok {
continue
}
s, ok := v.(string)
if !ok {
continue
}
if s = strings.TrimSpace(s); s == "" {
continue
}
if looksLikeMachineCode(s) {
if code == "" {
code = s
}
continue
}
return s
}
return code
}
// ServerErrorCode returns the stable `code` a server error body carries, or ""
// when err is not an HTTP failure or the body has no code.
//
// It exists so a command can recognize ONE specific refusal and print guidance
// for it without matching on the English sentence, which changes with copy edits
// and disappears under translation. Statuses whose generic copy is deliberately
// vague — 403 above all, where naming the cause could confirm a resource exists —
// keep that copy for every code a command has not explicitly opted into.
func ServerErrorCode(err error) string {
var httpErr *HTTPError
if !errors.As(err, &httpErr) {
return ""
}
body := strings.TrimSpace(httpErr.Body)
if body == "" || body[0] != '{' {
return ""
}
var parsed struct {
Code string `json:"code"`
}
if jsonErr := json.Unmarshal([]byte(body), &parsed); jsonErr != nil {
return ""
}
if !looksLikeMachineCode(parsed.Code) {
return ""
}
return parsed.Code
}
// looksLikeMachineCode reports whether s is a bare identifier such as
// "cursor_query_mismatch" rather than a sentence meant for a person. The test
// is deliberately narrow — lowercase word characters only — so that ordinary
// prose in any language, including Chinese without ASCII spaces, is never
// mistaken for a code.
func looksLikeMachineCode(s string) bool {
if s == "" {
return false
}
for _, r := range s {
switch {
case r >= 'a' && r <= 'z', r >= '0' && r <= '9', r == '_', r == '-', r == '.':
default:
return false
}
}
return true
}
// 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=%s 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
}