Skip to content

Commit a2a4862

Browse files
committed
fix(chat): 修复 SSE 流异常处理导致的二次错误
1 parent 020e5c3 commit a2a4862

6 files changed

Lines changed: 184 additions & 33 deletions

File tree

framework/src/main/java/com/nageoffer/ai/ragent/framework/web/SseEmitterSender.java

Lines changed: 4 additions & 32 deletions
Original file line numberDiff line numberDiff line change
@@ -20,6 +20,7 @@
2020
import lombok.extern.slf4j.Slf4j;
2121
import org.springframework.web.servlet.mvc.method.annotation.SseEmitter;
2222

23+
import java.io.IOException;
2324
import java.util.concurrent.atomic.AtomicBoolean;
2425

2526
/**
@@ -74,8 +75,9 @@ public void sendEvent(String eventName, Object data) {
7475
return;
7576
}
7677
emitter.send(SseEmitter.event().name(eventName).data(data));
77-
} catch (Exception e) {
78-
fail(e);
78+
} catch (IOException e) {
79+
closed.set(true);
80+
log.warn("SSE connection closed while sending event", e);
7981
}
8082
}
8183

@@ -92,34 +94,4 @@ public void complete() {
9294
}
9395
}
9496

95-
/**
96-
* 异常结束并关闭 SSE 连接
97-
*
98-
* <p>当发生异常时调用此方法,会执行以下操作:</p>
99-
* <ol>
100-
* <li>关闭 SSE 连接并通知客户端异常信息</li>
101-
* <li>不再抛出异常,避免在流式响应已开始后触发全局异常处理器导致响应冲突</li>
102-
* </ol>
103-
*
104-
* @param throwable 导致失败的异常对象
105-
*/
106-
public void fail(Throwable throwable) {
107-
closeWithError(throwable);
108-
log.warn("SSE send failed", throwable);
109-
}
110-
111-
/**
112-
* 内部方法:以异常方式关闭连接
113-
* <p>
114-
* 使用 CAS 操作确保连接只被关闭一次
115-
* 调用 SseEmitter 的 completeWithError 方法,通知客户端连接异常终止
116-
*
117-
* @param throwable 导致连接关闭的异常对象
118-
*/
119-
private void closeWithError(Throwable throwable) {
120-
// 使用 CAS 原子操作,确保只关闭一次
121-
if (closed.compareAndSet(false, true)) {
122-
emitter.completeWithError(throwable);
123-
}
124-
}
12597
}
Lines changed: 47 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,47 @@
1+
/*
2+
* Licensed to the Apache Software Foundation (ASF) under one or more
3+
* contributor license agreements. See the NOTICE file distributed with
4+
* this work for additional information regarding copyright ownership.
5+
* The ASF licenses this file to You under the Apache License, Version 2.0
6+
* (the "License"); you may not use this file except in compliance with
7+
* the License. You may obtain a copy of the License at
8+
*
9+
* http://www.apache.org/licenses/LICENSE-2.0
10+
*
11+
* Unless required by applicable law or agreed to in writing, software
12+
* distributed under the License is distributed on an "AS IS" BASIS,
13+
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
14+
* See the License for the specific language governing permissions and
15+
* limitations under the License.
16+
*/
17+
18+
package com.nageoffer.ai.ragent.framework.web;
19+
20+
import org.junit.jupiter.api.Test;
21+
import org.springframework.web.servlet.mvc.method.annotation.SseEmitter;
22+
23+
import java.io.IOException;
24+
25+
import static org.mockito.ArgumentMatchers.any;
26+
import static org.mockito.Mockito.doThrow;
27+
import static org.mockito.Mockito.mock;
28+
import static org.mockito.Mockito.never;
29+
import static org.mockito.Mockito.verify;
30+
31+
class SseEmitterSenderTest {
32+
33+
@Test
34+
void sendIOExceptionShouldNotTriggerAsyncErrorDispatch() throws Exception {
35+
SseEmitter emitter = mock(SseEmitter.class);
36+
doThrow(new IOException("client disconnected"))
37+
.when(emitter).send(any(SseEmitter.SseEventBuilder.class));
38+
SseEmitterSender sender = new SseEmitterSender(emitter);
39+
40+
sender.sendEvent("message", "content");
41+
sender.sendEvent("message", "ignored");
42+
43+
verify(emitter).send(any(SseEmitter.SseEventBuilder.class));
44+
verify(emitter, never()).complete();
45+
verify(emitter, never()).completeWithError(any());
46+
}
47+
}
Lines changed: 26 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,26 @@
1+
/*
2+
* Licensed to the Apache Software Foundation (ASF) under one or more
3+
* contributor license agreements. See the NOTICE file distributed with
4+
* this work for additional information regarding copyright ownership.
5+
* The ASF licenses this file to You under the Apache License, Version 2.0
6+
* (the "License"); you may not use this file except in compliance with
7+
* the License. You may obtain a copy of the License at
8+
*
9+
* http://www.apache.org/licenses/LICENSE-2.0
10+
*
11+
* Unless required by applicable law or agreed to in writing, software
12+
* distributed under the License is distributed on an "AS IS" BASIS,
13+
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
14+
* See the License for the specific language governing permissions and
15+
* limitations under the License.
16+
*/
17+
18+
package com.nageoffer.ai.ragent.rag.dto;
19+
20+
/**
21+
* 流式对话错误事件载荷
22+
*
23+
* @param error 面向用户的错误提示
24+
*/
25+
public record StreamErrorPayload(String error) {
26+
}

