Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
36 changes: 36 additions & 0 deletions packages/ui/src/utils/errorHandler.js
Original file line number Diff line number Diff line change
Expand Up @@ -17,3 +17,39 @@ const toErrorWithMessage = (maybeError) => {
export const getErrorMessage = (error) => {
return toErrorWithMessage(error).message
}

/**
* Safely extract a human-readable message from an Axios error.
*
* Axios errors may lack `error.response` when the request never received a
* response (network failure, timeout, CORS block, cancelled request, etc.).
* Reading `error.response.data` without a null-guard throws a secondary
* TypeError that masks the original failure.
*
* Priority order:
* 1. error.response.data.message (structured API error)
* 2. error.response.data.error (some APIs use this key)
* 3. error.response.data (plain string body)
* 4. HTTP status code (e.g. "Request failed with status 500")
* 5. error.message (Axios / JS built-in message)
* 6. Generic fallback
*/
export const getAxiosErrorMessage = (error) => {
if (error?.response?.data) {
const data = error.response.data
if (typeof data === 'object' && data !== null) {
// Some APIs nest the message (e.g. { error: { message } }) and NestJS
// validation returns an array of messages — resolve both to a string
// so the UI never renders "[object Object]".
const raw =
data.message ||
(typeof data.error === 'object' && data.error !== null ? data.error.message || data.error.error : data.error)
if (!raw) return getErrorMessage(error)
if (Array.isArray(raw)) return raw.join(', ')
if (typeof raw === 'object') return JSON.stringify(raw)
return String(raw)
}
return String(data)
}
return error?.message || 'An unexpected error occurred'
}
Comment on lines +37 to +55
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

medium

If the API returns a nested error object (e.g., { error: { message: '...' } } which is common in OpenAI and other standard APIs) or an array of messages (e.g., ['error 1', 'error 2'] in NestJS validation errors), the current implementation will return the object or array directly. When interpolated into a string in the UI, this results in [object Object] or unformatted output.

We can make getAxiosErrorMessage more robust by safely resolving nested error objects and joining array messages with commas.

Suggested change
export const getAxiosErrorMessage = (error) => {
if (error?.response?.data) {
const data = error.response.data
if (typeof data === 'object') {
return data.message || data.error || getErrorMessage(error)
}
return String(data)
}
return error?.message || 'An unexpected error occurred'
}
export const getAxiosErrorMessage = (error) => {
if (error?.response?.data) {
const data = error.response.data
if (typeof data === 'object' && data !== null) {
const rawMessage = data.message || (typeof data.error === 'object' ? data.error?.message || data.error?.error : data.error)
if (rawMessage) {
if (Array.isArray(rawMessage)) {
return rawMessage.join(', ')
}
if (typeof rawMessage === 'object') {
return JSON.stringify(rawMessage)
}
return String(rawMessage)
}
return getErrorMessage(error)
}
return String(data)
}
return error?.message || 'An unexpected error occurred'
}
References
  1. When using a heuristic for detection (e.g., for content type), ensure a safe fallback mechanism is in place to correctly handle cases where the heuristic fails.

64 changes: 64 additions & 0 deletions packages/ui/src/utils/errorHandler.test.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,64 @@
import { getErrorMessage, getAxiosErrorMessage } from './errorHandler'

describe('getErrorMessage', () => {
it('returns the message from an Error object', () => {
expect(getErrorMessage(new Error('boom'))).toBe('boom')
})

it('serialises a plain object when it has no message property', () => {
const result = getErrorMessage({ code: 42 })
expect(result).toContain('42')
})
})

describe('getAxiosErrorMessage', () => {
it('returns a safe fallback when error.response is undefined (network failure / timeout / CORS)', () => {
const axiosNetworkError = new Error('Network Error')
// Axios sets no .response on network-level failures
expect(axiosNetworkError.response).toBeUndefined()
expect(getAxiosErrorMessage(axiosNetworkError)).toBe('Network Error')
})

it('returns a safe fallback when the error itself is undefined', () => {
expect(getAxiosErrorMessage(undefined)).toBe('An unexpected error occurred')
})

it('returns a safe fallback when the error is null', () => {
expect(getAxiosErrorMessage(null)).toBe('An unexpected error occurred')
})

it('extracts response.data.message when the server returns a structured error', () => {
const error = { response: { data: { message: 'Validation failed', code: 400 } } }
expect(getAxiosErrorMessage(error)).toBe('Validation failed')
})

it('extracts response.data.error when only that key is present', () => {
const error = { response: { data: { error: 'Unauthorized' } } }
expect(getAxiosErrorMessage(error)).toBe('Unauthorized')
})

it('resolves a nested error object ({ error: { message } }) to its message', () => {
const error = { response: { data: { error: { message: 'Nested failure' } } } }
expect(getAxiosErrorMessage(error)).toBe('Nested failure')
})

it('joins an array of messages (NestJS validation) into a single string', () => {
const error = { response: { data: { message: ['name is required', 'email is invalid'] } } }
expect(getAxiosErrorMessage(error)).toBe('name is required, email is invalid')
})

it('returns a plain string response body as-is', () => {
const error = { response: { data: 'Internal Server Error' } }
expect(getAxiosErrorMessage(error)).toBe('Internal Server Error')
})

it('falls back to error.message when response.data is falsy', () => {
const error = { response: { data: null }, message: 'Request failed with status 503' }
expect(getAxiosErrorMessage(error)).toBe('Request failed with status 503')
})

it('returns the generic fallback when there is no message at all', () => {
const error = {}
expect(getAxiosErrorMessage(error)).toBe('An unexpected error occurred')
})
})
17 changes: 5 additions & 12 deletions packages/ui/src/views/tools/ToolDialog.jsx
Original file line number Diff line number Diff line change
Expand Up @@ -32,6 +32,7 @@ import useApi from '@/hooks/useApi'
// utils
import useNotifier from '@/utils/useNotifier'
import { generateRandomGradient, formatDataGridRows } from '@/utils/genericHelper'
import { getAxiosErrorMessage } from '@/utils/errorHandler'
import { HIDE_CANVAS_DIALOG, SHOW_CANVAS_DIALOG } from '@/store/actions'

const exampleAPIFunc = `/*
Expand Down Expand Up @@ -258,9 +259,7 @@ const ToolDialog = ({ show, dialogProps, onUseTemplate, onCancel, onConfirm, set
}
} catch (error) {
enqueueSnackbar({
message: `Failed to export Tool: ${
typeof error.response.data === 'object' ? error.response.data.message : error.response.data
}`,
message: `Failed to export Tool: ${getAxiosErrorMessage(error)}`,
options: {
key: new Date().getTime() + Math.random(),
variant: 'error',
Expand Down Expand Up @@ -304,9 +303,7 @@ const ToolDialog = ({ show, dialogProps, onUseTemplate, onCancel, onConfirm, set
}
} catch (error) {
enqueueSnackbar({
message: `Failed to add new Tool: ${
typeof error.response.data === 'object' ? error.response.data.message : error.response.data
}`,
message: `Failed to add new Tool: ${getAxiosErrorMessage(error)}`,
options: {
key: new Date().getTime() + Math.random(),
variant: 'error',
Expand Down Expand Up @@ -348,9 +345,7 @@ const ToolDialog = ({ show, dialogProps, onUseTemplate, onCancel, onConfirm, set
}
} catch (error) {
enqueueSnackbar({
message: `Failed to save Tool: ${
typeof error.response.data === 'object' ? error.response.data.message : error.response.data
}`,
message: `Failed to save Tool: ${getAxiosErrorMessage(error)}`,
options: {
key: new Date().getTime() + Math.random(),
variant: 'error',
Expand Down Expand Up @@ -395,9 +390,7 @@ const ToolDialog = ({ show, dialogProps, onUseTemplate, onCancel, onConfirm, set
}
} catch (error) {
enqueueSnackbar({
message: `Failed to delete Tool: ${
typeof error.response.data === 'object' ? error.response.data.message : error.response.data
}`,
message: `Failed to delete Tool: ${getAxiosErrorMessage(error)}`,
options: {
key: new Date().getTime() + Math.random(),
variant: 'error',
Expand Down