// 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 }