Enable Binding-scoped agent push/pull, empty-table schema ensure, SyncPage inspect/drop-table, default module import, and agent-bound publish docs from the 宇恒联调意见. Co-authored-by: Cursor <cursoragent@cursor.com>
278 lines
7.4 KiB
Go
278 lines
7.4 KiB
Go
package proxy
|
||
|
||
import (
|
||
"context"
|
||
"errors"
|
||
"io"
|
||
"log"
|
||
"net"
|
||
"net/http"
|
||
"net/http/httputil"
|
||
"net/url"
|
||
"strconv"
|
||
"strings"
|
||
"time"
|
||
)
|
||
|
||
type Director struct {
|
||
Platform *url.URL
|
||
AI *url.URL
|
||
Timeout time.Duration
|
||
}
|
||
|
||
func New(platformURL, aiURL string, timeoutSec int) (*Director, error) {
|
||
p, err := url.Parse(platformURL)
|
||
if err != nil {
|
||
return nil, err
|
||
}
|
||
a, err := url.Parse(aiURL)
|
||
if err != nil {
|
||
return nil, err
|
||
}
|
||
if timeoutSec <= 0 {
|
||
timeoutSec = 180
|
||
}
|
||
return &Director{Platform: p, AI: a, Timeout: time.Duration(timeoutSec) * time.Second}, nil
|
||
}
|
||
|
||
// eofRetryTransport:空闲连接被 platform 关掉时首包 EOF,自动换新连接重试一次。
|
||
type eofRetryTransport struct {
|
||
base http.RoundTripper
|
||
}
|
||
|
||
func (t eofRetryTransport) RoundTrip(req *http.Request) (*http.Response, error) {
|
||
base := t.base
|
||
if base == nil {
|
||
base = http.DefaultTransport
|
||
}
|
||
resp, err := base.RoundTrip(req)
|
||
if err == nil || req.Context().Err() != nil {
|
||
return resp, err
|
||
}
|
||
if !isTransientUpstreamEOF(err) {
|
||
return resp, err
|
||
}
|
||
// Body 可能已部分读;仅在尚未发出/可读失败时安全重试 GET/有 GetBody 的请求
|
||
if req.Body != nil && req.GetBody == nil && req.ContentLength != 0 {
|
||
return resp, err
|
||
}
|
||
if req.GetBody != nil {
|
||
body, gerr := req.GetBody()
|
||
if gerr != nil {
|
||
return resp, err
|
||
}
|
||
req = req.Clone(req.Context())
|
||
req.Body = body
|
||
}
|
||
return base.RoundTrip(req)
|
||
}
|
||
|
||
func isTransientUpstreamEOF(err error) bool {
|
||
if err == nil {
|
||
return false
|
||
}
|
||
if errors.Is(err, io.EOF) || errors.Is(err, io.ErrUnexpectedEOF) {
|
||
return true
|
||
}
|
||
msg := strings.ToLower(err.Error())
|
||
return strings.Contains(msg, "eof") ||
|
||
strings.Contains(msg, "connection reset") ||
|
||
strings.Contains(msg, "broken pipe") ||
|
||
strings.Contains(msg, "server closed idle connection")
|
||
}
|
||
|
||
func (d *Director) Handler() http.HandlerFunc {
|
||
baseTransport := &http.Transport{
|
||
Proxy: http.ProxyFromEnvironment,
|
||
DialContext: (&net.Dialer{
|
||
Timeout: 10 * time.Second,
|
||
KeepAlive: 30 * time.Second,
|
||
}).DialContext,
|
||
MaxIdleConns: 100,
|
||
MaxIdleConnsPerHost: 8,
|
||
IdleConnTimeout: 30 * time.Second,
|
||
TLSHandshakeTimeout: 10 * time.Second,
|
||
ExpectContinueTimeout: 1 * time.Second,
|
||
ResponseHeaderTimeout: d.Timeout,
|
||
// Windows 上 platform 重启后空闲连接易 EOF;关掉 keep-alive 比偶发 502 更稳
|
||
DisableKeepAlives: true,
|
||
}
|
||
transport := eofRetryTransport{base: baseTransport}
|
||
|
||
return func(w http.ResponseWriter, r *http.Request) {
|
||
target, path := d.route(r.URL.Path)
|
||
timeout := d.Timeout
|
||
// dbsync agent push/pull:Windows SQLite 冷打开可超过默认超时
|
||
if isDBSyncHeavyPath(path) && timeout < 180*time.Second {
|
||
timeout = 180 * time.Second
|
||
}
|
||
|
||
proxy := httputil.NewSingleHostReverseProxy(target)
|
||
proxy.Transport = transport
|
||
proxy.FlushInterval = 100 * time.Millisecond
|
||
proxy.ErrorHandler = func(rw http.ResponseWriter, req *http.Request, err error) {
|
||
rw.Header().Set("Content-Type", "application/json")
|
||
msg := escape(err.Error())
|
||
code := http.StatusBadGateway
|
||
// 客户端/网关超时 → 503,便于 agent 按 retryable 重试(与 platform 语义一致)
|
||
if errors.Is(err, context.DeadlineExceeded) || errors.Is(req.Context().Err(), context.DeadlineExceeded) ||
|
||
strings.Contains(strings.ToLower(err.Error()), "deadline exceeded") ||
|
||
strings.Contains(strings.ToLower(err.Error()), "context canceled") {
|
||
code = http.StatusServiceUnavailable
|
||
}
|
||
rw.WriteHeader(code)
|
||
_, _ = rw.Write([]byte(
|
||
`{"code":` + strconv.Itoa(code) + `,"message":"upstream unavailable: ` + msg + `","retryable":true}`,
|
||
))
|
||
}
|
||
proxy.ModifyResponse = func(resp *http.Response) error {
|
||
for _, h := range []string{
|
||
"Access-Control-Allow-Origin",
|
||
"Access-Control-Allow-Credentials",
|
||
"Access-Control-Allow-Headers",
|
||
"Access-Control-Allow-Methods",
|
||
"Access-Control-Expose-Headers",
|
||
"Access-Control-Max-Age",
|
||
} {
|
||
resp.Header.Del(h)
|
||
}
|
||
resp.Header.Set("X-Gateway", "aijianzhan-gateway")
|
||
return nil
|
||
}
|
||
|
||
r.URL.Path = path
|
||
r.Host = target.Host
|
||
r.Header.Set("X-Forwarded-Host", r.Header.Get("Host"))
|
||
r.Header.Set("X-Forwarded-Proto", "http")
|
||
if r.Header.Get("X-Request-Id") == "" {
|
||
r.Header.Set("X-Request-Id", newRequestID())
|
||
}
|
||
reqID := r.Header.Get("X-Request-Id")
|
||
started := time.Now()
|
||
rec := &statusRecorder{ResponseWriter: w, status: 200}
|
||
|
||
ctx, cancel := context.WithTimeout(r.Context(), timeout)
|
||
defer cancel()
|
||
proxy.ServeHTTP(rec, r.WithContext(ctx))
|
||
if isDBSyncHeavyPath(path) || rec.status >= 500 {
|
||
log.Printf("gateway %s %s status=%d dur=%s req=%s", r.Method, path, rec.status, time.Since(started), reqID)
|
||
}
|
||
}
|
||
}
|
||
|
||
type statusRecorder struct {
|
||
http.ResponseWriter
|
||
status int
|
||
}
|
||
|
||
func (s *statusRecorder) WriteHeader(code int) {
|
||
s.status = code
|
||
s.ResponseWriter.WriteHeader(code)
|
||
}
|
||
|
||
func isDBSyncHeavyPath(path string) bool {
|
||
if strings.Contains(path, "/agent/sync/") {
|
||
return true
|
||
}
|
||
if !strings.Contains(path, "/admin/sync/") {
|
||
return false
|
||
}
|
||
return strings.Contains(path, "/push") ||
|
||
strings.HasSuffix(path, "/reconcile") ||
|
||
strings.HasSuffix(path, "/ingest") ||
|
||
strings.HasSuffix(path, "/prepare") ||
|
||
strings.HasSuffix(path, "/start") ||
|
||
strings.HasSuffix(path, "/pull") ||
|
||
strings.HasSuffix(path, "/bootstrap")
|
||
}
|
||
|
||
func (d *Director) route(path string) (*url.URL, string) {
|
||
if strings.HasPrefix(path, "/ai/") || path == "/ai" {
|
||
next := strings.TrimPrefix(path, "/ai")
|
||
if next == "" {
|
||
next = "/"
|
||
}
|
||
return d.AI, next
|
||
}
|
||
if path == "/api/v1/apps/generate" || strings.HasPrefix(path, "/api/v1/apps/generate?") {
|
||
return d.AI, path
|
||
}
|
||
if strings.HasPrefix(path, "/api/v1/apps:generate") || strings.Contains(path, "/apps%3Agenerate") {
|
||
return d.AI, "/api/v1/apps/generate"
|
||
}
|
||
return d.Platform, path
|
||
}
|
||
|
||
func escape(s string) string {
|
||
s = strings.ReplaceAll(s, `\`, `\\`)
|
||
s = strings.ReplaceAll(s, `"`, `\"`)
|
||
if len(s) > 200 {
|
||
s = s[:200]
|
||
}
|
||
return s
|
||
}
|
||
|
||
func newRequestID() string {
|
||
return strings.ReplaceAll(time.Now().UTC().Format("20060102T150405.000000000"), ".", "")
|
||
}
|
||
|
||
// Health 聚合探测
|
||
func (d *Director) Health(w http.ResponseWriter, _ *http.Request) {
|
||
client := &http.Client{Timeout: 2 * time.Second}
|
||
out := []struct {
|
||
Name string `json:"name"`
|
||
OK bool `json:"ok"`
|
||
}{
|
||
probe(client, "platform", d.Platform.String()+"/api/v1/auth/login"),
|
||
probe(client, "ai", d.AI.String()+"/health"),
|
||
}
|
||
allOK := true
|
||
for _, s := range out {
|
||
if !s.OK {
|
||
allOK = false
|
||
break
|
||
}
|
||
}
|
||
code := http.StatusOK
|
||
if !allOK {
|
||
code = http.StatusServiceUnavailable
|
||
}
|
||
w.Header().Set("Content-Type", "application/json")
|
||
w.WriteHeader(code)
|
||
_, _ = io.WriteString(w, `{"gateway":"ok","upstreams":[`)
|
||
for i, s := range out {
|
||
if i > 0 {
|
||
_, _ = io.WriteString(w, ",")
|
||
}
|
||
ok := "false"
|
||
if s.OK {
|
||
ok = "true"
|
||
}
|
||
_, _ = io.WriteString(w, `{"name":"`+s.Name+`","ok":`+ok+`}`)
|
||
}
|
||
_, _ = io.WriteString(w, `]}`)
|
||
}
|
||
|
||
func probe(client *http.Client, name, rawURL string) struct {
|
||
Name string `json:"name"`
|
||
OK bool `json:"ok"`
|
||
} {
|
||
req, _ := http.NewRequest(http.MethodGet, rawURL, nil)
|
||
if name == "platform" {
|
||
req, _ = http.NewRequest(http.MethodPost, rawURL, strings.NewReader(`{}`))
|
||
req.Header.Set("Content-Type", "application/json")
|
||
}
|
||
resp, err := client.Do(req)
|
||
if err != nil {
|
||
return struct {
|
||
Name string `json:"name"`
|
||
OK bool `json:"ok"`
|
||
}{Name: name, OK: false}
|
||
}
|
||
defer resp.Body.Close()
|
||
return struct {
|
||
Name string `json:"name"`
|
||
OK bool `json:"ok"`
|
||
}{Name: name, OK: true}
|
||
}
|