Skip to content
GitLab
Menu
Projects
Groups
Snippets
Loading...
Help
Help
Support
Community forum
Keyboard shortcuts
?
Submit feedback
Sign in / Register
Toggle navigation
Menu
Open sidebar
陈曦
sub2api
Commits
1985be26
Commit
1985be26
authored
Feb 21, 2026
by
yangjianbo
Browse files
fix(gateway): 恢复 Anthropic 透传流数据间隔超时保护并补充回归测试
parent
fdfc739b
Changes
2
Hide whitespace changes
Inline
Side-by-side
backend/internal/service/gateway_anthropic_apikey_passthrough_test.go
View file @
1985be26
...
@@ -352,7 +352,7 @@ func TestGatewayService_AnthropicAPIKeyPassthrough_StreamingStillCollectsUsageAf
...
@@ -352,7 +352,7 @@ func TestGatewayService_AnthropicAPIKeyPassthrough_StreamingStillCollectsUsageAf
},
"
\n
"
))),
},
"
\n
"
))),
}
}
result
,
err
:=
svc
.
handleStreamingResponseAnthropicAPIKeyPassthrough
(
context
.
Background
(),
resp
,
c
,
&
Account
{
ID
:
1
},
time
.
Now
())
result
,
err
:=
svc
.
handleStreamingResponseAnthropicAPIKeyPassthrough
(
context
.
Background
(),
resp
,
c
,
&
Account
{
ID
:
1
},
time
.
Now
()
,
"claude-3-7-sonnet-20250219"
)
require
.
NoError
(
t
,
err
)
require
.
NoError
(
t
,
err
)
require
.
NotNil
(
t
,
result
)
require
.
NotNil
(
t
,
result
)
require
.
NotNil
(
t
,
result
.
usage
)
require
.
NotNil
(
t
,
result
.
usage
)
...
@@ -602,12 +602,117 @@ func TestGatewayService_AnthropicAPIKeyPassthrough_StreamingErrTooLong(t *testin
...
@@ -602,12 +602,117 @@ func TestGatewayService_AnthropicAPIKeyPassthrough_StreamingErrTooLong(t *testin
Body
:
io
.
NopCloser
(
strings
.
NewReader
(
longLine
)),
Body
:
io
.
NopCloser
(
strings
.
NewReader
(
longLine
)),
}
}
result
,
err
:=
svc
.
handleStreamingResponseAnthropicAPIKeyPassthrough
(
context
.
Background
(),
resp
,
c
,
&
Account
{
ID
:
2
},
time
.
Now
())
result
,
err
:=
svc
.
handleStreamingResponseAnthropicAPIKeyPassthrough
(
context
.
Background
(),
resp
,
c
,
&
Account
{
ID
:
2
},
time
.
Now
()
,
"claude-3-7-sonnet-20250219"
)
require
.
Error
(
t
,
err
)
require
.
Error
(
t
,
err
)
require
.
ErrorIs
(
t
,
err
,
bufio
.
ErrTooLong
)
require
.
ErrorIs
(
t
,
err
,
bufio
.
ErrTooLong
)
require
.
NotNil
(
t
,
result
)
require
.
NotNil
(
t
,
result
)
}
}
func
TestGatewayService_AnthropicAPIKeyPassthrough_StreamingDataIntervalTimeout
(
t
*
testing
.
T
)
{
gin
.
SetMode
(
gin
.
TestMode
)
rec
:=
httptest
.
NewRecorder
()
c
,
_
:=
gin
.
CreateTestContext
(
rec
)
c
.
Request
=
httptest
.
NewRequest
(
http
.
MethodPost
,
"/v1/messages"
,
nil
)
svc
:=
&
GatewayService
{
cfg
:
&
config
.
Config
{
Gateway
:
config
.
GatewayConfig
{
StreamDataIntervalTimeout
:
1
,
MaxLineSize
:
defaultMaxLineSize
,
},
},
rateLimitService
:
&
RateLimitService
{},
}
pr
,
pw
:=
io
.
Pipe
()
resp
:=
&
http
.
Response
{
StatusCode
:
http
.
StatusOK
,
Header
:
http
.
Header
{
"Content-Type"
:
[]
string
{
"text/event-stream"
}},
Body
:
pr
,
}
result
,
err
:=
svc
.
handleStreamingResponseAnthropicAPIKeyPassthrough
(
context
.
Background
(),
resp
,
c
,
&
Account
{
ID
:
5
},
time
.
Now
(),
"claude-3-7-sonnet-20250219"
)
_
=
pw
.
Close
()
_
=
pr
.
Close
()
require
.
Error
(
t
,
err
)
require
.
Contains
(
t
,
err
.
Error
(),
"stream data interval timeout"
)
require
.
NotNil
(
t
,
result
)
require
.
False
(
t
,
result
.
clientDisconnect
)
}
func
TestGatewayService_AnthropicAPIKeyPassthrough_StreamingReadError
(
t
*
testing
.
T
)
{
gin
.
SetMode
(
gin
.
TestMode
)
rec
:=
httptest
.
NewRecorder
()
c
,
_
:=
gin
.
CreateTestContext
(
rec
)
c
.
Request
=
httptest
.
NewRequest
(
http
.
MethodPost
,
"/v1/messages"
,
nil
)
svc
:=
&
GatewayService
{
cfg
:
&
config
.
Config
{
Gateway
:
config
.
GatewayConfig
{
MaxLineSize
:
defaultMaxLineSize
,
},
},
}
resp
:=
&
http
.
Response
{
StatusCode
:
http
.
StatusOK
,
Header
:
http
.
Header
{
"Content-Type"
:
[]
string
{
"text/event-stream"
}},
Body
:
&
streamReadCloser
{
err
:
io
.
ErrUnexpectedEOF
,
},
}
result
,
err
:=
svc
.
handleStreamingResponseAnthropicAPIKeyPassthrough
(
context
.
Background
(),
resp
,
c
,
&
Account
{
ID
:
6
},
time
.
Now
(),
"claude-3-7-sonnet-20250219"
)
require
.
Error
(
t
,
err
)
require
.
Contains
(
t
,
err
.
Error
(),
"stream read error"
)
require
.
NotNil
(
t
,
result
)
require
.
False
(
t
,
result
.
clientDisconnect
)
}
func
TestGatewayService_AnthropicAPIKeyPassthrough_StreamingTimeoutAfterClientDisconnect
(
t
*
testing
.
T
)
{
gin
.
SetMode
(
gin
.
TestMode
)
rec
:=
httptest
.
NewRecorder
()
c
,
_
:=
gin
.
CreateTestContext
(
rec
)
c
.
Request
=
httptest
.
NewRequest
(
http
.
MethodPost
,
"/v1/messages"
,
nil
)
c
.
Writer
=
&
failWriteResponseWriter
{
ResponseWriter
:
c
.
Writer
}
svc
:=
&
GatewayService
{
cfg
:
&
config
.
Config
{
Gateway
:
config
.
GatewayConfig
{
StreamDataIntervalTimeout
:
1
,
MaxLineSize
:
defaultMaxLineSize
,
},
},
rateLimitService
:
&
RateLimitService
{},
}
pr
,
pw
:=
io
.
Pipe
()
resp
:=
&
http
.
Response
{
StatusCode
:
http
.
StatusOK
,
Header
:
http
.
Header
{
"Content-Type"
:
[]
string
{
"text/event-stream"
}},
Body
:
pr
,
}
done
:=
make
(
chan
struct
{})
go
func
()
{
defer
close
(
done
)
_
,
_
=
pw
.
Write
([]
byte
(
`data: {"type":"message_start","message":{"usage":{"input_tokens":9}}}`
+
"
\n
"
))
// 保持上游连接静默,触发数据间隔超时分支。
time
.
Sleep
(
1500
*
time
.
Millisecond
)
_
=
pw
.
Close
()
}()
result
,
err
:=
svc
.
handleStreamingResponseAnthropicAPIKeyPassthrough
(
context
.
Background
(),
resp
,
c
,
&
Account
{
ID
:
7
},
time
.
Now
(),
"claude-3-7-sonnet-20250219"
)
_
=
pr
.
Close
()
<-
done
require
.
NoError
(
t
,
err
)
require
.
NotNil
(
t
,
result
)
require
.
True
(
t
,
result
.
clientDisconnect
)
require
.
Equal
(
t
,
9
,
result
.
usage
.
InputTokens
)
}
func
TestGatewayService_AnthropicAPIKeyPassthrough_StreamingContextCanceled
(
t
*
testing
.
T
)
{
func
TestGatewayService_AnthropicAPIKeyPassthrough_StreamingContextCanceled
(
t
*
testing
.
T
)
{
gin
.
SetMode
(
gin
.
TestMode
)
gin
.
SetMode
(
gin
.
TestMode
)
rec
:=
httptest
.
NewRecorder
()
rec
:=
httptest
.
NewRecorder
()
...
@@ -630,7 +735,7 @@ func TestGatewayService_AnthropicAPIKeyPassthrough_StreamingContextCanceled(t *t
...
@@ -630,7 +735,7 @@ func TestGatewayService_AnthropicAPIKeyPassthrough_StreamingContextCanceled(t *t
},
},
}
}
result
,
err
:=
svc
.
handleStreamingResponseAnthropicAPIKeyPassthrough
(
context
.
Background
(),
resp
,
c
,
&
Account
{
ID
:
3
},
time
.
Now
())
result
,
err
:=
svc
.
handleStreamingResponseAnthropicAPIKeyPassthrough
(
context
.
Background
(),
resp
,
c
,
&
Account
{
ID
:
3
},
time
.
Now
()
,
"claude-3-7-sonnet-20250219"
)
require
.
NoError
(
t
,
err
)
require
.
NoError
(
t
,
err
)
require
.
NotNil
(
t
,
result
)
require
.
NotNil
(
t
,
result
)
require
.
True
(
t
,
result
.
clientDisconnect
)
require
.
True
(
t
,
result
.
clientDisconnect
)
...
@@ -660,7 +765,7 @@ func TestGatewayService_AnthropicAPIKeyPassthrough_StreamingUpstreamReadErrorAft
...
@@ -660,7 +765,7 @@ func TestGatewayService_AnthropicAPIKeyPassthrough_StreamingUpstreamReadErrorAft
},
},
}
}
result
,
err
:=
svc
.
handleStreamingResponseAnthropicAPIKeyPassthrough
(
context
.
Background
(),
resp
,
c
,
&
Account
{
ID
:
4
},
time
.
Now
())
result
,
err
:=
svc
.
handleStreamingResponseAnthropicAPIKeyPassthrough
(
context
.
Background
(),
resp
,
c
,
&
Account
{
ID
:
4
},
time
.
Now
()
,
"claude-3-7-sonnet-20250219"
)
require
.
NoError
(
t
,
err
)
require
.
NoError
(
t
,
err
)
require
.
NotNil
(
t
,
result
)
require
.
NotNil
(
t
,
result
)
require
.
True
(
t
,
result
.
clientDisconnect
)
require
.
True
(
t
,
result
.
clientDisconnect
)
...
...
backend/internal/service/gateway_service.go
View file @
1985be26
...
@@ -3679,7 +3679,7 @@ func (s *GatewayService) forwardAnthropicAPIKeyPassthrough(
...
@@ -3679,7 +3679,7 @@ func (s *GatewayService) forwardAnthropicAPIKeyPassthrough(
var
firstTokenMs
*
int
var
firstTokenMs
*
int
var
clientDisconnect
bool
var
clientDisconnect
bool
if
reqStream
{
if
reqStream
{
streamResult
,
err
:=
s
.
handleStreamingResponseAnthropicAPIKeyPassthrough
(
ctx
,
resp
,
c
,
account
,
startTime
)
streamResult
,
err
:=
s
.
handleStreamingResponseAnthropicAPIKeyPassthrough
(
ctx
,
resp
,
c
,
account
,
startTime
,
reqModel
)
if
err
!=
nil
{
if
err
!=
nil
{
return
nil
,
err
return
nil
,
err
}
}
...
@@ -3764,6 +3764,7 @@ func (s *GatewayService) handleStreamingResponseAnthropicAPIKeyPassthrough(
...
@@ -3764,6 +3764,7 @@ func (s *GatewayService) handleStreamingResponseAnthropicAPIKeyPassthrough(
c
*
gin
.
Context
,
c
*
gin
.
Context
,
account
*
Account
,
account
*
Account
,
startTime
time
.
Time
,
startTime
time
.
Time
,
model
string
,
)
(
*
streamingResult
,
error
)
{
)
(
*
streamingResult
,
error
)
{
if
s
.
rateLimitService
!=
nil
{
if
s
.
rateLimitService
!=
nil
{
s
.
rateLimitService
.
UpdateSessionWindow
(
ctx
,
account
,
resp
.
Header
)
s
.
rateLimitService
.
UpdateSessionWindow
(
ctx
,
account
,
resp
.
Header
)
...
@@ -3804,55 +3805,118 @@ func (s *GatewayService) handleStreamingResponseAnthropicAPIKeyPassthrough(
...
@@ -3804,55 +3805,118 @@ func (s *GatewayService) handleStreamingResponseAnthropicAPIKeyPassthrough(
}
}
scanBuf
:=
getSSEScannerBuf64K
()
scanBuf
:=
getSSEScannerBuf64K
()
scanner
.
Buffer
(
scanBuf
[
:
0
],
maxLineSize
)
scanner
.
Buffer
(
scanBuf
[
:
0
],
maxLineSize
)
defer
putSSEScannerBuf64K
(
scanBuf
)
for
scanner
.
Scan
()
{
type
scanEvent
struct
{
line
:=
scanner
.
Text
()
line
string
if
data
,
ok
:=
extractAnthropicSSEDataLine
(
line
);
ok
{
err
error
trimmed
:=
strings
.
TrimSpace
(
data
)
}
if
firstTokenMs
==
nil
&&
trimmed
!=
""
&&
trimmed
!=
"[DONE]"
{
events
:=
make
(
chan
scanEvent
,
16
)
ms
:=
int
(
time
.
Since
(
startTime
)
.
Milliseconds
())
done
:=
make
(
chan
struct
{})
firstTokenMs
=
&
ms
sendEvent
:=
func
(
ev
scanEvent
)
bool
{
}
select
{
s
.
parseSSEUsagePassthrough
(
data
,
usage
)
case
events
<-
ev
:
return
true
case
<-
done
:
return
false
}
}
}
if
!
clientDisconnected
{
var
lastReadAt
int64
if
_
,
err
:=
io
.
WriteString
(
w
,
line
);
err
!=
nil
{
atomic
.
StoreInt64
(
&
lastReadAt
,
time
.
Now
()
.
UnixNano
())
clientDisconnected
=
true
go
func
(
scanBuf
*
sseScannerBuf64K
)
{
logger
.
LegacyPrintf
(
"service.gateway"
,
"[Anthropic passthrough] Client disconnected during streaming, continue draining upstream for usage: account=%d"
,
account
.
ID
)
defer
putSSEScannerBuf64K
(
scanBuf
)
}
else
if
_
,
err
:=
io
.
WriteString
(
w
,
"
\n
"
);
err
!=
nil
{
defer
close
(
events
)
clientDisconnected
=
true
for
scanner
.
Scan
()
{
logger
.
LegacyPrintf
(
"service.gateway"
,
"[Anthropic passthrough] Client disconnected during streaming, continue draining upstream for usage: account=%d"
,
account
.
ID
)
atomic
.
StoreInt64
(
&
lastReadAt
,
time
.
Now
()
.
UnixNano
())
}
else
if
line
==
""
{
if
!
sendEvent
(
scanEvent
{
line
:
scanner
.
Text
()})
{
// 按 SSE 事件边界刷出,减少每行 flush 带来的 syscall 开销。
return
flusher
.
Flush
()
}
}
}
}
if
err
:=
scanner
.
Err
();
err
!=
nil
{
_
=
sendEvent
(
scanEvent
{
err
:
err
})
}
}(
scanBuf
)
defer
close
(
done
)
streamInterval
:=
time
.
Duration
(
0
)
if
s
.
cfg
!=
nil
&&
s
.
cfg
.
Gateway
.
StreamDataIntervalTimeout
>
0
{
streamInterval
=
time
.
Duration
(
s
.
cfg
.
Gateway
.
StreamDataIntervalTimeout
)
*
time
.
Second
}
}
if
!
clientDisconnected
{
var
intervalTicker
*
time
.
Ticker
// 兜底补刷,确保最后一个未以空行结尾的事件也能及时送达客户端。
if
streamInterval
>
0
{
flusher
.
Flush
()
intervalTicker
=
time
.
NewTicker
(
streamInterval
)
defer
intervalTicker
.
Stop
()
}
var
intervalCh
<-
chan
time
.
Time
if
intervalTicker
!=
nil
{
intervalCh
=
intervalTicker
.
C
}
}
if
err
:=
scanner
.
Err
();
err
!=
nil
{
for
{
if
clientDisconnected
{
select
{
logger
.
LegacyPrintf
(
"service.gateway"
,
"[Anthropic passthrough] Upstream read error after client disconnect: account=%d err=%v"
,
account
.
ID
,
err
)
case
ev
,
ok
:=
<-
events
:
return
&
streamingResult
{
usage
:
usage
,
firstTokenMs
:
firstTokenMs
,
clientDisconnect
:
true
},
nil
if
!
ok
{
}
if
!
clientDisconnected
{
if
errors
.
Is
(
err
,
context
.
Canceled
)
||
errors
.
Is
(
err
,
context
.
DeadlineExceeded
)
{
// 兜底补刷,确保最后一个未以空行结尾的事件也能及时送达客户端。
logger
.
LegacyPrintf
(
"service.gateway"
,
"[Anthropic passthrough] 流读取被取消: account=%d request_id=%s err=%v ctx_err=%v"
,
flusher
.
Flush
()
account
.
ID
,
resp
.
Header
.
Get
(
"x-request-id"
),
err
,
ctx
.
Err
())
}
return
&
streamingResult
{
usage
:
usage
,
firstTokenMs
:
firstTokenMs
,
clientDisconnect
:
true
},
nil
return
&
streamingResult
{
usage
:
usage
,
firstTokenMs
:
firstTokenMs
,
clientDisconnect
:
clientDisconnected
},
nil
}
}
if
errors
.
Is
(
err
,
bufio
.
ErrTooLong
)
{
if
ev
.
err
!=
nil
{
logger
.
LegacyPrintf
(
"service.gateway"
,
"[Anthropic passthrough] SSE line too long: account=%d max_size=%d error=%v"
,
account
.
ID
,
maxLineSize
,
err
)
if
clientDisconnected
{
return
&
streamingResult
{
usage
:
usage
,
firstTokenMs
:
firstTokenMs
},
err
logger
.
LegacyPrintf
(
"service.gateway"
,
"[Anthropic passthrough] Upstream read error after client disconnect: account=%d err=%v"
,
account
.
ID
,
ev
.
err
)
return
&
streamingResult
{
usage
:
usage
,
firstTokenMs
:
firstTokenMs
,
clientDisconnect
:
true
},
nil
}
if
errors
.
Is
(
ev
.
err
,
context
.
Canceled
)
||
errors
.
Is
(
ev
.
err
,
context
.
DeadlineExceeded
)
{
logger
.
LegacyPrintf
(
"service.gateway"
,
"[Anthropic passthrough] 流读取被取消: account=%d request_id=%s err=%v ctx_err=%v"
,
account
.
ID
,
resp
.
Header
.
Get
(
"x-request-id"
),
ev
.
err
,
ctx
.
Err
())
return
&
streamingResult
{
usage
:
usage
,
firstTokenMs
:
firstTokenMs
,
clientDisconnect
:
true
},
nil
}
if
errors
.
Is
(
ev
.
err
,
bufio
.
ErrTooLong
)
{
logger
.
LegacyPrintf
(
"service.gateway"
,
"[Anthropic passthrough] SSE line too long: account=%d max_size=%d error=%v"
,
account
.
ID
,
maxLineSize
,
ev
.
err
)
return
&
streamingResult
{
usage
:
usage
,
firstTokenMs
:
firstTokenMs
},
ev
.
err
}
return
&
streamingResult
{
usage
:
usage
,
firstTokenMs
:
firstTokenMs
},
fmt
.
Errorf
(
"stream read error: %w"
,
ev
.
err
)
}
line
:=
ev
.
line
if
data
,
ok
:=
extractAnthropicSSEDataLine
(
line
);
ok
{
trimmed
:=
strings
.
TrimSpace
(
data
)
if
firstTokenMs
==
nil
&&
trimmed
!=
""
&&
trimmed
!=
"[DONE]"
{
ms
:=
int
(
time
.
Since
(
startTime
)
.
Milliseconds
())
firstTokenMs
=
&
ms
}
s
.
parseSSEUsagePassthrough
(
data
,
usage
)
}
if
!
clientDisconnected
{
if
_
,
err
:=
io
.
WriteString
(
w
,
line
);
err
!=
nil
{
clientDisconnected
=
true
logger
.
LegacyPrintf
(
"service.gateway"
,
"[Anthropic passthrough] Client disconnected during streaming, continue draining upstream for usage: account=%d"
,
account
.
ID
)
}
else
if
_
,
err
:=
io
.
WriteString
(
w
,
"
\n
"
);
err
!=
nil
{
clientDisconnected
=
true
logger
.
LegacyPrintf
(
"service.gateway"
,
"[Anthropic passthrough] Client disconnected during streaming, continue draining upstream for usage: account=%d"
,
account
.
ID
)
}
else
if
line
==
""
{
// 按 SSE 事件边界刷出,减少每行 flush 带来的 syscall 开销。
flusher
.
Flush
()
}
}
case
<-
intervalCh
:
lastRead
:=
time
.
Unix
(
0
,
atomic
.
LoadInt64
(
&
lastReadAt
))
if
time
.
Since
(
lastRead
)
<
streamInterval
{
continue
}
if
clientDisconnected
{
logger
.
LegacyPrintf
(
"service.gateway"
,
"[Anthropic passthrough] Upstream timeout after client disconnect: account=%d model=%s"
,
account
.
ID
,
model
)
return
&
streamingResult
{
usage
:
usage
,
firstTokenMs
:
firstTokenMs
,
clientDisconnect
:
true
},
nil
}
logger
.
LegacyPrintf
(
"service.gateway"
,
"[Anthropic passthrough] Stream data interval timeout: account=%d model=%s interval=%s"
,
account
.
ID
,
model
,
streamInterval
)
if
s
.
rateLimitService
!=
nil
{
s
.
rateLimitService
.
HandleStreamTimeout
(
ctx
,
account
,
model
)
}
return
&
streamingResult
{
usage
:
usage
,
firstTokenMs
:
firstTokenMs
},
fmt
.
Errorf
(
"stream data interval timeout"
)
}
}
return
&
streamingResult
{
usage
:
usage
,
firstTokenMs
:
firstTokenMs
},
fmt
.
Errorf
(
"stream read error: %w"
,
err
)
}
}
return
&
streamingResult
{
usage
:
usage
,
firstTokenMs
:
firstTokenMs
,
clientDisconnect
:
clientDisconnected
},
nil
}
}
func
extractAnthropicSSEDataLine
(
line
string
)
(
string
,
bool
)
{
func
extractAnthropicSSEDataLine
(
line
string
)
(
string
,
bool
)
{
...
...
Write
Preview
Markdown
is supported
0%
Try again
or
attach a new file
.
Attach a file
Cancel
You are about to add
0
people
to the discussion. Proceed with caution.
Finish editing this message first!
Cancel
Please
register
or
sign in
to comment