onesvm-browser-server/server/internal/gateway/schedulerclient.go
chii eb972dfa93 feat: 落地 browser-server 控制面并打通 mgr1 海外订阅
单二进制三角色 + Dock 适配器 + Swarm stack 达到可部署态;mgr1 实测订阅经 central-proxy bootstrap,探活 alive=41/52。

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-09-02 15:05:12 +08:00

186 lines
6.1 KiB
Go
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

// schedulerclient.go:gateway → scheduler 内部 HTTP 契约客户端。
//
// 内部契约(W2/W3 对齐面,详见 impl-receipt-w2 §4):
//
// POST {base}/enqueue body=contract.JobEnvelopeExt → 200 {ok,job_id,request_id,queued_position}
// 失败:503 {error:{code:"unavailable"}} / 429 {error:{code:"rate_limited"}}
// GET {base}/result/{request_id} → 200 {request_id,status,position?,envelope?}
//
// 失败语义:连接失败/5xx 一律返回 *APIError(gateway 转 503+Retry-After,零落盘)。
package gateway
import (
"bytes"
"context"
"encoding/json"
"fmt"
"io"
"net/http"
"strings"
"time"
"onesvm.com/onesvm/browser-server/internal/contract"
)
// EnqueueTimeout enqueue 单次请求超时(brief:3s)。
const EnqueueTimeout = 3 * time.Second
// APIError scheduler 侧错误(HTTP 状态 + 错误码)。
type APIError struct {
HTTPStatus int
Code string
Message string
}
// Error 实现 error。
func (e *APIError) Error() string {
return fmt.Sprintf("scheduler: %d %s: %s", e.HTTPStatus, e.Code, e.Message)
}
// SchedulerClient scheduler HTTP 客户端(enqueue 3s 超时 + 1 次重试;result 轮询)。
type SchedulerClient struct {
base string
hc *http.Client
}
// NewSchedulerClient 构造。base 形如 http://scheduler:8641。
func NewSchedulerClient(base string) *SchedulerClient {
base = strings.TrimRight(base, "/")
return &SchedulerClient{
base: base,
hc: &http.Client{
Timeout: EnqueueTimeout,
Transport: &http.Transport{
MaxIdleConns: 8,
IdleConnTimeout: 30 * time.Second,
ResponseHeaderTimeout: EnqueueTimeout,
},
},
}
}
// Enqueue 入队(3s 超时 + 1 次重试)。重试仅针对网络错误/503(幂等:request_id 唯一约束兜底)。
func (c *SchedulerClient) Enqueue(ctx context.Context, job *contract.JobEnvelopeExt) (*enqueueResult, error) {
body, err := json.Marshal(job)
if err != nil {
return nil, fmt.Errorf("gateway: job 序列化: %w", err)
}
var lastErr error
for attempt := 0; attempt < 2; attempt++ {
if attempt > 0 {
select {
case <-ctx.Done():
return nil, ctx.Err()
case <-time.After(200 * time.Millisecond):
}
}
res, err := c.postEnqueue(ctx, body)
if err == nil {
return res, nil
}
lastErr = err
// 4xx(除 503/429 限流类)不重试:请求本身有问题
if ae, ok := err.(*APIError); ok && ae.HTTPStatus != http.StatusServiceUnavailable &&
ae.HTTPStatus != http.StatusTooManyRequests {
return nil, err
}
}
return nil, lastErr
}
// postEnqueue 单次 enqueue 调用。
func (c *SchedulerClient) postEnqueue(ctx context.Context, body []byte) (*enqueueResult, error) {
cctx, cancel := context.WithTimeout(ctx, EnqueueTimeout)
defer cancel()
req, err := http.NewRequestWithContext(cctx, http.MethodPost, c.base+"/enqueue", bytes.NewReader(body))
if err != nil {
return nil, fmt.Errorf("gateway: enqueue 请求构造: %w", err)
}
req.Header.Set("Content-Type", "application/json")
resp, err := c.hc.Do(req)
if err != nil {
return nil, &APIError{HTTPStatus: http.StatusServiceUnavailable,
Code: contract.CodeUnavailable, Message: "scheduler 不可达"}
}
defer resp.Body.Close()
raw, _ := io.ReadAll(io.LimitReader(resp.Body, 64*1024))
switch {
case resp.StatusCode == http.StatusOK:
var res enqueueResult
if err := json.Unmarshal(raw, &res); err != nil {
return nil, fmt.Errorf("gateway: enqueue 响应解析: %w", err)
}
return &res, nil
case resp.StatusCode == http.StatusServiceUnavailable || resp.StatusCode == http.StatusTooManyRequests:
ae := &APIError{HTTPStatus: resp.StatusCode, Code: contract.CodeUnavailable,
Message: "scheduler 队列满"}
var eb struct {
Error *contract.ErrBody `json:"error"`
}
if json.Unmarshal(raw, &eb) == nil && eb.Error != nil {
ae.Code = eb.Error.Code
ae.Message = eb.Error.Message
}
return nil, ae
default:
return nil, &APIError{HTTPStatus: resp.StatusCode, Code: contract.CodeUpstream,
Message: fmt.Sprintf("enqueue 异常状态 %d", resp.StatusCode)}
}
}
// Result 查询任务状态(轮询路径;短超时防轮询拖垮)。
func (c *SchedulerClient) Result(ctx context.Context, requestID string) (*resultState, error) {
cctx, cancel := context.WithTimeout(ctx, 3*time.Second)
defer cancel()
req, err := http.NewRequestWithContext(cctx, http.MethodGet,
c.base+"/result/"+requestID, nil)
if err != nil {
return nil, fmt.Errorf("gateway: result 请求构造: %w", err)
}
resp, err := c.hc.Do(req)
if err != nil {
// 轮询途中的瞬时不可达:让上层重试下一跳,不立即判死
return nil, &APIError{HTTPStatus: http.StatusServiceUnavailable,
Code: contract.CodeUnavailable, Message: "scheduler 不可达"}
}
defer resp.Body.Close()
raw, _ := io.ReadAll(io.LimitReader(resp.Body, 16*1024*1024))
// 202 = scheduler 侧 queued/running 非终态(ITER-1 F1 统一形状,golden:
// contract.ResultShapeAccepted):解析 status 返回,由 waitResult 继续轮询。
if resp.StatusCode == http.StatusAccepted {
var st resultState
if err := json.Unmarshal(raw, &st); err != nil {
return nil, fmt.Errorf("gateway: result 202 响应解析: %w", err)
}
return &st, nil
}
if resp.StatusCode != http.StatusOK {
return nil, &APIError{HTTPStatus: resp.StatusCode, Code: contract.CodeUpstream,
Message: fmt.Sprintf("result 异常状态 %d", resp.StatusCode)}
}
var st resultState
if err := json.Unmarshal(raw, &st); err != nil {
return nil, fmt.Errorf("gateway: result 响应解析: %w", err)
}
return &st, nil
}
// Pressure 查询背压(/readyz 用)。
func (c *SchedulerClient) Pressure(ctx context.Context) error {
cctx, cancel := context.WithTimeout(ctx, 2*time.Second)
defer cancel()
req, err := http.NewRequestWithContext(cctx, http.MethodGet, c.base+"/pressure", nil)
if err != nil {
return err
}
resp, err := c.hc.Do(req)
if err != nil {
return fmt.Errorf("scheduler /pressure 不可达: %w", err)
}
defer resp.Body.Close()
_, _ = io.Copy(io.Discard, io.LimitReader(resp.Body, 4*1024))
if resp.StatusCode != http.StatusOK {
return fmt.Errorf("scheduler /pressure 状态 %d", resp.StatusCode)
}
return nil
}