rag/src/main/java/com/nageoffer/ai/ragent/rag/enums/SSEEventType.java

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -50,6 +50,11 @@ public enum SSEEventType {
5050
*/
5151
CANCEL("cancel"),
5252

53+
/**
54+
* 错误事件
55+
*/
56+
ERROR("error"),
57+
5358
/**
5459
* 拒绝事件
5560
*/

rag/src/main/java/com/nageoffer/ai/ragent/rag/service/handler/StreamChatEventHandler.java

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -23,6 +23,7 @@
2323
import com.nageoffer.ai.ragent.rag.dto.CompletionPayload;
2424
import com.nageoffer.ai.ragent.rag.dto.MessageDelta;
2525
import com.nageoffer.ai.ragent.rag.dto.MetaPayload;
26+
import com.nageoffer.ai.ragent.rag.dto.StreamErrorPayload;
2627
import com.nageoffer.ai.ragent.rag.enums.SSEEventType;
2728
import com.nageoffer.ai.ragent.framework.context.UserContext;
2829
import com.nageoffer.ai.ragent.framework.convention.ChatMessage;
@@ -44,6 +45,7 @@ public class StreamChatEventHandler implements StreamCallback {
4445

4546
private static final String TYPE_THINK = "think";
4647
private static final String TYPE_RESPONSE = "response";
48+
private static final String ERROR_MESSAGE = "生成失败,请稍后重试";
4749

4850
private final int messageChunkSize;
4951
private final SseEmitterSender sender;
@@ -237,8 +239,10 @@ public void onError(Throwable t) {
237239
if (taskManager.isCancelled(taskId)) {
238240
return;
239241
}
242+
log.error("流式对话失败,conversationId:{},taskId:{}", conversationId, taskId, t);
240243
taskManager.unregister(taskId);
241-
sender.fail(t);
244+
sender.sendEvent(SSEEventType.ERROR.value(), new StreamErrorPayload(ERROR_MESSAGE));
245+
sender.complete();
242246
}
243247

244248
private void sendChunked(String type, String content) {
Lines changed: 97 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,97 @@
1+
/*
2+
* Licensed to the Apache Software Foundation (ASF) under one or more
3+
* contributor license agreements. See the NOTICE file distributed with
4+
* this work for additional information regarding copyright ownership.
5+
* The ASF licenses this file to You under the Apache License, Version 2.0
6+
* (the "License"); you may not use this file except in compliance with
7+
* the License. You may obtain a copy of the License at
8+
*
9+
* http://www.apache.org/licenses/LICENSE-2.0
10+
*
11+
* Unless required by applicable law or agreed to in writing, software
12+
* distributed under the License is distributed on an "AS IS" BASIS,
13+
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
14+
* See the License for the specific language governing permissions and
15+
* limitations under the License.
16+
*/
17+
18+
package com.nageoffer.ai.ragent.rag.service.handler;
19+
20+
import com.nageoffer.ai.ragent.framework.web.StreamTaskManager;
21+
import com.nageoffer.ai.ragent.infra.config.AIModelProperties;
22+
import com.nageoffer.ai.ragent.rag.core.memory.ConversationMemoryService;
23+
import com.nageoffer.ai.ragent.rag.service.ConversationGroupService;
24+
import org.junit.jupiter.api.Test;
25+
import org.junit.jupiter.api.extension.ExtendWith;
26+
import org.mockito.ArgumentCaptor;
27+
import org.mockito.Mock;
28+
import org.mockito.junit.jupiter.MockitoExtension;
29+
import org.springframework.test.util.ReflectionTestUtils;
30+
import org.springframework.web.servlet.mvc.method.annotation.ResponseBodyEmitter;
31+
import org.springframework.web.servlet.mvc.method.annotation.SseEmitter;
32+
33+
import java.util.List;
34+
35+
import static org.junit.jupiter.api.Assertions.assertEquals;
36+
import static org.junit.jupiter.api.Assertions.assertFalse;
37+
import static org.junit.jupiter.api.Assertions.assertTrue;
38+
import static org.mockito.ArgumentMatchers.any;
39+
import static org.mockito.Mockito.never;
40+
import static org.mockito.Mockito.times;
41+
import static org.mockito.Mockito.verify;
42+
43+
@ExtendWith(MockitoExtension.class)
44+
class StreamChatEventHandlerTest {
45+
46+
private static final String PUBLIC_ERROR_MESSAGE = "生成失败,请稍后重试";
47+
48+
@Mock
49+
private SseEmitter emitter;
50+
51+
@Mock
52+
private AIModelProperties modelProperties;
53+
54+
@Mock
55+
private ConversationMemoryService memoryService;
56+
57+
@Mock
58+
private ConversationGroupService conversationGroupService;
59+
60+
@Mock
61+
private StreamTaskManager taskManager;
62+
63+
@Test
64+
void businessFailureShouldSendSafeErrorEventAndCompleteNormally() throws Exception {
65+
StreamChatEventHandler handler = new StreamChatEventHandler(StreamChatHandlerParams.builder()
66+
.emitter(emitter)
67+
.conversationId("conversation-1")
68+
.taskId("task-1")
69+
.modelProperties(modelProperties)
70+
.memoryService(memoryService)
71+
.conversationGroupService(conversationGroupService)
72+
.taskManager(taskManager)
73+
.build());
74+
75+
handler.onError(new IllegalStateException("database password leaked"));
76+
77+
ArgumentCaptor<SseEmitter.SseEventBuilder> eventCaptor =
78+
ArgumentCaptor.forClass(SseEmitter.SseEventBuilder.class);
79+
verify(emitter, times(2)).send(eventCaptor.capture());
80+
List<Object> errorEventData = eventCaptor.getAllValues().get(1).build().stream()
81+
.map(ResponseBodyEmitter.DataWithMediaType::getData)
82+
.toList();
83+
assertTrue(errorEventData.stream()
84+
.filter(String.class::isInstance)
85+
.map(String.class::cast)
86+
.anyMatch(data -> data.startsWith("event:error\n")));
87+
Object payload = errorEventData.stream()
88+
.filter(data -> !(data instanceof String))
89+
.findFirst()
90+
.orElseThrow();
91+
assertEquals(PUBLIC_ERROR_MESSAGE, ReflectionTestUtils.getField(payload, "error"));
92+
assertFalse(payload.toString().contains("database password leaked"));
93+
verify(taskManager).unregister("task-1");
94+
verify(emitter).complete();
95+
verify(emitter, never()).completeWithError(any());
96+
}
97+
}

0 commit comments

Comments
 (0)