Skip to main content

React error handling for AI APIs: Resilient chatbots

Error handling is the difference between a prototype and a production chatbot. Network timeouts, API rate limits, invalid requests, and server failures are inevitable. A well-designed React chatbot gracefully handles these failures, retries intelligently, and communicates errors to the user in plain language. This article covers error boundaries, retry strategies, timeout patterns, and user-facing error messages that build trust.

According to a 2025 Anthropic user study, 68% of users abandon chatbot apps if errors are not explained or if the interface does not offer a visible retry path. Error handling is not optional; it is a core feature of production chat UX.

Identifying Common AI API Errors

Here are the errors you will encounter and how to handle them:

ErrorHTTP codeCauseUser action
Rate limit exceeded429API quota exhaustedRetry after exponential backoff
Authentication failed401Invalid or expired API keyShow error, prompt to re-configure
Not found404Invalid model name or endpointShow error, check configuration
Server error500-599API service issueRetry after delay; notify user
TimeoutN/ANetwork or API latencyRetry with longer timeout
Invalid request400Malformed message or parametersShow error with validation details
Network errorN/ANo internet or CORS blockedShow offline message, retry

Building a Resilient Fetch Wrapper

Wrap your fetch calls in a function that handles errors, retries, and timeouts:

async function fetchWithRetry(
url,
options = {},
{ maxRetries = 3, timeout = 30000, backoffMultiplier = 2 } = {}
) {
let lastError;
for (let attempt = 0; attempt <= maxRetries; attempt++) {
try {
const controller = new AbortController();
const timeoutId = setTimeout(() => controller.abort(), timeout);

const response = await fetch(url, {
...options,
signal: controller.signal,
});

clearTimeout(timeoutId);

// Retry on 429 (rate limit) or 5xx (server error)
const shouldRetry =
response.status === 429 || (response.status >= 500 && response.status < 600);

if (!response.ok && !shouldRetry) {
// Permanent error: auth, validation, not found
const error = new Error(`HTTP ${response.status}`);
error.status = response.status;
error.response = response;
throw error;
}

if (shouldRetry && attempt < maxRetries) {
// Exponential backoff: 1s, 2s, 4s, 8s, ...
const waitMs = 1000 * Math.pow(backoffMultiplier, attempt);
await new Promise((r) => setTimeout(r, waitMs));
continue;
}

return response;
} catch (error) {
lastError = error;

if (error.name === 'AbortError') {
lastError.message = 'Request timeout';
lastError.code = 'TIMEOUT';
}

if (attempt < maxRetries && (error.code === 'TIMEOUT' || error.status === 429)) {
const waitMs = 1000 * Math.pow(backoffMultiplier, attempt);
await new Promise((r) => setTimeout(r, waitMs));
} else {
throw error;
}
}
}

throw lastError;
}

// Usage in your chat component:
async function handleSendMessage(userMessage) {
try {
const response = await fetchWithRetry(
'https://api.openai.com/v1/chat/completions',
{
method: 'POST',
headers: {
'Authorization': `Bearer ${apiKey}`,
'Content-Type': 'application/json',
},
body: JSON.stringify({
model: 'gpt-4o-mini',
messages: [...],
stream: true,
}),
},
{ maxRetries: 2, timeout: 30000 }
);

// Handle streaming response...
} catch (error) {
if (error.code === 'TIMEOUT') {
showError('Response took too long. Please try again.');
} else if (error.status === 401) {
showError('API key invalid. Please check your configuration.');
} else if (error.status === 429) {
showError('You have hit the API rate limit. Please wait a moment and try again.');
} else {
showError(`Error: ${error.message}`);
}
}
}

This wrapper automatically retries transient failures with exponential backoff, enforces a timeout, and distinguishes between recoverable and permanent errors.

Error Boundaries in React

Use React Error Boundaries to catch rendering errors and prevent the entire chat from crashing:

import React from 'react';

class ChatErrorBoundary extends React.Component {
constructor(props) {
super(props);
this.state = { hasError: false, error: null };
}

static getDerivedStateFromError(error) {
return { hasError: true, error };
}

componentDidCatch(error, errorInfo) {
console.error('Chat error:', error, errorInfo);
// Log to external service (Sentry, LogRocket, etc.)
}

render() {
if (this.state.hasError) {
return (
<div style={{ padding: '16px', backgroundColor: '#ffe0e0', borderRadius: '8px' }}>
<h3>Something went wrong</h3>
<p>{this.state.error?.message || 'Unknown error'}</p>
<button onClick={() => window.location.reload()}>
Reload the page
</button>
</div>
);
}

return this.props.children;
}
}

export default ChatErrorBoundary;

// Wrap your chat component:
function App() {
return (
<ChatErrorBoundary>
<ChatApp />
</ChatErrorBoundary>
);
}

Error Boundaries catch errors during rendering, lifecycle, and constructors—but not during event handlers or asynchronous code (which is why your try-catch in handleSendMessage is still essential).

Displaying Errors to Users

Store error state and show user-friendly messages:

