Skip to content

Commit 6786ac7

Browse files
authored
feat: add timeout error handling in Chat (#130)
1 parent 33668a2 commit 6786ac7

File tree

6 files changed

+231
-2
lines changed

6 files changed

+231
-2
lines changed

src/components/BotError.stories.tsx

Lines changed: 43 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,43 @@
1+
import type { Meta, StoryObj } from '@storybook/react-vite'
2+
import { BotError } from './BotError'
3+
import { AlertTriangle } from 'lucide-react'
4+
5+
const meta: Meta<typeof BotError> = {
6+
title: 'Bot/BotError',
7+
component: BotError,
8+
tags: ['autodocs'],
9+
parameters: {
10+
docs: {
11+
description: {
12+
component:
13+
'BotError displays an error message from the bot, using accessible Shadcn/ui and Tailwind styling. Use for error boundaries or bot error responses.',
14+
},
15+
},
16+
},
17+
}
18+
19+
export default meta
20+
21+
type Story = StoryObj<typeof BotError>
22+
23+
export const Default: Story = {
24+
args: {
25+
message: 'Something went wrong. Please try again.',
26+
},
27+
}
28+
29+
export const LongError: Story = {
30+
args: {
31+
message: (
32+
<>
33+
<div>
34+
<strong>Request failed:</strong> The server returned a 500 error.
35+
<br />
36+
Please check your network connection or try again later.
37+
<br />
38+
<code>Error: Internal Server Error</code>
39+
</div>
40+
</>
41+
),
42+
},
43+
}

src/components/BotError.test.tsx

Lines changed: 38 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,38 @@
1+
// @vitest-environment jsdom
2+
import {
3+
getByRole,
4+
getByTestId,
5+
getByText,
6+
render,
7+
} from '@testing-library/react'
8+
import { describe, expect, it } from 'vitest'
9+
import { BotError } from './BotError'
10+
11+
describe('BotError', () => {
12+
it('renders a simple error message', () => {
13+
const { container } = render(
14+
<BotError message="Something went wrong. Please try again." />,
15+
)
16+
17+
expect(getByText(container, 'Error')).toBeInTheDocument()
18+
expect(
19+
getByText(container, 'Something went wrong. Please try again.'),
20+
).toBeInTheDocument()
21+
})
22+
23+
it('renders a long error message with HTML', () => {
24+
const longMessage = (
25+
<div data-testid="error-message">
26+
<strong>Request failed:</strong> The server returned a 500 error.
27+
<br />
28+
Please check your network connection or try again later.
29+
<br />
30+
<code>Error: Internal Server Error</code>
31+
</div>
32+
)
33+
const { container } = render(<BotError message={longMessage} />)
34+
35+
expect(getByText(container, 'Error')).toBeInTheDocument()
36+
expect(getByTestId(container, 'error-message')).toBeInTheDocument()
37+
})
38+
})

src/components/BotError.tsx

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,9 +1,10 @@
11
import { AlertTriangle } from 'lucide-react'
22
import { CollapsibleSection } from './ui/collapsible-section'
33
import { MessageAvatar } from './MessageAvatar'
4+
import type { ReactNode } from 'react'
45

56
interface BotErrorProps {
6-
message: string
7+
message: ReactNode
78
}
89

910
export function BotError({ message }: BotErrorProps) {
@@ -15,7 +16,7 @@ export function BotError({ message }: BotErrorProps) {
1516
/>
1617
<div className="flex flex-col space-y-1 items-start w-full sm:w-[85%] md:w-[75%] lg:w-[65%]">
1718
<CollapsibleSection title="Error" variant="error" open={true}>
18-
<div>{message}</div>
19+
{message}
1920
</CollapsibleSection>
2021
</div>
2122
</div>

src/components/Chat.tsx

Lines changed: 58 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -168,6 +168,9 @@ const getEventKey = (event: StreamEvent | Message, idx: number): string => {
168168
return `message-${'id' in event ? (event as any).id : idx}`
169169
}
170170

171+
const TIMEOUT_ERROR_MESSAGE =
172+
'Your connection was interrupted after 30 seconds, possibly due to a Pomerium proxy timeout.'
173+
171174
export function Chat() {
172175
const hasMounted = useHasMounted()
173176
const messagesEndRef = useRef<HTMLDivElement>(null)
@@ -181,6 +184,8 @@ export function Chat() {
181184
const [useWebSearch, setUseWebSearch] = useState(false)
182185
const { selectedModel, setSelectedModel } = useModel()
183186
const { user } = useUser()
187+
const [requestId, setRequestId] = useState<string | null>(null)
188+
const [timedOut, setTimedOut] = useState(false)
184189

185190
const handleModelChange = (newModel: string) => {
186191
setSelectedModel(newModel)
@@ -321,10 +326,15 @@ export function Chat() {
321326
},
322327
])
323328
setStreaming(false)
329+
setTimedOut(false)
324330
}, [])
325331

326332
const handleResponse = useCallback(
327333
(response: Response) => {
334+
// Extract x-request-id header for timeout troubleshooting
335+
const xRequestId = response.headers.get('x-request-id')
336+
setRequestId(xRequestId)
337+
328338
if (!response.ok) {
329339
console.error(
330340
'Chat response error:',
@@ -339,10 +349,12 @@ export function Chat() {
339349
},
340350
])
341351
setStreaming(false)
352+
setTimedOut(false)
342353
return
343354
}
344355

345356
setStreaming(true)
357+
setTimedOut(false)
346358

347359
// Clone the response to handle our custom streaming while letting useChat handle its own
348360
const reader = response.clone().body?.getReader()
@@ -359,12 +371,14 @@ export function Chat() {
359371
},
360372
])
361373
setStreaming(false)
374+
setTimedOut(false)
362375
return
363376
}
364377

