fix: handle Bedrock 503 streaming errors with timeout detection (#92)

- Add 15s streaming timeout to detect mid-stream stalls (e.g., Bedrock 503)
- Add stop() call to allow user retry after timeout
- Add streamingError state for timeout-detected errors
- Improve server-side error logging for empty usage detection
- Add user-friendly error messages for ServiceUnavailable and Throttling errors
This commit is contained in:
Dayuan Jiang
2025-12-05 14:23:47 +09:00
committed by GitHub
parent b206e16c02
commit 95160f5a21
2 changed files with 105 additions and 10 deletions

View File

@@ -154,9 +154,28 @@ ${lastMessageText}
messages: allMessages, messages: allMessages,
...(providerOptions && { providerOptions }), ...(providerOptions && { providerOptions }),
...(headers && { headers }), ...(headers && { headers }),
onFinish: ({ usage, providerMetadata }) => { onFinish: ({ usage, providerMetadata, finishReason, text, toolCalls }) => {
// Detect potential mid-stream failures (e.g., Bedrock 503 ServiceUnavailableException)
// When this happens, usage is empty and providerMetadata is undefined
const hasUsage = usage && Object.keys(usage).length > 0;
if (!hasUsage) {
console.error('[Stream Error] Empty usage detected - possible Bedrock 503 or mid-stream failure');
console.error('[Stream Error] finishReason:', finishReason);
console.error('[Stream Error] text received:', text?.substring(0, 200) || '(none)');
console.error('[Stream Error] toolCalls:', toolCalls?.length || 0);
// Log the user's last message for debugging
const lastUserMsg = enhancedMessages.filter(m => m.role === 'user').pop();
if (lastUserMsg) {
const content = lastUserMsg.content;
const preview = Array.isArray(content)
? (content.find((c) => c.type === 'text') as { type: 'text'; text: string } | undefined)?.text?.substring(0, 100)
: String(content).substring(0, 100);
console.error('[Stream Error] Last user message preview:', preview);
}
} else {
console.log('[Cache] Full providerMetadata:', JSON.stringify(providerMetadata, null, 2)); console.log('[Cache] Full providerMetadata:', JSON.stringify(providerMetadata, null, 2));
console.log('[Cache] Usage:', JSON.stringify(usage, null, 2)); console.log('[Cache] Usage:', JSON.stringify(usage, null, 2));
}
}, },
tools: { tools: {
// Client-side tool that will be executed on the client // Client-side tool that will be executed on the client
@@ -232,6 +251,23 @@ IMPORTANT: Keep edits concise:
? error.message ? error.message
: JSON.stringify(error); : JSON.stringify(error);
// Check for Bedrock service errors (503, throttling, etc.)
if (errorString.includes('ServiceUnavailable') ||
errorString.includes('503') ||
errorString.includes('temporarily unavailable')) {
console.error('[Bedrock Error] ServiceUnavailableException:', errorString);
return 'The AI service is temporarily unavailable. Please try again in a few seconds.';
}
// Check for throttling errors
if (errorString.includes('ThrottlingException') ||
errorString.includes('rate limit') ||
errorString.includes('too many requests') ||
errorString.includes('429')) {
console.error('[Bedrock Error] ThrottlingException:', errorString);
return 'Too many requests. Please wait a moment and try again.';
}
// Check for image not supported error (e.g., DeepSeek models) // Check for image not supported error (e.g., DeepSeek models)
if (errorString.includes('image_url') || if (errorString.includes('image_url') ||
errorString.includes('unknown variant') || errorString.includes('unknown variant') ||

View File

@@ -61,6 +61,7 @@ export default function ChatPanel({
const [files, setFiles] = useState<File[]>([]); const [files, setFiles] = useState<File[]>([]);
const [showHistory, setShowHistory] = useState(false); const [showHistory, setShowHistory] = useState(false);
const [input, setInput] = useState(""); const [input, setInput] = useState("");
const [streamingError, setStreamingError] = useState<Error | null>(null);
// Store XML snapshots for each user message (keyed by message index) // Store XML snapshots for each user message (keyed by message index)
const xmlSnapshotsRef = useRef<Map<number, string>>(new Map()); const xmlSnapshotsRef = useRef<Map<number, string>>(new Map());
@@ -71,7 +72,7 @@ export default function ChatPanel({
chartXMLRef.current = chartXML; chartXMLRef.current = chartXML;
}, [chartXML]); }, [chartXML]);
const { messages, sendMessage, addToolOutput, status, error, setMessages } = const { messages, sendMessage, addToolResult, status, error, setMessages, stop } =
useChat({ useChat({
transport: new DefaultChatTransport({ transport: new DefaultChatTransport({
api: "/api/chat", api: "/api/chat",
@@ -83,13 +84,13 @@ export default function ChatPanel({
const validationError = validateMxCellStructure(xml); const validationError = validateMxCellStructure(xml);
if (validationError) { if (validationError) {
addToolOutput({ addToolResult({
tool: "display_diagram", tool: "display_diagram",
toolCallId: toolCall.toolCallId, toolCallId: toolCall.toolCallId,
output: validationError, output: validationError,
}); });
} else { } else {
addToolOutput({ addToolResult({
tool: "display_diagram", tool: "display_diagram",
toolCallId: toolCall.toolCallId, toolCallId: toolCall.toolCallId,
output: "Successfully displayed the diagram.", output: "Successfully displayed the diagram.",
@@ -122,7 +123,7 @@ export default function ChatPanel({
onDisplayChart(editedXml); onDisplayChart(editedXml);
addToolOutput({ addToolResult({
tool: "edit_diagram", tool: "edit_diagram",
toolCallId: toolCall.toolCallId, toolCallId: toolCall.toolCallId,
output: `Successfully applied ${edits.length} edit(s) to the diagram.`, output: `Successfully applied ${edits.length} edit(s) to the diagram.`,
@@ -136,7 +137,7 @@ export default function ChatPanel({
? error.message ? error.message
: String(error); : String(error);
addToolOutput({ addToolResult({
tool: "edit_diagram", tool: "edit_diagram",
toolCallId: toolCall.toolCallId, toolCallId: toolCall.toolCallId,
output: `Edit failed: ${errorMessage} output: `Edit failed: ${errorMessage}
@@ -153,9 +154,64 @@ Please retry with an adjusted search pattern or use display_diagram if retries a
}, },
onError: (error) => { onError: (error) => {
console.error("Chat error:", error); console.error("Chat error:", error);
setStreamingError(error);
}, },
}); });
// Streaming timeout detection - detects when stream stalls mid-response (e.g., Bedrock 503)
// This catches cases where onError doesn't fire because headers were already sent
const lastMessageCountRef = useRef(0);
const lastMessagePartsRef = useRef(0);
useEffect(() => {
// Clear streaming error and reset refs when status changes to ready
if (status === "ready") {
setStreamingError(null);
lastMessageCountRef.current = 0;
lastMessagePartsRef.current = 0;
return;
}
if (status !== "streaming") return;
const STALL_TIMEOUT_MS = 15000; // 15 seconds without any update
// Capture current state BEFORE setting timeout
// This way we compare against values at the time timeout was set
const currentPartsCount = messages.reduce(
(acc, msg) => acc + (msg.parts?.length || 0),
0
);
const capturedMessageCount = messages.length;
const capturedPartsCount = currentPartsCount;
// Update refs immediately so next effect run has fresh values
lastMessageCountRef.current = messages.length;
lastMessagePartsRef.current = currentPartsCount;
const timeoutId = setTimeout(() => {
// Re-count parts at timeout time
const newPartsCount = messages.reduce(
(acc, msg) => acc + (msg.parts?.length || 0),
0
);
// If no change since timeout was set, stream has stalled
if (
messages.length === capturedMessageCount &&
newPartsCount === capturedPartsCount
) {
console.error("[Streaming Timeout] No activity for 15s - forcing error state");
setStreamingError(
new Error("Connection lost. The AI service may be temporarily unavailable. Please try again.")
);
stop(); // Allow user to retry by transitioning status to "ready"
}
}, STALL_TIMEOUT_MS);
return () => clearTimeout(timeoutId);
}, [status, messages, stop]);
const messagesEndRef = useRef<HTMLDivElement>(null); const messagesEndRef = useRef<HTMLDivElement>(null);
useEffect(() => { useEffect(() => {
@@ -167,8 +223,11 @@ Please retry with an adjusted search pattern or use display_diagram if retries a
const onFormSubmit = async (e: React.FormEvent<HTMLFormElement>) => { const onFormSubmit = async (e: React.FormEvent<HTMLFormElement>) => {
e.preventDefault(); e.preventDefault();
const isProcessing = status === "streaming" || status === "submitted"; // Allow retry if there's a streaming error (workaround for stop() not transitioning status)
const isProcessing = (status === "streaming" || status === "submitted") && !streamingError;
if (input.trim() && !isProcessing) { if (input.trim() && !isProcessing) {
// Clear any previous streaming error before starting new request
setStreamingError(null);
try { try {
let chartXml = await onFetchChart(); let chartXml = await onFetchChart();
chartXml = formatXML(chartXml); chartXml = formatXML(chartXml);
@@ -415,7 +474,7 @@ Please retry with an adjusted search pattern or use display_diagram if retries a
<main className="flex-1 overflow-hidden"> <main className="flex-1 overflow-hidden">
<ChatMessageDisplay <ChatMessageDisplay
messages={messages} messages={messages}
error={error} error={error || streamingError}
setInput={setInput} setInput={setInput}
setFiles={handleFileChange} setFiles={handleFileChange}
onRegenerate={handleRegenerate} onRegenerate={handleRegenerate}