Files
multica/server/internal/cli/errors_integration_test.go
LinYushen 28de8b8bde feat(cli): central error translation layer (PR1, MUL-3104) (#3892)
* 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>
2026-06-08 15:34:59 +08:00

233 lines
8.8 KiB
Go

package cli
import (
"context"
"encoding/json"
"errors"
"io"
"net/http"
"net/http/httptest"
"strings"
"testing"
"time"
)
// TestHelperStatusErrorsAreClassified is an integration test that drives the
// real client helpers against a fake server and asserts the error they return
// flows correctly through the top-level FormatError / ExitCodeFor. This is the
// coverage the unit tests were missing: it proves that the actual command
// paths (issue update -> PutJSON, comment list -> GetJSONWithHeaders, project
// delete -> DeleteJSON, agent update -> PatchJSON, upload, download) all get
// the friendly copy and the tiered exit code, not the old raw string + exit 1.
func TestHelperStatusErrorsAreClassified(t *testing.T) {
withLang(t, "en_US.UTF-8")
// Each helper wraps a real client call onto a fixed path. The server
// returns whatever status the current case dictates.
helpers := []struct {
name string
call func(c *APIClient, ctx context.Context) error
}{
{"GetJSON (issue get)", func(c *APIClient, ctx context.Context) error {
var out map[string]any
return c.GetJSON(ctx, "/api/issues/abc", &out)
}},
{"GetJSONWithHeaders (comment list)", func(c *APIClient, ctx context.Context) error {
var out []any
_, err := c.GetJSONWithHeaders(ctx, "/api/issues/abc/comments", &out)
return err
}},
{"PostJSON (issue create)", func(c *APIClient, ctx context.Context) error {
return c.PostJSON(ctx, "/api/issues", map[string]any{"title": "t"}, nil)
}},
{"PutJSON (issue update)", func(c *APIClient, ctx context.Context) error {
return c.PutJSON(ctx, "/api/issues/abc", map[string]any{"title": "t"}, nil)
}},
{"PatchJSON (agent update)", func(c *APIClient, ctx context.Context) error {
return c.PatchJSON(ctx, "/api/agents/abc", map[string]any{"name": "n"}, nil)
}},
{"DeleteJSON (project delete)", func(c *APIClient, ctx context.Context) error {
return c.DeleteJSON(ctx, "/api/projects/abc")
}},
{"DeleteJSONWithBody (squad member remove)", func(c *APIClient, ctx context.Context) error {
return c.DeleteJSONWithBody(ctx, "/api/squads/abc/members", map[string]any{"member_id": "x"})
}},
{"UploadFile (issue attachment)", func(c *APIClient, ctx context.Context) error {
_, err := c.UploadFile(ctx, []byte("x"), "x.txt", "abc")
return err
}},
{"UploadFileWithURL (avatar)", func(c *APIClient, ctx context.Context) error {
_, _, err := c.UploadFileWithURL(ctx, []byte("x"), "x.txt")
return err
}},
{"DownloadFile (attachment)", func(c *APIClient, ctx context.Context) error {
_, err := c.DownloadFile(ctx, "/uploads/abc")
return err
}},
}
statusCases := []struct {
status int
wantExit int
wantCopy string
}{
{http.StatusUnauthorized, ExitAuth, "multica login"},
{http.StatusForbidden, ExitAuth, "permission"},
{http.StatusNotFound, ExitNotFound, "not found"},
{http.StatusUnprocessableEntity, ExitValidation, "title is required"},
{http.StatusInternalServerError, ExitGeneric, "temporarily unavailable"},
}
for _, sc := range statusCases {
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.WriteHeader(sc.status)
// Validation responses carry a server message that FormatError
// should surface verbatim; other statuses ignore the body.
io.WriteString(w, `{"error":"title is required"}`)
}))
for _, h := range helpers {
t.Run(http.StatusText(sc.status)+"/"+h.name, func(t *testing.T) {
client := NewAPIClient(srv.URL, "ws", "token")
err := h.call(client, context.Background())
if err == nil {
t.Fatalf("expected error, got nil")
}
// 1. The helper must return a *HTTPError (possibly wrapped).
var httpErr *HTTPError
if !errors.As(err, &httpErr) {
t.Fatalf("expected *HTTPError, got %T: %v", err, err)
}
if httpErr.StatusCode != sc.status {
t.Errorf("status = %d, want %d", httpErr.StatusCode, sc.status)
}
// 2. Tiered exit code.
if got := ExitCodeFor(err); got != sc.wantExit {
t.Errorf("ExitCodeFor = %d, want %d", got, sc.wantExit)
}
// 3. Friendly, localized copy — and no raw path/verb leak.
msg := FormatError(err, false)
if !strings.Contains(msg, sc.wantCopy) {
t.Errorf("FormatError = %q, want it to contain %q", msg, sc.wantCopy)
}
if strings.Contains(msg, "/api/") || strings.Contains(msg, "returned") {
t.Errorf("FormatError leaked raw detail: %q", msg)
}
})
}
srv.Close()
}
}
// TestCommandContextNotTruncatedBelowHTTPTimeout proves the must-fix: a
// command-level context derived from APIContext never cancels a request before
// the configured transport timeout. Previously commands hardcoded a 15s
// context that truncated a longer MULTICA_HTTP_TIMEOUT.
func TestCommandContextNotTruncatedBelowHTTPTimeout(t *testing.T) {
withLang(t, "en_US.UTF-8")
// Use a short transport timeout so the test runs fast; the command budget
// (transport + grace) is what we assert is NOT the limiting factor.
t.Setenv("MULTICA_HTTP_TIMEOUT", "400ms")
if APITimeout() <= httpTimeout() {
t.Fatalf("APITimeout (%v) must be strictly greater than httpTimeout (%v)", APITimeout(), httpTimeout())
}
t.Run("request within transport budget completes (context does not fire first)", func(t *testing.T) {
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
time.Sleep(120 * time.Millisecond) // < 400ms transport timeout
w.Header().Set("Content-Type", "application/json")
json.NewEncoder(w).Encode(map[string]string{"ok": "true"})
}))
defer srv.Close()
client := NewAPIClient(srv.URL, "", "")
ctx, cancel := APIContext(context.Background())
defer cancel()
var out map[string]string
if err := client.GetJSON(ctx, "/x", &out); err != nil {
t.Fatalf("expected success within transport budget, got %v", err)
}
})
t.Run("transport timeout governs and yields a friendly timeout error", func(t *testing.T) {
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
time.Sleep(900 * time.Millisecond) // > 400ms transport timeout
w.WriteHeader(http.StatusOK)
}))
defer srv.Close()
client := NewAPIClient(srv.URL, "", "")
ctx, cancel := APIContext(context.Background())
defer cancel()
start := time.Now()
err := client.GetJSON(ctx, "/x", nil)
elapsed := time.Since(start)
if err == nil {
t.Fatal("expected a timeout error")
}
// Must fail near the 400ms transport timeout, not the ~5.4s command
// context deadline. A generous 3s ceiling keeps the test non-flaky
// while still proving the context did not govern.
if elapsed > 3*time.Second {
t.Errorf("request took %v; the command context likely truncated/governed instead of the transport timeout", elapsed)
}
if msg := FormatError(err, false); !strings.Contains(msg, "timed out") {
t.Errorf("expected friendly timeout copy, got %q", msg)
}
if got := ExitCodeFor(err); got != ExitNetwork {
t.Errorf("ExitCodeFor = %d, want ExitNetwork(%d)", got, ExitNetwork)
}
})
}
// TestAPITimeoutRespectsEnv verifies the command budget tracks
// MULTICA_HTTP_TIMEOUT and the AtLeast floor.
func TestAPITimeoutRespectsEnv(t *testing.T) {
t.Run("default", func(t *testing.T) {
t.Setenv("MULTICA_HTTP_TIMEOUT", "")
if got, want := APITimeout(), defaultHTTPTimeout+apiContextGrace; got != want {
t.Errorf("APITimeout = %v, want %v", got, want)
}
})
t.Run("env override raises both transport and command budget", func(t *testing.T) {
t.Setenv("MULTICA_HTTP_TIMEOUT", "90s")
if got, want := APITimeout(), 90*time.Second+apiContextGrace; got != want {
t.Errorf("APITimeout = %v, want %v", got, want)
}
})
t.Run("AtLeast floor wins when larger", func(t *testing.T) {
t.Setenv("MULTICA_HTTP_TIMEOUT", "10s")
if got, want := AtLeastAPITimeout(60*time.Second), 60*time.Second; got != want {
t.Errorf("AtLeastAPITimeout = %v, want %v", got, want)
}
})
t.Run("AtLeast budget wins when larger than floor", func(t *testing.T) {
t.Setenv("MULTICA_HTTP_TIMEOUT", "120s")
if got, want := AtLeastAPITimeout(60*time.Second), 120*time.Second+apiContextGrace; got != want {
t.Errorf("AtLeastAPITimeout = %v, want %v", got, want)
}
})
// The login workspace-creation poll uses a short 10s floor to stay
// responsive, but it must still honor MULTICA_HTTP_TIMEOUT (it is not a
// silent exception to the env-override promise). With the env unset the
// budget is at least the 10s floor; with the env raised it scales up.
t.Run("login poll 10s floor honors env override", func(t *testing.T) {
t.Setenv("MULTICA_HTTP_TIMEOUT", "")
if got := AtLeastAPITimeout(10 * time.Second); got < 10*time.Second {
t.Errorf("AtLeastAPITimeout(10s) = %v, want >= 10s floor", got)
}
t.Setenv("MULTICA_HTTP_TIMEOUT", "90s")
if got, want := AtLeastAPITimeout(10*time.Second), 90*time.Second+apiContextGrace; got != want {
t.Errorf("AtLeastAPITimeout(10s) with env=90s = %v, want %v", got, want)
}
})
}