Files
mrlonely 84e56b6b00 MUL-8256 fix(runtime): recover stalled HTTP/2 connections (#9551)
* fix(daemon): recover stalled HTTP/2 control-plane connections

* fix(daemon): recover stalled skill bundle connections

* fix(runtime): share HTTP/2 recovery across persistent clients
2026-10-28 13:08:35 +08:00

91 lines
2.6 KiB
Go

// Package qoderruntime bridges Multica's daemon task protocol to Qoder's managed sessions API.
package qoderruntime
import (
"bytes"
"context"
"encoding/json"
"fmt"
"io"
"net/http"
"net/url"
"strings"
"time"
"github.com/multica-ai/multica/server/pkg/httptransport"
)
type api struct {
base, token, workspace string
client *http.Client
}
type apiError struct{ Status int }
func (e *apiError) Error() string { return fmt.Sprintf("remote API returned HTTP %d", e.Status) }
func newAPI(base, token, workspace string) (*api, error) {
u, err := url.Parse(base)
if err != nil || u.Host == "" || (u.Scheme != "https" && u.Scheme != "http") || u.User != nil || u.RawQuery != "" || u.Fragment != "" {
return nil, fmt.Errorf("invalid API base URL")
}
if u.Scheme == "http" && u.Hostname() != "localhost" && u.Hostname() != "127.0.0.1" && u.Hostname() != "::1" {
return nil, fmt.Errorf("API base URL must use HTTPS except on loopback")
}
if strings.TrimSpace(token) == "" {
return nil, fmt.Errorf("API token is required")
}
return &api{
base: strings.TrimRight(base, "/"), token: token, workspace: workspace,
client: &http.Client{
Timeout: 30 * time.Second, Transport: httptransport.CloneDefault(),
CheckRedirect: func(*http.Request, []*http.Request) error { return http.ErrUseLastResponse },
},
}, nil
}
// Errors deliberately omit remote bodies and URLs, which may echo credentials or task content.
func (a *api) call(ctx context.Context, method, path string, in, out any) error {
var body io.Reader
if in != nil {
b, err := json.Marshal(in)
if err != nil {
return err
}
body = bytes.NewReader(b)
}
req, err := http.NewRequestWithContext(ctx, method, a.base+path, body)
if err != nil {
return fmt.Errorf("build API request: %w", err)
}
req.Header.Set("Authorization", "Bearer "+a.token)
req.Header.Set("Content-Type", "application/json")
req.Header.Set("Accept", "application/json")
if a.workspace != "" {
req.Header.Set("X-Workspace-ID", a.workspace)
}
resp, err := a.client.Do(req)
if err != nil {
if ctx.Err() != nil {
return ctx.Err()
}
return fmt.Errorf("remote API transport failed")
}
defer resp.Body.Close()
if resp.StatusCode < 200 || resp.StatusCode >= 300 {
return &apiError{resp.StatusCode}
}
b, err := io.ReadAll(io.LimitReader(resp.Body, 8<<20+1))
if err != nil {
return fmt.Errorf("read API response failed")
}
if len(b) > 8<<20 {
return fmt.Errorf("API response exceeds 8 MiB")
}
if out != nil {
if err := json.Unmarshal(b, out); err != nil {
return fmt.Errorf("invalid API JSON response")
}
}
return nil
}