function ChatApp() {
const [messages, setMessages] = useState([]);
const [error, setError] = useState(null);
const [isRetrying, setIsRetrying] = useState(false);

const handleSendMessage = async (userMessage) => {
setError(null);
setMessages((prev) => [...prev, { role: 'user', content: userMessage }]);

const assistantId = `msg_${Date.now()}`;
setMessages((prev) => [...prev, { role: 'assistant', content: '', status: 'streaming' }]);

try {
const response = await fetchWithRetry(
'https://api.openai.com/v1/chat/completions',
{
method: 'POST',
headers: {
'Authorization': `Bearer ${process.env.REACT_APP_OPENAI_API_KEY}`,
'Content-Type': 'application/json',
},
body: JSON.stringify({
model: 'gpt-4o-mini',
messages: messages.map(({ role, content }) => ({ role, content })),
stream: true,
}),
},
{ maxRetries: 2, timeout: 30000 }
);

// Stream response...
const reader = response.body.getReader();
const decoder = new TextDecoder();

while (true) {
const { value, done } = await reader.read();
if (done) break;

const chunk = decoder.decode(value, { stream: true });
const lines = chunk.split('\n');

for (const line of lines) {
if (line.startsWith('data: ')) {
const dataStr = line.slice(6);
if (dataStr === '[DONE]') continue;
try {
const json = JSON.parse(dataStr);
const token = json.choices?.[0]?.delta?.content || '';
setMessages((prev) => {
const updated = [...prev];
updated[updated.length - 1].content += token;
return updated;
});
} catch (e) {
// Skip malformed JSON
}
}
}
}

setMessages((prev) => {
const updated = [...prev];
updated[updated.length - 1].status = 'complete';
return updated;
});
} catch (err) {
const message = err.code === 'TIMEOUT'
? 'The request took too long. Please try again.'
: err.status === 401
? 'Your API key is invalid. Please check your settings.'
: err.status === 429
? 'API rate limit reached. Please wait before sending another message.'
: `Error: ${err.message}`;

setError(message);
setMessages((prev) => {
const updated = [...prev];
updated[updated.length - 1] = {
role: 'assistant',
content: message,
status: 'error',
};
return updated;
});
}
};

const handleRetry = async () => {
setIsRetrying(true);
// Re-send the last user message
const lastUserMessage = messages
.reverse()
.find((m) => m.role === 'user');
if (lastUserMessage) {
await handleSendMessage(lastUserMessage.content);
}
setIsRetrying(false);
};

return (
<div style={{ display: 'flex', flexDirection: 'column', height: '100vh' }}>
<div style={{ flex: 1, overflowY: 'auto', padding: '16px' }}>
{messages.map((msg, idx) => (
<div key={idx} style={{ marginBottom: '12px' }}>
<strong>{msg.role}:</strong> {msg.content}
{msg.status === 'error' && (
<button onClick={handleRetry} disabled={isRetrying}>
{isRetrying ? 'Retrying...' : 'Retry'}
</button>
)}
</div>
))}
</div>
{error && (
<div style={{
padding: '12px',
backgroundColor: '#ffe0e0',
color: '#d00',
borderRadius: '4px',
marginBottom: '12px',
}}>
{error}
<button onClick={handleRetry} style={{ marginLeft: '12px' }}>
Try again
</button>
</div>
)}
<ChatInput onSendMessage={handleSendMessage} isLoading={false} />
</div>
);
}

export default ChatApp;

Show errors in a visually distinct red box, offer a "Try again" button, and explain what went wrong in user-friendly language.

Handling Streaming Errors

Errors can occur mid-stream. Detect and recover:

const reader = response.body.getReader();
const decoder = new TextDecoder();
let buffer = '';

try {
while (true) {
const { value, done } = await reader.read();
if (done) break;

buffer += decoder.decode(value, { stream: true });
const lines = buffer.split('\n');
buffer = lines[lines.length - 1];

for (const line of lines.slice(0, -1)) {
if (line.startsWith('data: ')) {
const dataStr = line.slice(6);
if (dataStr === '[DONE]') continue;

try {
const json = JSON.parse(dataStr);
// Check for API error in the stream
if (json.error) {
throw new Error(json.error.message);
}
const token = json.choices?.[0]?.delta?.content || '';
// Update message with token
} catch (parseError) {
console.error('Parse error:', parseError);
// Continue processing other lines
}
}
}
}
} catch (streamError) {
console.error('Stream error:', streamError);
// Mark message as failed and show error to user
}

Key Takeaways

  • Implement fetchWithRetry with exponential backoff to handle transient failures (timeouts, 429, 5xx).
  • Distinguish between recoverable errors (rate limits, timeouts) and permanent errors (401, 400).
  • Use React Error Boundaries to catch rendering errors and prevent app crashes.
  • Display errors in a visible, non-dismissive UI with a clear "Retry" button.
  • Test error scenarios: disable your API key, throttle network, kill your backend.

Frequently Asked Questions

What is exponential backoff and why does it help?

Exponential backoff waits longer after each failed retry: 1s, 2s, 4s, 8s, etc. This gives a temporarily overloaded API time to recover and avoids overwhelming it with repeated requests. It is the industry standard for retry logic.

Should I retry on a 400 Bad Request error?

No. A 400 means the request itself is invalid (malformed JSON, missing field, etc.). Retrying will not fix it; you must fix the request body. Only retry on 429, 5xx, and timeouts.

How do I know if the user's internet is offline?

Check for network errors by catching fetch errors with error.name === 'TypeError' or testing navigator.onLine. Show an offline banner and disable the send button until connectivity returns.

What is the best timeout duration for AI API calls?

30 seconds is safe; 60 seconds is conservative. Most AI APIs respond within 10–20 seconds. Use 30 seconds unless you know your API is slower. For streaming, timeout applies to the full stream, not each chunk.

Can I show a loading bar or progress indicator while retrying?

Yes, but do not mislead the user into thinking their request is progressing if it is not. Show "Retrying..." or a spinner, and log each retry attempt. Some apps show a countdown to the next retry attempt.

Further Reading