mirror of
https://github.com/multica-ai/multica.git
synced 2026-07-05 13:29:44 +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>
590 lines
17 KiB
Go
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
|
|
}
|