Files
multica/server/internal/cli/client.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

590 lines
17 KiB
Go

package cli
import (
"bytes"
"context"
"encoding/json"
"fmt"
"io"
"mime/multipart"
"net/http"
"os"
"path/filepath"
"runtime"
"strconv"
"strings"
"time"
)
// ClientVersion is the CLI version sent on every request as X-Client-Version.
// Set by the multica binary at init() so the package doesn't depend on the
// concrete cmd package. Defaults to "dev" when running unset (e.g. tests).
var ClientVersion = "dev"
// ClientPlatform identifies this client to the server. Override for tests
// or alternative entry points; defaults to "cli".
var ClientPlatform = "cli"
// ClientOS is the normalized operating system string sent as X-Client-OS.
// Computed once from runtime.GOOS so the server doesn't need to reverse-map
// Go's os names ("darwin"/"windows"/"linux") into the protocol vocabulary.
var ClientOS = normalizeGOOS(runtime.GOOS)
func normalizeGOOS(goos string) string {
switch goos {
case "darwin":
return "macos"
case "windows":
return "windows"
case "linux":
return "linux"
default:
return goos
}
}
// APIClient is a REST client for the Multica server API.
// Used by ctrl subcommands (agent, runtime, status, etc.). Requests
// automatically include auth and execution context headers when configured.
type APIClient struct {
BaseURL string
WorkspaceID string
Token string
AgentID string // When set, requests are attributed to this agent instead of the user.
TaskID string // When set, sent as X-Task-ID for agent-task validation.
HTTPClient *http.Client
// Identity overrides. Empty values fall back to the package-level
// ClientPlatform / ClientVersion / ClientOS.
Platform string
Version string
OS string
}
type HTTPError struct {
Method string
Path string
StatusCode int
Body string
}
func (e *HTTPError) Error() string {
return fmt.Sprintf("%s %s returned %d: %s", e.Method, e.Path, e.StatusCode, strings.TrimSpace(e.Body))
}
// newHTTPError builds a *HTTPError from an error response (status >= 400),
// reading a capped slice of the body. Every Multica API helper funnels its
// >= 400 responses through this so the top-level FormatError / ExitCodeFor can
// classify the failure via errors.As(err, **HTTPError) regardless of which
// HTTP verb the command used.
func newHTTPError(method, path string, resp *http.Response) *HTTPError {
data, _ := io.ReadAll(io.LimitReader(resp.Body, 4096))
return &HTTPError{
Method: method,
Path: path,
StatusCode: resp.StatusCode,
Body: strings.TrimSpace(string(data)),
}
}
// defaultHTTPTimeout is the per-request timeout for the CLI's HTTP client.
// It can be overridden with the MULTICA_HTTP_TIMEOUT environment variable
// (see httpTimeout). 30s is chosen over the historical 15s because complex
// networks (notably in mainland China) routinely need more than 15s to
// complete the TLS handshake plus request round-trip, which surfaced as an
// opaque "context deadline exceeded" to users.
const defaultHTTPTimeout = 30 * time.Second
// httpTimeout returns the HTTP client timeout, honoring MULTICA_HTTP_TIMEOUT.
// The value may be a Go duration string ("45s", "2m") or a plain integer
// number of seconds ("45"). Invalid or non-positive values fall back to the
// default.
func httpTimeout() time.Duration {
v := strings.TrimSpace(os.Getenv("MULTICA_HTTP_TIMEOUT"))
if v == "" {
return defaultHTTPTimeout
}
if d, err := time.ParseDuration(v); err == nil && d > 0 {
return d
}
if secs, err := strconv.Atoi(v); err == nil && secs > 0 {
return time.Duration(secs) * time.Second
}
return defaultHTTPTimeout
}
// apiContextGrace is added on top of the HTTP transport timeout when deriving
// a command-level context deadline, so the transport timeout (which produces a
// clean, classifiable "request timed out" error) is the one that fires rather
// than the outer context being canceled first.
const apiContextGrace = 5 * time.Second
// APITimeout returns the deadline budget for a single CLI API command. It is
// always at least the configured HTTP transport timeout (see httpTimeout,
// which honors MULTICA_HTTP_TIMEOUT) plus a small grace margin, so a
// command-level context never truncates an in-flight request below the timeout
// the user configured. This is the fix for command contexts that previously
// hardcoded a 15s deadline shorter than the 30s/env transport timeout.
func APITimeout() time.Duration {
return AtLeastAPITimeout(0)
}
// AtLeastAPITimeout returns max(min, APITimeout()). Use it for commands that
// need a larger floor than usual (for example file uploads, which historically
// used a 60s budget).
func AtLeastAPITimeout(min time.Duration) time.Duration {
budget := httpTimeout() + apiContextGrace
if min > budget {
return min
}
return budget
}
// APIContext derives a command-scoped context whose deadline is APITimeout().
// The returned cancel func must be called (typically via defer) to release
// resources. Commands should use this instead of context.WithTimeout with a
// hardcoded duration so the deadline always respects MULTICA_HTTP_TIMEOUT.
func APIContext(parent context.Context) (context.Context, context.CancelFunc) {
if parent == nil {
parent = context.Background()
}
return context.WithTimeout(parent, APITimeout())
}
// NewAPIClient creates a new API client for ctrl commands.
func NewAPIClient(baseURL, workspaceID, token string) *APIClient {
return &APIClient{
BaseURL: strings.TrimRight(baseURL, "/"),
WorkspaceID: workspaceID,
Token: token,
HTTPClient: &http.Client{Timeout: httpTimeout()},
}
}
func (c *APIClient) setHeaders(req *http.Request) {
if c.Token != "" {
req.Header.Set("Authorization", "Bearer "+c.Token)
}
if c.WorkspaceID != "" {
req.Header.Set("X-Workspace-ID", c.WorkspaceID)
}
if c.AgentID != "" {
req.Header.Set("X-Agent-ID", c.AgentID)
}
if c.TaskID != "" {
req.Header.Set("X-Task-ID", c.TaskID)
}
platform := c.Platform
if platform == "" {
platform = ClientPlatform
}
if platform != "" {
req.Header.Set("X-Client-Platform", platform)
}
version := c.Version
if version == "" {
version = ClientVersion
}
if version != "" {
req.Header.Set("X-Client-Version", version)
}
osName := c.OS
if osName == "" {
osName = ClientOS
}
if osName != "" {
req.Header.Set("X-Client-OS", osName)
}
}
// GetJSON performs a GET request and decodes the JSON response.
//
// On an HTTP error response (status >= 400) the returned error is a
// *HTTPError so callers can use errors.As to inspect the status code
// (for example to recognize a 404 from a server that does not expose a
// given endpoint and degrade gracefully). The error string format
// ("GET <path> returned <code>: <body>") is preserved by HTTPError.Error().
func (c *APIClient) GetJSON(ctx context.Context, path string, out any) error {
req, err := http.NewRequestWithContext(ctx, http.MethodGet, c.BaseURL+path, nil)
if err != nil {
return err
}
c.setHeaders(req)
resp, err := c.HTTPClient.Do(req)
err = wrapTransport(req, err)
if err != nil {
return err
}
defer resp.Body.Close()
if resp.StatusCode >= 400 {
return newHTTPError(http.MethodGet, path, resp)
}
if out == nil {
return nil
}
return json.NewDecoder(resp.Body).Decode(out)
}
// GetJSONWithHeaders performs a GET request, decodes the JSON response, and
// returns the response headers. Useful when callers need header values like
// X-Total-Count for pagination.
func (c *APIClient) GetJSONWithHeaders(ctx context.Context, path string, out any) (http.Header, error) {
req, err := http.NewRequestWithContext(ctx, http.MethodGet, c.BaseURL+path, nil)
if err != nil {
return nil, err
}
c.setHeaders(req)
resp, err := c.HTTPClient.Do(req)
err = wrapTransport(req, err)
if err != nil {
return nil, err
}
defer resp.Body.Close()
if resp.StatusCode >= 400 {
return nil, newHTTPError(http.MethodGet, path, resp)
}
if out != nil {
if err := json.NewDecoder(resp.Body).Decode(out); err != nil {
return resp.Header, err
}
}
return resp.Header, nil
}
// DeleteJSON performs a DELETE request.
func (c *APIClient) DeleteJSON(ctx context.Context, path string) error {
req, err := http.NewRequestWithContext(ctx, http.MethodDelete, c.BaseURL+path, nil)
if err != nil {
return err
}
c.setHeaders(req)
resp, err := c.HTTPClient.Do(req)
err = wrapTransport(req, err)
if err != nil {
return err
}
defer resp.Body.Close()
if resp.StatusCode >= 400 {
return newHTTPError(http.MethodDelete, path, resp)
}
return nil
}
// DeleteJSONWithBody performs a DELETE request with a JSON body.
func (c *APIClient) DeleteJSONWithBody(ctx context.Context, path string, body any) error {
data, err := json.Marshal(body)
if err != nil {
return err
}
req, err := http.NewRequestWithContext(ctx, http.MethodDelete, c.BaseURL+path, bytes.NewReader(data))
if err != nil {
return err
}
req.Header.Set("Content-Type", "application/json")
c.setHeaders(req)
resp, err := c.HTTPClient.Do(req)
err = wrapTransport(req, err)
if err != nil {
return err
}
defer resp.Body.Close()
if resp.StatusCode >= 400 {
return newHTTPError(http.MethodDelete, path, resp)
}
return nil
}
// PostJSON performs a POST request with a JSON body.
func (c *APIClient) PostJSON(ctx context.Context, path string, body any, out any) error {
data, err := json.Marshal(body)
if err != nil {
return err
}
req, err := http.NewRequestWithContext(ctx, http.MethodPost, c.BaseURL+path, bytes.NewReader(data))
if err != nil {
return err
}
req.Header.Set("Content-Type", "application/json")
c.setHeaders(req)
resp, err := c.HTTPClient.Do(req)
err = wrapTransport(req, err)
if err != nil {
return err
}
defer resp.Body.Close()
if resp.StatusCode >= 400 {
return newHTTPError(http.MethodPost, path, resp)
}
if out == nil {
return nil
}
return json.NewDecoder(resp.Body).Decode(out)
}
// PutJSON performs a PUT request with a JSON body.
func (c *APIClient) PutJSON(ctx context.Context, path string, body any, out any) error {
data, err := json.Marshal(body)
if err != nil {
return err
}
req, err := http.NewRequestWithContext(ctx, http.MethodPut, c.BaseURL+path, bytes.NewReader(data))
if err != nil {
return err
}
req.Header.Set("Content-Type", "application/json")
c.setHeaders(req)
resp, err := c.HTTPClient.Do(req)
err = wrapTransport(req, err)
if err != nil {
return err
}
defer resp.Body.Close()
if resp.StatusCode >= 400 {
return newHTTPError(http.MethodPut, path, resp)
}
if out == nil {
return nil
}
return json.NewDecoder(resp.Body).Decode(out)
}
// PatchJSON performs a PATCH request with a JSON body.
func (c *APIClient) PatchJSON(ctx context.Context, path string, body any, out any) error {
data, err := json.Marshal(body)
if err != nil {
return err
}
req, err := http.NewRequestWithContext(ctx, http.MethodPatch, c.BaseURL+path, bytes.NewReader(data))
if err != nil {
return err
}
req.Header.Set("Content-Type", "application/json")
c.setHeaders(req)
resp, err := c.HTTPClient.Do(req)
err = wrapTransport(req, err)
if err != nil {
return err
}
defer resp.Body.Close()
if resp.StatusCode >= 400 {
return newHTTPError(http.MethodPatch, path, resp)
}
if out == nil {
return nil
}
return json.NewDecoder(resp.Body).Decode(out)
}
// AttachmentResponse mirrors the server's upload-file response.
type AttachmentResponse struct {
ID string `json:"id"`
URL string `json:"url"`
DownloadURL string `json:"download_url"`
Filename string `json:"filename"`
ContentType string `json:"content_type"`
SizeBytes int64 `json:"size_bytes"`
CreatedAt string `json:"created_at"`
}
// UploadFile uploads a file via multipart form to /api/upload-file.
// It returns the attachment ID from the server response.
func (c *APIClient) UploadFile(ctx context.Context, fileData []byte, filename string, issueID string) (string, error) {
var body bytes.Buffer
writer := multipart.NewWriter(&body)
part, err := writer.CreateFormFile("file", filepath.Base(filename))
if err != nil {
return "", fmt.Errorf("create form file: %w", err)
}
if _, err := part.Write(fileData); err != nil {
return "", fmt.Errorf("write file data: %w", err)
}
if issueID != "" {
if err := writer.WriteField("issue_id", issueID); err != nil {
return "", fmt.Errorf("write issue_id field: %w", err)
}
}
if err := writer.Close(); err != nil {
return "", fmt.Errorf("close multipart writer: %w", err)
}
req, err := http.NewRequestWithContext(ctx, http.MethodPost, c.BaseURL+"/api/upload-file", &body)
if err != nil {
return "", err
}
req.Header.Set("Content-Type", writer.FormDataContentType())
c.setHeaders(req)
resp, err := c.HTTPClient.Do(req)
err = wrapTransport(req, err)
if err != nil {
return "", err
}
defer resp.Body.Close()
if resp.StatusCode >= 400 {
return "", newHTTPError(http.MethodPost, "/api/upload-file", resp)
}
var result map[string]any
if err := json.NewDecoder(resp.Body).Decode(&result); err != nil {
return "", fmt.Errorf("decode upload response: %w", err)
}
id, _ := result["id"].(string)
if id == "" {
return "", fmt.Errorf("upload response missing attachment id")
}
return id, nil
}
// UploadFileWithURL uploads a file via multipart form to /api/upload-file
// without associating it with an issue or comment. It decodes the full
// AttachmentResponse and returns the attachment ID and URL.
func (c *APIClient) UploadFileWithURL(ctx context.Context, fileData []byte, filename string) (string, string, error) {
var body bytes.Buffer
writer := multipart.NewWriter(&body)
part, err := writer.CreateFormFile("file", filepath.Base(filename))
if err != nil {
return "", "", fmt.Errorf("create form file: %w", err)
}
if _, err := part.Write(fileData); err != nil {
return "", "", fmt.Errorf("write file data: %w", err)
}
if err := writer.Close(); err != nil {
return "", "", fmt.Errorf("close multipart writer: %w", err)
}
req, err := http.NewRequestWithContext(ctx, http.MethodPost, c.BaseURL+"/api/upload-file", &body)
if err != nil {
return "", "", err
}
req.Header.Set("Content-Type", writer.FormDataContentType())
c.setHeaders(req)
// Use a client that respects the context deadline for slow uploads
// (e.g. avatar uploads with 5MB files). The default HTTP client timeout
// shadows any longer context deadline.
httpClient := c.HTTPClient
if deadline, ok := ctx.Deadline(); ok {
remaining := time.Until(deadline)
if remaining > httpClient.Timeout {
clientCopy := *httpClient
clientCopy.Timeout = remaining
httpClient = &clientCopy
}
}
resp, err := httpClient.Do(req)
err = wrapTransport(req, err)
if err != nil {
return "", "", err
}
defer resp.Body.Close()
if resp.StatusCode >= 400 {
return "", "", newHTTPError(http.MethodPost, "/api/upload-file", resp)
}
var result AttachmentResponse
if err := json.NewDecoder(resp.Body).Decode(&result); err != nil {
return "", "", fmt.Errorf("decode upload response: %w", err)
}
if result.URL == "" {
return "", "", fmt.Errorf("upload response missing attachment url")
}
// Allow empty ID: the server returns id="" in the fallback path where
// S3 upload succeeded but the attachment DB record failed. The file
// is still usable via its URL.
return result.ID, result.URL, nil
}
// DownloadFile downloads a file from the given URL and returns the response body.
// This is used for downloading attachments via their signed download_url.
// Downloads are limited to 100 MB to match the upload size limit.
//
// The URL may be absolute (a signed CloudFront/S3 URL) or relative
// (a server-relative path like "/api/attachments/{id}/download" or
// "/uploads/...") depending on how the
// server is configured. Relative URLs are resolved against the client's
// BaseURL and sent with the standard auth headers; absolute URLs are
// used as-is so that their query-string signatures are not disturbed.
func (c *APIClient) DownloadFile(ctx context.Context, downloadURL string) ([]byte, error) {
isRelative := !strings.HasPrefix(downloadURL, "http://") && !strings.HasPrefix(downloadURL, "https://")
if isRelative {
if c.BaseURL == "" {
return nil, fmt.Errorf("download URL %q is relative but client has no BaseURL", downloadURL)
}
downloadURL = c.BaseURL + downloadURL
}
req, err := http.NewRequestWithContext(ctx, http.MethodGet, downloadURL, nil)
if err != nil {
return nil, err
}
if isRelative {
c.setHeaders(req)
}
resp, err := c.HTTPClient.Do(req)
err = wrapTransport(req, err)
if err != nil {
return nil, err
}
defer resp.Body.Close()
if resp.StatusCode >= 400 {
return nil, newHTTPError(http.MethodGet, downloadURL, resp)
}
const maxDownloadSize = 100 << 20 // 100 MB
return io.ReadAll(io.LimitReader(resp.Body, maxDownloadSize))
}
// HealthCheck hits the /health endpoint and returns the response body.
func (c *APIClient) HealthCheck(ctx context.Context) (string, error) {
req, err := http.NewRequestWithContext(ctx, http.MethodGet, c.BaseURL+"/health", nil)
if err != nil {
return "", err
}
resp, err := c.HTTPClient.Do(req)
err = wrapTransport(req, err)
if err != nil {
return "", err
}
defer resp.Body.Close()
data, _ := io.ReadAll(io.LimitReader(resp.Body, 4096))
if resp.StatusCode >= 400 {
return "", &HTTPError{
Method: http.MethodGet,
Path: "/health",
StatusCode: resp.StatusCode,
Body: strings.TrimSpace(string(data)),
}
}
return strings.TrimSpace(string(data)), nil
}