@@ -1,11 +1,15 @@
package proxy
import (
"context"
"errors"
"io"
"log"
"net"
"net/http"
"net/http/httputil"
"net/url"
"strconv"
"strings"
"time"
)
@@ -26,36 +30,102 @@ func New(platformURL, aiURL string, timeoutSec int) (*Director, error) {
return nil , err
}
if timeoutSec <= 0 {
timeoutSec = 6 0
timeoutSec = 18 0
}
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 {
t ransport := & http . Transport {
baseT ransport := & http . Transport {
Proxy : http . ProxyFromEnvironment ,
DialContext : ( & net . Dialer {
Timeout : 10 * time . Second ,
KeepAlive : 30 * time . Second ,
} ) . DialContext ,
MaxIdleConns : 100 ,
IdleConnTimeou t : 90 * time . Second ,
Max IdleConnsPerHos t: 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" )
rw . WriteHeader ( http . StatusBadGateway )
_ , _ = rw . Write ( [ ] byte ( ` { "code":502,"message":"upstream unavailable: ` + escape ( err . Error ( ) ) + ` "} ` ) )
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 {
// 网关已统一加 CORS; 去掉上游重复头, 避免浏览器报 Failed to fetch
for _ , h := range [ ] string {
"Access-Control-Allow-Origin" ,
"Access-Control-Allow-Credentials" ,
@@ -70,7 +140,6 @@ func (d *Director) Handler() http.HandlerFunc {
return nil
}
// 重写路径
r . URL . Path = path
r . Host = target . Host
r . Header . Set ( "X-Forwarded-Host" , r . Header . Get ( "Host" ) )
@@ -78,15 +147,46 @@ func (d *Director) Handler() http.HandlerFunc {
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 := r . Context ( )
proxy . ServeHTTP ( w , r . WithContext ( ctx ) )
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 ) {
// AI: /ai/* → 上游去掉 /ai 前缀
if strings . HasPrefix ( path , "/ai/" ) || path == "/ai" {
next := strings . TrimPrefix ( path , "/ai" )
if next == "" {
@@ -94,14 +194,12 @@ func (d *Director) route(path string) (*url.URL, string) {
}
return d . AI , next
}
// 生成蓝图:统一 /api/v1/apps/generate( 兼容旧冒号路径, 避免重复实现)
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"
}
// 其余 /api 走中台
return d . Platform , path
}
@@ -120,13 +218,11 @@ func newRequestID() string {
// Health 聚合探测
func ( d * Director ) Health ( w http . ResponseWriter , _ * http . Request ) {
type st struct {
Name string ` json:"name" `
OK bool ` json:"ok" `
Detail string ` json:"detail,omitempty" `
}
client := & http . Client { Timeout : 2 * time . Second }
out := [ ] st{
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" ) ,
}
@@ -158,29 +254,24 @@ func (d *Director) Health(w http.ResponseWriter, _ *http.Request) {
}
func probe ( client * http . Client , name , rawURL string ) struct {
Name string ` json:"name" `
OK bool ` json:"ok" `
Detail string ` json:"detail,omitempty" `
Name string ` json:"name" `
OK bool ` json:"ok" `
} {
// login 用 OPTIONS/GET 可能 405; 用短超时 HEAD/GET health 风格
req , _ := http . NewRequest ( http . MethodGet , rawURL , nil )
if name == "platform" {
// 未登录会 401/405 都说明服务活着;连接失败才算挂
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" `
Detail string ` json:"detail,omitempty" `
} { Name : name , OK : false , Detail : err . Error ( ) }
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" `
Detail string ` json:"detail,omitempty" `
Name string ` json:"name" `
OK bool ` json:"ok" `
} { Name : name , OK : true }
}