diff --git a/README.md b/README.md index 4735744..9471520 100644 --- a/README.md +++ b/README.md @@ -54,6 +54,7 @@ Configure the widget via `window.ClaudiusConfig`: | `placeholder` | `"Type your message..."` | Input placeholder | | `persistMessages` | `true` | Save chat history to `sessionStorage` (survives page navigation, clears on tab close) | | `storageKeyPrefix` | `"claudius:messages"` | Storage key prefix; set to a unique value per widget when embedding multiple widgets on one page | +| `requestTimeoutMs` | `30000` | Per-attempt request timeout in ms. The widget aborts and surfaces a retryable timeout error. Set to `0` to disable. | | `theme` | `"light"` | Color scheme: `"light"`, `"dark"`, or `"auto"` | | `accentColor` | `"#2563eb"` | Primary brand color override | diff --git a/widget/src/api/__tests__/client.test.ts b/widget/src/api/__tests__/client.test.ts index bf868f0..f087c6f 100644 --- a/widget/src/api/__tests__/client.test.ts +++ b/widget/src/api/__tests__/client.test.ts @@ -49,11 +49,14 @@ describe("ChatApiClient", () => { const client = new ChatApiClient(BASE_URL, { debounceMs: 0 }); await client.sendMessage(mockMessages); - expect(mockFetch).toHaveBeenCalledWith(`${BASE_URL}/api/chat`, { - method: "POST", - headers: { "Content-Type": "application/json" }, - body: JSON.stringify({ messages: mockMessages }), - }); + expect(mockFetch).toHaveBeenCalledWith( + `${BASE_URL}/api/chat`, + expect.objectContaining({ + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ messages: mockMessages }), + }), + ); }); it("returns typed ChatResponse on success", async () => { @@ -291,6 +294,106 @@ describe("ChatApiClient", () => { }); }); + describe("timeout", () => { + it("aborts and throws ChatApiError(code=TIMEOUT) when the request exceeds timeoutMs", async () => { + vi.useFakeTimers(); + + // Fetch never resolves on its own; the client aborts via AbortSignal. + mockFetch.mockImplementation( + (_input: RequestInfo, init?: RequestInit) => + new Promise((_resolve, reject) => { + init?.signal?.addEventListener("abort", () => { + const err = new Error("aborted"); + err.name = "AbortError"; + reject(err); + }); + }), + ); + + const client = new ChatApiClient(BASE_URL, { + debounceMs: 0, + maxRetries: 0, + timeoutMs: 5_000, + }); + + const promise = client.sendMessage(mockMessages).catch((e: unknown) => e); + + // Trigger the timeout AbortController. + await vi.advanceTimersByTimeAsync(5_000); + + const error = await promise; + expect(error).toBeInstanceOf(ChatApiError); + expect(error).toMatchObject({ status: 0, code: "TIMEOUT" }); + }); + + it("treats TIMEOUT as retryable and recovers on the next attempt", async () => { + vi.useFakeTimers(); + + let callCount = 0; + mockFetch.mockImplementation( + (_input: RequestInfo, init?: RequestInit) => { + callCount += 1; + if (callCount === 1) { + // First call: hang until aborted by the client's timeout. + return new Promise((_resolve, reject) => { + init?.signal?.addEventListener("abort", () => { + const err = new Error("aborted"); + err.name = "AbortError"; + reject(err); + }); + }); + } + // Second call: succeed. + return Promise.resolve( + createMockResponse({ + ok: true, + status: 200, + body: { reply: "OK" }, + }), + ); + }, + ); + + const client = new ChatApiClient(BASE_URL, { + debounceMs: 0, + maxRetries: 1, + timeoutMs: 1_000, + }); + + const promise = client.sendMessage(mockMessages); + + // Advance past the timeout to abort attempt 1. + await vi.advanceTimersByTimeAsync(1_000); + // Advance past the network-error backoff (1s for first retry). + await vi.advanceTimersByTimeAsync(1_000); + + const result = await promise; + expect(result).toEqual({ reply: "OK" }); + expect(callCount).toBe(2); + }); + + it("does not abort when timeoutMs is 0", async () => { + mockFetch.mockResolvedValueOnce( + createMockResponse({ + ok: true, + status: 200, + body: { reply: "OK" }, + }), + ); + + const client = new ChatApiClient(BASE_URL, { + debounceMs: 0, + timeoutMs: 0, + }); + + const result = await client.sendMessage(mockMessages); + expect(result).toEqual({ reply: "OK" }); + // When timeoutMs is 0, fetch is called without a signal. + const call = mockFetch.mock.calls[0]; + expect(call[1].signal).toBeUndefined(); + }); + }); + describe("debounce", () => { it("rejects rapid calls within debounce window with DebounceError", async () => { vi.useFakeTimers(); diff --git a/widget/src/api/client.ts b/widget/src/api/client.ts index c4e8853..b7d53c5 100644 --- a/widget/src/api/client.ts +++ b/widget/src/api/client.ts @@ -8,18 +8,28 @@ import { ChatApiError, DebounceError } from "./errors"; export interface ChatApiClientOptions { maxRetries?: number; debounceMs?: number; + /** + * Per-attempt request timeout in milliseconds. Aborts the in-flight fetch + * via AbortController and surfaces a retryable ChatApiError with code + * "TIMEOUT". Set to 0 to disable. Defaults to 30000 (30s). + */ + timeoutMs?: number; } +const DEFAULT_TIMEOUT_MS = 30_000; + export class ChatApiClient { private readonly baseUrl: string; private readonly maxRetries: number; private readonly debounceMs: number; + private readonly timeoutMs: number; private lastSendTime = 0; constructor(baseUrl: string, options?: ChatApiClientOptions) { this.baseUrl = baseUrl; this.maxRetries = options?.maxRetries ?? 2; this.debounceMs = options?.debounceMs ?? 300; + this.timeoutMs = options?.timeoutMs ?? DEFAULT_TIMEOUT_MS; } async sendMessage(messages: ChatMessage[]): Promise { @@ -36,24 +46,22 @@ export class ChatApiClient { for (let attempt = 0; attempt <= this.maxRetries; attempt++) { try { - const response = await fetch(`${this.baseUrl}/api/chat`, { - method: "POST", - headers: { "Content-Type": "application/json" }, - body: JSON.stringify({ messages }), - }); + const response = await this.fetchWithTimeout(messages); if (response.ok) { return (await response.json()) as ChatResponse; } - const body = (await response.json()) as ChatErrorResponse; + const body = (await response.json().catch(() => ({}))) as + | ChatErrorResponse + | Record; const retryAfterHeader = response.headers.get("Retry-After"); const retryAfter = retryAfterHeader ? Number(retryAfterHeader) : undefined; lastError = new ChatApiError( - body.error, + body.error ?? `Request failed with status ${response.status}`, response.status, body.code, retryAfter, @@ -69,7 +77,7 @@ export class ChatApiClient { } } catch (error) { if (error instanceof ChatApiError) { - if (!this.isRetryable(error.status)) { + if (!this.isRetryable(error.status, error.code)) { throw error; } lastError = error; @@ -78,10 +86,11 @@ export class ChatApiClient { await this.delay(delayMs); } } else { - // Network error + // Network error (fetch threw, e.g. DNS / offline / CORS). lastError = new ChatApiError( "Failed to connect. Please try again.", 0, + "NETWORK_ERROR", ); if (attempt < this.maxRetries) { const delayMs = this.getRetryDelay(null, attempt); @@ -94,7 +103,40 @@ export class ChatApiClient { throw lastError!; } - private isRetryable(status: number): boolean { + private async fetchWithTimeout(messages: ChatMessage[]): Promise { + if (this.timeoutMs <= 0) { + return fetch(`${this.baseUrl}/api/chat`, { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ messages }), + }); + } + + const controller = new AbortController(); + const timeoutId = setTimeout(() => controller.abort(), this.timeoutMs); + try { + return await fetch(`${this.baseUrl}/api/chat`, { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ messages }), + signal: controller.signal, + }); + } catch (err) { + if (controller.signal.aborted) { + throw new ChatApiError( + "Request timed out. Please try again.", + 0, + "TIMEOUT", + ); + } + throw err; + } finally { + clearTimeout(timeoutId); + } + } + + private isRetryable(status: number, code?: string): boolean { + if (code === "TIMEOUT" || code === "NETWORK_ERROR") return true; return status === 429 || status === 503; } diff --git a/widget/src/components/ChatWidget.tsx b/widget/src/components/ChatWidget.tsx index f5dd880..51773dd 100644 --- a/widget/src/components/ChatWidget.tsx +++ b/widget/src/components/ChatWidget.tsx @@ -23,6 +23,7 @@ export interface ChatWidgetProps { placeholder?: string; persistMessages?: boolean; storageKeyPrefix?: string; + requestTimeoutMs?: number; theme?: "light" | "dark" | "auto"; accentColor?: string; position?: WidgetPosition; @@ -37,6 +38,7 @@ export function ChatWidget({ placeholder, persistMessages, storageKeyPrefix, + requestTimeoutMs, theme = "light", accentColor, position = "bottom-right", @@ -50,10 +52,11 @@ export function ChatWidget({ [translationOverrides] ); - const { messages, isLoading, error, sendMessage } = useChat({ + const { messages, isLoading, error, canRetry, sendMessage, retry } = useChat({ apiUrl, persistMessages, storageKeyPrefix, + timeoutMs: requestTimeoutMs, translations, }); const toggleRef = useRef(null); @@ -108,7 +111,9 @@ export function ChatWidget({ messages={messages} isLoading={isLoading} error={error} + canRetry={canRetry} onSend={sendMessage} + onRetry={retry} onClose={handleClose} title={title ?? translations.title} subtitle={subtitle ?? translations.subtitle} diff --git a/widget/src/components/ChatWindow.tsx b/widget/src/components/ChatWindow.tsx index 57f4490..be31f5f 100644 --- a/widget/src/components/ChatWindow.tsx +++ b/widget/src/components/ChatWindow.tsx @@ -13,7 +13,9 @@ interface ChatWindowProps { messages: ChatMessageData[]; isLoading: boolean; error: string | null; + canRetry?: boolean; onSend: (message: string) => void; + onRetry?: () => void; onClose: () => void; title?: string; subtitle?: string; @@ -24,13 +26,15 @@ interface ChatWindowProps { isMobile?: boolean; } -function TypingIndicator() { +function TypingIndicator({ label }: { label: string }) { + // motion-safe: variant disables the bounce when prefers-reduced-motion is + // set; the dots remain visible as a static status pill. return ( -
+
- - - + + +
); @@ -47,7 +51,9 @@ export function ChatWindow({ messages, isLoading, error, + canRetry = false, onSend, + onRetry, onClose, title = "Chat", subtitle = "Ask me anything", @@ -194,14 +200,27 @@ export function ChatWindow({ /> ))} - {isLoading && } + {isLoading && ( + + )} {error && (
- {error} + {error} + {canRetry && onRetry && !isLoading && ( + + )}
)}
diff --git a/widget/src/components/__tests__/ChatWindow.test.tsx b/widget/src/components/__tests__/ChatWindow.test.tsx index 071ca7c..84a6104 100644 --- a/widget/src/components/__tests__/ChatWindow.test.tsx +++ b/widget/src/components/__tests__/ChatWindow.test.tsx @@ -58,6 +58,70 @@ describe("ChatWindow", () => { expect(screen.getByText(/Connection failed/i)).toBeInTheDocument(); }); + it("renders a retry button when canRetry is true and onRetry is provided", () => { + const onRetry = vi.fn(); + render( + + ); + expect(screen.getByRole("button", { name: /retry/i })).toBeInTheDocument(); + }); + + it("calls onRetry when the retry button is clicked", async () => { + const onRetry = vi.fn(); + const user = userEvent.setup(); + render( + + ); + await user.click(screen.getByRole("button", { name: /retry/i })); + expect(onRetry).toHaveBeenCalledTimes(1); + }); + + it("does not render retry button when canRetry is false", () => { + render( + + ); + expect(screen.queryByRole("button", { name: /retry/i })).not.toBeInTheDocument(); + }); + + it("hides retry button while loading", () => { + render( + + ); + expect(screen.queryByRole("button", { name: /retry/i })).not.toBeInTheDocument(); + }); + const messagesWithSources = [ { id: "msg-1", role: "user" as const, content: "Help me" }, { diff --git a/widget/src/embed.tsx b/widget/src/embed.tsx index f39abfd..0106772 100644 --- a/widget/src/embed.tsx +++ b/widget/src/embed.tsx @@ -10,6 +10,7 @@ interface ClaudiusConfig { placeholder?: string; persistMessages?: boolean; storageKeyPrefix?: string; + requestTimeoutMs?: number; theme?: "light" | "dark" | "auto"; accentColor?: string; position?: WidgetPosition; @@ -42,6 +43,7 @@ function init() { placeholder={config.placeholder} persistMessages={config.persistMessages} storageKeyPrefix={config.storageKeyPrefix} + requestTimeoutMs={config.requestTimeoutMs} theme={config.theme} accentColor={config.accentColor} position={config.position} @@ -63,6 +65,7 @@ class ClaudiusChat extends HTMLElement { "placeholder", "persist-messages", "storage-key-prefix", + "request-timeout-ms", "theme", "accent-color", "position", @@ -104,6 +107,10 @@ class ClaudiusChat extends HTMLElement { const persistMessages = persistAttr === null ? undefined : persistAttr !== "false"; + const timeoutAttr = this.getAttribute("request-timeout-ms"); + const requestTimeoutMs = + timeoutAttr === null ? undefined : Number(timeoutAttr); + this.root.render( { ]); }); }); + +describe("retry on failure", () => { + beforeEach(() => { + mockFetch.mockReset(); + sessionStorage.clear(); + }); + + afterEach(() => { + vi.useRealTimers(); + }); + + it("sets canRetry on network error and resends the same messages on retry", async () => { + vi.useFakeTimers(); + + // Every attempt rejects — the client retries internally (initial + 2), + // backing off 1s and 3s between attempts before giving up. + mockFetch.mockRejectedValue(new TypeError("Failed to fetch")); + + const { result } = renderHook(() => + useChat({ + apiUrl: "https://test.workers.dev", + // Disable timeout so AbortController doesn't interfere with the mock. + timeoutMs: 0, + }) + ); + + let sendPromise!: Promise; + act(() => { + sendPromise = result.current.sendMessage("Hello"); + }); + + // Drain the client's two backoffs so the failure surfaces. + await act(async () => { + await vi.advanceTimersByTimeAsync(1000); + await vi.advanceTimersByTimeAsync(3000); + await sendPromise; + }); + + expect(result.current.error).not.toBeNull(); + expect(result.current.canRetry).toBe(true); + expect(result.current.messages).toHaveLength(1); + expect(result.current.messages[0]).toMatchObject({ + role: "user", + content: "Hello", + }); + + // Network recovers — retry should succeed without re-appending the user message. + mockFetch.mockReset(); + mockFetch.mockResolvedValueOnce({ + ok: true, + status: 200, + headers: new Headers(), + json: () => Promise.resolve({ reply: "Welcome back!" }), + }); + + await act(async () => { + await result.current.retry(); + }); + + expect(result.current.error).toBeNull(); + expect(result.current.canRetry).toBe(false); + expect(result.current.messages).toHaveLength(2); + expect(result.current.messages[0]).toMatchObject({ role: "user", content: "Hello" }); + expect(result.current.messages[1]).toMatchObject({ + role: "assistant", + content: "Welcome back!", + }); + + // The retried request must contain only the original user message — + // not a second copy. + const lastCallBody = JSON.parse(mockFetch.mock.calls[0][1].body); + expect(lastCallBody.messages).toHaveLength(1); + expect(lastCallBody.messages[0]).toMatchObject({ + role: "user", + content: "Hello", + }); + }); + + it("does not set canRetry on validation errors", async () => { + mockFetch.mockResolvedValueOnce({ + ok: false, + status: 400, + headers: new Headers(), + json: () => + Promise.resolve({ error: "Invalid input", code: "VALIDATION_ERROR" }), + }); + + const { result } = renderHook(() => + useChat({ apiUrl: "https://test.workers.dev", timeoutMs: 0 }) + ); + + await act(async () => { + await result.current.sendMessage("Hi"); + }); + + expect(result.current.error).not.toBeNull(); + expect(result.current.canRetry).toBe(false); + }); + + it("retry is a no-op when last message is not from the user", async () => { + mockFetch.mockResolvedValueOnce({ + ok: true, + status: 200, + headers: new Headers(), + json: () => Promise.resolve({ reply: "Hi!" }), + }); + + const { result } = renderHook(() => + useChat({ apiUrl: "https://test.workers.dev", timeoutMs: 0 }) + ); + + await act(async () => { + await result.current.sendMessage("Hello"); + }); + + expect(result.current.messages).toHaveLength(2); + mockFetch.mockReset(); + + await act(async () => { + await result.current.retry(); + }); + + expect(mockFetch).not.toHaveBeenCalled(); + expect(result.current.messages).toHaveLength(2); + }); +}); diff --git a/widget/src/hooks/useChat.ts b/widget/src/hooks/useChat.ts index 67974bb..4663268 100644 --- a/widget/src/hooks/useChat.ts +++ b/widget/src/hooks/useChat.ts @@ -8,6 +8,7 @@ interface UseChatOptions { apiUrl: string; persistMessages?: boolean; storageKeyPrefix?: string; + timeoutMs?: number; translations?: ClaudiusTranslations; } @@ -15,7 +16,9 @@ interface UseChatReturn { messages: ChatMessage[]; isLoading: boolean; error: string | null; + canRetry: boolean; sendMessage: (content: string) => Promise; + retry: () => Promise; clearMessages: () => void; } @@ -58,11 +61,12 @@ export function useChat({ apiUrl, persistMessages = true, storageKeyPrefix = DEFAULT_STORAGE_KEY_PREFIX, + timeoutMs, translations, }: UseChatOptions): UseChatReturn { const client = useMemo( - () => new ChatApiClient(apiUrl, { debounceMs: 0 }), - [apiUrl], + () => new ChatApiClient(apiUrl, { debounceMs: 0, timeoutMs }), + [apiUrl, timeoutMs], ); const storageKey = getStorageKey(storageKeyPrefix, apiUrl); @@ -72,6 +76,7 @@ export function useChat({ const [messages, setMessages] = useState(initialMessages); const [isLoading, setIsLoading] = useState(false); const [error, setError] = useState(null); + const [canRetry, setCanRetry] = useState(false); const idCounterRef = useRef(initialMessages.length); const isLoadingRef = useRef(false); @@ -97,51 +102,63 @@ export function useChat({ return `msg-${idCounterRef.current}`; }; - const getErrorMessage = ( - code?: string, - fallback?: string - ): string => { - if (!translations) { - return fallback ?? "Something went wrong. Please try again."; - } - - switch (code) { - case "RATE_LIMITED": - // Check the message to determine minute vs hour - if (fallback?.includes("minute")) { - return translations.errorRateLimitMinute; - } - return translations.errorRateLimitHour; - case "VALIDATION_ERROR": - case "CONFIG_ERROR": - case "SERVICE_ERROR": - case "UNKNOWN_ERROR": - default: - return fallback ?? translations.errorGeneric; - } - }; + const getErrorMessage = useCallback( + (code?: string, fallback?: string): string => { + if (!translations) { + return fallback ?? "Something went wrong. Please try again."; + } - const sendMessage = useCallback( - async (content: string) => { - const trimmed = content.trim(); - if (!trimmed || isLoadingRef.current) return; + switch (code) { + case "TIMEOUT": + return translations.errorTimeout; + case "NETWORK_ERROR": + return translations.errorConnection; + case "RATE_LIMITED": + if (fallback?.includes("minute")) { + return translations.errorRateLimitMinute; + } + return translations.errorRateLimitHour; + case "VALIDATION_ERROR": + case "CONFIG_ERROR": + case "SERVICE_ERROR": + case "UNKNOWN_ERROR": + default: + return fallback ?? translations.errorGeneric; + } + }, + [translations] + ); - const userMessage: ChatMessage = { - id: nextId(), - role: "user", - content: trimmed, - }; + // Recoverable codes — show the retry button on failures the user can retry. + // Validation/config errors aren't retryable: the input or server config + // would need to change first. + const isRetryableError = useCallback( + (err: unknown): boolean => { + if (!(err instanceof ChatApiError)) return true; // unknown failure → allow retry + if ( + err.code === "TIMEOUT" || + err.code === "NETWORK_ERROR" || + err.code === "RATE_LIMITED" || + err.code === "SERVICE_ERROR" || + err.code === "UNKNOWN_ERROR" + ) { + return true; + } + if (err.status >= 500 || err.status === 0) return true; + return false; + }, + [] + ); - const updatedMessages = [...messagesRef.current, userMessage]; - messagesRef.current = updatedMessages; - setMessages(updatedMessages); - saveMessages(updatedMessages); + const submit = useCallback( + async (msgsToSend: ChatMessage[]) => { setIsLoading(true); isLoadingRef.current = true; setError(null); + setCanRetry(false); try { - const data = await client.sendMessage(updatedMessages); + const data = await client.sendMessage(msgsToSend); const assistantMessage: ChatMessage = { id: nextId(), @@ -149,7 +166,7 @@ export function useChat({ content: data.reply, sources: data.sources, }; - const withReply = [...updatedMessages, assistantMessage]; + const withReply = [...msgsToSend, assistantMessage]; messagesRef.current = withReply; setMessages(withReply); saveMessages(withReply); @@ -163,18 +180,48 @@ export function useChat({ "Failed to connect. Please try again.", ); } + setCanRetry(isRetryableError(err)); } finally { setIsLoading(false); isLoadingRef.current = false; } }, - [client, saveMessages, translations] + [client, getErrorMessage, isRetryableError, saveMessages, translations] ); + const sendMessage = useCallback( + async (content: string) => { + const trimmed = content.trim(); + if (!trimmed || isLoadingRef.current) return; + + const userMessage: ChatMessage = { + id: nextId(), + role: "user", + content: trimmed, + }; + + const updatedMessages = [...messagesRef.current, userMessage]; + messagesRef.current = updatedMessages; + setMessages(updatedMessages); + saveMessages(updatedMessages); + + await submit(updatedMessages); + }, + [saveMessages, submit] + ); + + const retry = useCallback(async () => { + if (isLoadingRef.current) return; + const last = messagesRef.current[messagesRef.current.length - 1]; + if (!last || last.role !== "user") return; + await submit(messagesRef.current); + }, [submit]); + const clearMessages = useCallback(() => { messagesRef.current = []; setMessages([]); setError(null); + setCanRetry(false); if (persistMessages) { const storage = getSessionStorage(); if (!storage) return; @@ -186,7 +233,15 @@ export function useChat({ } }, [persistMessages, storageKey]); - return { messages, isLoading, error, sendMessage, clearMessages }; + return { + messages, + isLoading, + error, + canRetry, + sendMessage, + retry, + clearMessages, + }; } export type { ChatMessage }; diff --git a/widget/src/i18n.ts b/widget/src/i18n.ts index ef13640..e23c7e6 100644 --- a/widget/src/i18n.ts +++ b/widget/src/i18n.ts @@ -5,6 +5,7 @@ export interface ClaudiusTranslations { welcomeMessage: string; closeChat: string; chatMessages: string; + typingIndicator: string; // ChatInput placeholder: string; @@ -17,8 +18,10 @@ export interface ClaudiusTranslations { // Errors errorGeneric: string; errorConnection: string; + errorTimeout: string; errorRateLimitMinute: string; errorRateLimitHour: string; + errorRetry: string; } export const defaultTranslations: ClaudiusTranslations = { @@ -28,6 +31,7 @@ export const defaultTranslations: ClaudiusTranslations = { welcomeMessage: "Hi! How can I help you today?", closeChat: "Close chat", chatMessages: "Chat messages", + typingIndicator: "Assistant is typing", // ChatInput placeholder: "Type your message...", @@ -40,8 +44,10 @@ export const defaultTranslations: ClaudiusTranslations = { // Errors errorGeneric: "Something went wrong. Please try again.", errorConnection: "Failed to connect. Please try again.", + errorTimeout: "Request timed out. Please try again.", errorRateLimitMinute: "Too many requests. Please wait a minute.", errorRateLimitHour: "Hourly limit reached. Please try again later.", + errorRetry: "Retry", }; export function createTranslations(