365378
const decoder = new TextDecoder()
366379
let buffer = ''
367380
let assistantId: string | null = null
381+
let receivedCompletion = false
368382

369383
const processChunk = (line: string) => {
370384
if (line.startsWith('e:')) {
@@ -407,6 +421,11 @@ export function Chat() {
407421
if (toolState.type === 'tool_call_completed') {
408422
return
409423
}
424+
// Handle stream_done event (signals end of stream)
425+
if (toolState.type === 'stream_done') {
426+
receivedCompletion = true
427+
return
428+
}
410429

411430
// Handle reasoning summary streaming
412431
if (toolState.type === 'reasoning_summary_delta') {
@@ -766,6 +785,10 @@ export function Chat() {
766785
// Flush any remaining text buffer
767786
flushTextBuffer()
768787
setStreaming(false)
788+
// If stream ended but we did not receive a completion event, treat as timeout
789+
if (!receivedCompletion) {
790+
setTimedOut(true)
791+
}
769792
return
770793
}
771794

@@ -848,6 +871,7 @@ export function Chat() {
848871

849872
const handleSendMessage = useCallback(
850873
(prompt: string) => {
874+
setTimedOut(false)
851875
if (!hasStartedChat) {
852876
setHasStartedChat(true)
853877
}
@@ -1048,6 +1072,40 @@ export function Chat() {
10481072
}
10491073
})}
10501074
{streaming && <BotThinking />}
1075+
{timedOut && (
1076+
<BotError
1077+
key="timeout-error"
1078+
message={
1079+
<div className="grid gap-2">
1080+
<p>{TIMEOUT_ERROR_MESSAGE}</p>
1081+
<p>
1082+
See the Pomerium{' '}
1083+
<a
1084+
href="https://www.pomerium.com/docs/reference/routes/timeouts"
1085+
target="_blank"
1086+
rel="noopener noreferrer"
1087+
className="underline"
1088+
>
1089+
Timeouts Settings documentation
1090+
</a>{' '}
1091+
for more information. .
1092+
</p>
1093+
{requestId && (
1094+
<>
1095+
<hr />
1096+
<dl>
1097+
<dt>Request ID</dt>
1098+
<dd>{requestId}</dd>
1099+
</dl>
1100+
<p>
1101+
Use this ID to search Pomerium logs for more details.
1102+
</p>
1103+
</>
1104+
)}
1105+
</div>
1106+
}
1107+
/>
1108+
)}
10511109
<div ref={messagesEndRef} />
10521110
</div>
10531111
</div>

src/lib/streaming.test.ts

Lines changed: 85 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,20 @@
11
import { describe, it, expect } from 'vitest'
22
import { stopStreamProcessing } from './utils/streaming'
3+
import { streamText } from './streaming'
4+
5+
function iterableFromArray<T>(arr: T[]): AsyncIterable<T> {
6+
return {
7+
[Symbol.asyncIterator]() {
8+
let i = 0
9+
return {
10+
next: async () =>
11+
i < arr.length
12+
? { value: arr[i++], done: false }
13+
: { value: undefined, done: true },
14+
}
15+
},
16+
}
17+
}
318

419
describe('stopStreamProcessing', () => {
520
it('replaces response body with an empty closed stream', async () => {
@@ -59,3 +74,73 @@ describe('stopStreamProcessing', () => {
5974
expect(response.body).toBeInstanceOf(ReadableStream)
6075
})
6176
})
77+
78+
describe('streamText', () => {
79+
it('emits stream_done when the stream ends', async () => {
80+
// Simulate a normal completion (not timing out)
81+
const chunks = [
82+
{ type: 'response.output_text.delta', delta: "I'm glad you asked!" },
83+
{
84+
type: 'response.output_text.delta',
85+
delta: ' Here are a few universally nice things that',
86+
},
87+
{
88+
type: 'response.output_text.delta',
89+
delta: ' could have happened today:\n\n',
90+
},
91+
{
92+
type: 'response.output_text.delta',
93+
delta: '- Someone smiled at a stranger, brightening',
94+
},
95+
{ type: 'response.output_text.delta', delta: ' their day\n' },
96+
{
97+
type: 'response.output_text.delta',
98+
delta: '- A teacher helped a student understand a',
99+
},
100+
{ type: 'response.output_text.delta', delta: ' difficult concept\n' },
101+
{
102+
type: 'response.output_text.delta',
103+
delta: '- A kind person paid for someone’s coffee',
104+
},
105+
{ type: 'response.output_text.delta', delta: ' in line\n' },
106+
{
107+
type: 'response.output_text.delta',
108+
delta: '- A pet reunited with its owner at a local',
109+
},
110+
{ type: 'response.output_text.delta', delta: ' animal shelter\n' },
111+
{
112+
type: 'response.output_text.delta',
113+
delta: '- A friend reached out just to say hello\n\n',
114+
},
115+
{
116+
type: 'response.output_text.delta',
117+
delta: 'Would you like to hear some real, uplifting',
118+
},
119+
{ type: 'response.output_text.delta', delta: ' news from today?' },
120+
{ type: 'response.output_text.delta', delta: ' Just let me know!' },
121+
{
122+
type: 'response.tool_call_completed',
123+
response: {
124+
/* ...omitted... */
125+
},
126+
},
127+
{
128+
type: 'response.completed',
129+
response: {
130+
/* ...omitted... */
131+
},
132+
},
133+
]
134+
const response = streamText(iterableFromArray(chunks))
135+
const reader = response.body!.getReader()
136+
let result = ''
137+
let done = false
138+
while (!done) {
139+
const { value, done: d } = await reader.read()
140+
if (value) result += new TextDecoder().decode(value)
141+
done = d
142+
}
143+
// Should include t:{...stream_done...}
144+
expect(result).toMatch(/t:{\"type\":\"stream_done\"}/)
145+
})
146+
})

src/lib/streaming.ts

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -357,6 +357,10 @@ export function streamText(
357357

358358
// Flush any remaining content
359359
flush()
360+
// Emit a final done event to signal successful completion
361+
controller.enqueue(
362+
encoder.encode(`t:${JSON.stringify({ type: 'stream_done' })}\n`),
363+
)
360364
controller.close()
361365
} catch (error: unknown) {
362366
console.error('Error during streamed response:', error)

0 commit comments

Comments
 (0)