mirror of
https://github.com/multica-ai/multica.git
synced 2026-06-17 03:38:32 +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>
233 lines
8.8 KiB
Go
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)
|
|
}
|
|
})
|
|
}
|