From 8a7d3e9950bfb363e92bd58200b9366053102d02 Mon Sep 17 00:00:00 2001 From: Jeremy Date: Tue, 13 May 2025 19:01:28 -0700 Subject: [PATCH] feat: improve error messages (#1006) --- app/(chat)/api/chat/route.ts | 63 ++++++------- app/(chat)/api/document/route.ts | 41 ++++++--- app/(chat)/api/history/route.ts | 35 ++++---- app/(chat)/api/suggestions/route.ts | 12 ++- app/(chat)/api/vote/route.ts | 27 +++--- components/chat.tsx | 14 +-- lib/db/queries.ts | 131 ++++++++++++++++----------- lib/errors.ts | 132 ++++++++++++++++++++++++++++ lib/utils.ts | 42 +++++---- package.json | 2 +- tests/e2e/session.test.ts | 3 +- tests/routes/chat.test.ts | 16 ++-- tests/routes/document.test.ts | 26 +++--- 13 files changed, 370 insertions(+), 174 deletions(-) create mode 100644 lib/errors.ts diff --git a/app/(chat)/api/chat/route.ts b/app/(chat)/api/chat/route.ts index 705ef2d..1526e96 100644 --- a/app/(chat)/api/chat/route.ts +++ b/app/(chat)/api/chat/route.ts @@ -35,6 +35,7 @@ import { import { after } from 'next/server'; import type { Chat } from '@/lib/db/schema'; import { differenceInSeconds } from 'date-fns'; +import { ChatSDKError } from '@/lib/errors'; export const maxDuration = 60; @@ -67,7 +68,7 @@ export async function POST(request: Request) { const json = await request.json(); requestBody = postRequestBodySchema.parse(json); } catch (_) { - return new Response('Invalid request body', { status: 400 }); + return new ChatSDKError('bad_request:api').toResponse(); } try { @@ -77,7 +78,7 @@ export async function POST(request: Request) { const session = await auth(); if (!session?.user) { - return new Response('Unauthorized', { status: 401 }); + return new ChatSDKError('unauthorized:chat').toResponse(); } const userType: UserType = session.user.type; @@ -88,12 +89,7 @@ export async function POST(request: Request) { }); if (messageCount > entitlementsByUserType[userType].maxMessagesPerDay) { - return new Response( - 'You have exceeded your maximum number of messages for the day! Please try again later.', - { - status: 429, - }, - ); + return new ChatSDKError('rate_limit:chat').toResponse(); } const chat = await getChatById({ id }); @@ -111,7 +107,7 @@ export async function POST(request: Request) { }); } else { if (chat.userId !== session.user.id) { - return new Response('Forbidden', { status: 403 }); + return new ChatSDKError('forbidden:chat').toResponse(); } } @@ -237,10 +233,10 @@ export async function POST(request: Request) { } else { return new Response(stream); } - } catch (_) { - return new Response('An error occurred while processing your request!', { - status: 500, - }); + } catch (error) { + if (error instanceof ChatSDKError) { + return error.toResponse(); + } } } @@ -256,13 +252,13 @@ export async function GET(request: Request) { const chatId = searchParams.get('chatId'); if (!chatId) { - return new Response('id is required', { status: 400 }); + return new ChatSDKError('bad_request:api').toResponse(); } const session = await auth(); if (!session?.user) { - return new Response('Unauthorized', { status: 401 }); + return new ChatSDKError('unauthorized:chat').toResponse(); } let chat: Chat; @@ -270,27 +266,27 @@ export async function GET(request: Request) { try { chat = await getChatById({ id: chatId }); } catch { - return new Response('Not found', { status: 404 }); + return new ChatSDKError('not_found:chat').toResponse(); } if (!chat) { - return new Response('Not found', { status: 404 }); + return new ChatSDKError('not_found:chat').toResponse(); } if (chat.visibility === 'private' && chat.userId !== session.user.id) { - return new Response('Forbidden', { status: 403 }); + return new ChatSDKError('forbidden:chat').toResponse(); } const streamIds = await getStreamIdsByChatId({ chatId }); if (!streamIds.length) { - return new Response('No streams found', { status: 404 }); + return new ChatSDKError('not_found:stream').toResponse(); } const recentStreamId = streamIds.at(-1); if (!recentStreamId) { - return new Response('No recent stream found', { status: 404 }); + return new ChatSDKError('not_found:stream').toResponse(); } const emptyDataStream = createDataStream({ @@ -344,29 +340,22 @@ export async function DELETE(request: Request) { const id = searchParams.get('id'); if (!id) { - return new Response('Not Found', { status: 404 }); + return new ChatSDKError('bad_request:api').toResponse(); } const session = await auth(); - if (!session?.user?.id) { - return new Response('Unauthorized', { status: 401 }); + if (!session?.user) { + return new ChatSDKError('unauthorized:chat').toResponse(); } - try { - const chat = await getChatById({ id }); + const chat = await getChatById({ id }); - if (chat.userId !== session.user.id) { - return new Response('Forbidden', { status: 403 }); - } - - const deletedChat = await deleteChatById({ id }); - - return Response.json(deletedChat, { status: 200 }); - } catch (error) { - console.error(error); - return new Response('An error occurred while processing your request!', { - status: 500, - }); + if (chat.userId !== session.user.id) { + return new ChatSDKError('forbidden:chat').toResponse(); } + + const deletedChat = await deleteChatById({ id }); + + return Response.json(deletedChat, { status: 200 }); } diff --git a/app/(chat)/api/document/route.ts b/app/(chat)/api/document/route.ts index a1c0884..de41836 100644 --- a/app/(chat)/api/document/route.ts +++ b/app/(chat)/api/document/route.ts @@ -5,19 +5,23 @@ import { getDocumentsById, saveDocument, } from '@/lib/db/queries'; +import { ChatSDKError } from '@/lib/errors'; export async function GET(request: Request) { const { searchParams } = new URL(request.url); const id = searchParams.get('id'); if (!id) { - return new Response('Missing id', { status: 400 }); + return new ChatSDKError( + 'bad_request:api', + 'Parameter id is missing', + ).toResponse(); } const session = await auth(); - if (!session?.user?.id) { - return new Response('Unauthorized', { status: 401 }); + if (!session?.user) { + return new ChatSDKError('unauthorized:document').toResponse(); } const documents = await getDocumentsById({ id }); @@ -25,11 +29,11 @@ export async function GET(request: Request) { const [document] = documents; if (!document) { - return new Response('Not found', { status: 404 }); + return new ChatSDKError('not_found:document').toResponse(); } if (document.userId !== session.user.id) { - return new Response('Forbidden', { status: 403 }); + return new ChatSDKError('forbidden:document').toResponse(); } return Response.json(documents, { status: 200 }); @@ -40,13 +44,16 @@ export async function POST(request: Request) { const id = searchParams.get('id'); if (!id) { - return new Response('Missing id', { status: 400 }); + return new ChatSDKError( + 'bad_request:api', + 'Parameter id is required.', + ).toResponse(); } const session = await auth(); - if (!session?.user?.id) { - return new Response('Unauthorized', { status: 401 }); + if (!session?.user) { + return new ChatSDKError('not_found:document').toResponse(); } const { @@ -62,7 +69,7 @@ export async function POST(request: Request) { const [document] = documents; if (document.userId !== session.user.id) { - return new Response('Forbidden', { status: 403 }); + return new ChatSDKError('forbidden:document').toResponse(); } } @@ -83,17 +90,23 @@ export async function DELETE(request: Request) { const timestamp = searchParams.get('timestamp'); if (!id) { - return new Response('Missing id', { status: 400 }); + return new ChatSDKError( + 'bad_request:api', + 'Parameter id is required.', + ).toResponse(); } if (!timestamp) { - return new Response('Missing timestamp', { status: 400 }); + return new ChatSDKError( + 'bad_request:api', + 'Parameter timestamp is required.', + ).toResponse(); } const session = await auth(); - if (!session?.user?.id) { - return new Response('Unauthorized', { status: 401 }); + if (!session?.user) { + return new ChatSDKError('unauthorized:document').toResponse(); } const documents = await getDocumentsById({ id }); @@ -101,7 +114,7 @@ export async function DELETE(request: Request) { const [document] = documents; if (document.userId !== session.user.id) { - return new Response('Unauthorized', { status: 401 }); + return new ChatSDKError('forbidden:document').toResponse(); } const documentsDeleted = await deleteDocumentsByIdAfterTimestamp({ diff --git a/app/(chat)/api/history/route.ts b/app/(chat)/api/history/route.ts index acfd538..e28290c 100644 --- a/app/(chat)/api/history/route.ts +++ b/app/(chat)/api/history/route.ts @@ -1,37 +1,34 @@ import { auth } from '@/app/(auth)/auth'; -import { NextRequest } from 'next/server'; +import type { NextRequest } from 'next/server'; import { getChatsByUserId } from '@/lib/db/queries'; +import { ChatSDKError } from '@/lib/errors'; export async function GET(request: NextRequest) { const { searchParams } = request.nextUrl; - const limit = parseInt(searchParams.get('limit') || '10'); + const limit = Number.parseInt(searchParams.get('limit') || '10'); const startingAfter = searchParams.get('starting_after'); const endingBefore = searchParams.get('ending_before'); if (startingAfter && endingBefore) { - return Response.json( - 'Only one of starting_after or ending_before can be provided!', - { status: 400 }, - ); + return new ChatSDKError( + 'bad_request:api', + 'Only one of starting_after or ending_before can be provided.', + ).toResponse(); } const session = await auth(); - if (!session?.user?.id) { - return Response.json('Unauthorized!', { status: 401 }); + if (!session?.user) { + return new ChatSDKError('unauthorized:chat').toResponse(); } - try { - const chats = await getChatsByUserId({ - id: session.user.id, - limit, - startingAfter, - endingBefore, - }); + const chats = await getChatsByUserId({ + id: session.user.id, + limit, + startingAfter, + endingBefore, + }); - return Response.json(chats); - } catch (_) { - return Response.json('Failed to fetch chats!', { status: 500 }); - } + return Response.json(chats); } diff --git a/app/(chat)/api/suggestions/route.ts b/app/(chat)/api/suggestions/route.ts index 542bc61..ffbda2f 100644 --- a/app/(chat)/api/suggestions/route.ts +++ b/app/(chat)/api/suggestions/route.ts @@ -1,18 +1,22 @@ import { auth } from '@/app/(auth)/auth'; import { getSuggestionsByDocumentId } from '@/lib/db/queries'; +import { ChatSDKError } from '@/lib/errors'; export async function GET(request: Request) { const { searchParams } = new URL(request.url); const documentId = searchParams.get('documentId'); if (!documentId) { - return new Response('Not Found', { status: 404 }); + return new ChatSDKError( + 'bad_request:api', + 'Parameter documentId is required.', + ).toResponse(); } const session = await auth(); - if (!session || !session.user) { - return new Response('Unauthorized', { status: 401 }); + if (!session?.user) { + return new ChatSDKError('unauthorized:suggestions').toResponse(); } const suggestions = await getSuggestionsByDocumentId({ @@ -26,7 +30,7 @@ export async function GET(request: Request) { } if (suggestion.userId !== session.user.id) { - return new Response('Unauthorized', { status: 401 }); + return new ChatSDKError('forbidden:api').toResponse(); } return Response.json(suggestions, { status: 200 }); diff --git a/app/(chat)/api/vote/route.ts b/app/(chat)/api/vote/route.ts index 60fc8fc..b3d7e45 100644 --- a/app/(chat)/api/vote/route.ts +++ b/app/(chat)/api/vote/route.ts @@ -1,28 +1,32 @@ import { auth } from '@/app/(auth)/auth'; import { getChatById, getVotesByChatId, voteMessage } from '@/lib/db/queries'; +import { ChatSDKError } from '@/lib/errors'; export async function GET(request: Request) { const { searchParams } = new URL(request.url); const chatId = searchParams.get('chatId'); if (!chatId) { - return new Response('chatId is required', { status: 400 }); + return new ChatSDKError( + 'bad_request:api', + 'Parameter chatId is required.', + ).toResponse(); } const session = await auth(); - if (!session || !session.user || !session.user.email) { - return new Response('Unauthorized', { status: 401 }); + if (!session?.user) { + return new ChatSDKError('unauthorized:vote').toResponse(); } const chat = await getChatById({ id: chatId }); if (!chat) { - return new Response('Chat not found', { status: 404 }); + return new ChatSDKError('not_found:chat').toResponse(); } if (chat.userId !== session.user.id) { - return new Response('Unauthorized', { status: 401 }); + return new ChatSDKError('forbidden:vote').toResponse(); } const votes = await getVotesByChatId({ id: chatId }); @@ -39,23 +43,26 @@ export async function PATCH(request: Request) { await request.json(); if (!chatId || !messageId || !type) { - return new Response('messageId and type are required', { status: 400 }); + return new ChatSDKError( + 'bad_request:api', + 'Parameters chatId, messageId, and type are required.', + ).toResponse(); } const session = await auth(); - if (!session || !session.user || !session.user.email) { - return new Response('Unauthorized', { status: 401 }); + if (!session?.user) { + return new ChatSDKError('unauthorized:vote').toResponse(); } const chat = await getChatById({ id: chatId }); if (!chat) { - return new Response('Chat not found', { status: 404 }); + return new ChatSDKError('not_found:vote').toResponse(); } if (chat.userId !== session.user.id) { - return new Response('Unauthorized', { status: 401 }); + return new ChatSDKError('forbidden:vote').toResponse(); } await voteMessage({ diff --git a/components/chat.tsx b/components/chat.tsx index f4ad074..9108772 100644 --- a/components/chat.tsx +++ b/components/chat.tsx @@ -6,7 +6,7 @@ import { useEffect, useState } from 'react'; import useSWR, { useSWRConfig } from 'swr'; import { ChatHeader } from '@/components/chat-header'; import type { Vote } from '@/lib/db/schema'; -import { fetcher, generateUUID } from '@/lib/utils'; +import { fetcher, fetchWithErrorHandlers, generateUUID } from '@/lib/utils'; import { Artifact } from './artifact'; import { MultimodalInput } from './multimodal-input'; import { Messages } from './messages'; @@ -19,6 +19,7 @@ import type { Session } from 'next-auth'; import { useSearchParams } from 'next/navigation'; import { useChatVisibility } from '@/hooks/use-chat-visibility'; import { useAutoResume } from '@/hooks/use-auto-resume'; +import { ChatSDKError } from '@/lib/errors'; export function Chat({ id, @@ -62,6 +63,7 @@ export function Chat({ experimental_throttle: 100, sendExtraMessageFields: true, generateId: generateUUID, + fetch: fetchWithErrorHandlers, experimental_prepareRequestBody: (body) => ({ id, message: body.messages.at(-1), @@ -72,10 +74,12 @@ export function Chat({ mutate(unstable_serialize(getChatHistoryPaginationKey)); }, onError: (error) => { - toast({ - type: 'error', - description: error.message, - }); + if (error instanceof ChatSDKError) { + toast({ + type: 'error', + description: error.message, + }); + } }, }); diff --git a/lib/db/queries.ts b/lib/db/queries.ts index e1215db..2f8b2b2 100644 --- a/lib/db/queries.ts +++ b/lib/db/queries.ts @@ -32,6 +32,7 @@ import type { ArtifactKind } from '@/components/artifact'; import { generateUUID } from '../utils'; import { generateHashedPassword } from './utils'; import type { VisibilityType } from '@/components/visibility-selector'; +import { ChatSDKError } from '../errors'; // Optionally, if not using email/pass login, you can // use the Drizzle adapter for Auth.js / NextAuth @@ -45,8 +46,10 @@ export async function getUser(email: string): Promise> { try { return await db.select().from(user).where(eq(user.email, email)); } catch (error) { - console.error('Failed to get user from database'); - throw error; + throw new ChatSDKError( + 'bad_request:database', + 'Failed to get user by email', + ); } } @@ -56,8 +59,7 @@ export async function createUser(email: string, password: string) { try { return await db.insert(user).values({ email, password: hashedPassword }); } catch (error) { - console.error('Failed to create user in database'); - throw error; + throw new ChatSDKError('bad_request:database', 'Failed to create user'); } } @@ -71,8 +73,10 @@ export async function createGuestUser() { email: user.email, }); } catch (error) { - console.error('Failed to create guest user in database'); - throw error; + throw new ChatSDKError( + 'bad_request:database', + 'Failed to create guest user', + ); } } @@ -96,8 +100,7 @@ export async function saveChat({ visibility, }); } catch (error) { - console.error('Failed to save chat in database'); - throw error; + throw new ChatSDKError('bad_request:database', 'Failed to save chat'); } } @@ -113,8 +116,10 @@ export async function deleteChatById({ id }: { id: string }) { .returning(); return chatsDeleted; } catch (error) { - console.error('Failed to delete chat by id from database'); - throw error; + throw new ChatSDKError( + 'bad_request:database', + 'Failed to delete chat by id', + ); } } @@ -154,7 +159,10 @@ export async function getChatsByUserId({ .limit(1); if (!selectedChat) { - throw new Error(`Chat with id ${startingAfter} not found`); + throw new ChatSDKError( + 'not_found:database', + `Chat with id ${startingAfter} not found`, + ); } filteredChats = await query(gt(chat.createdAt, selectedChat.createdAt)); @@ -166,7 +174,10 @@ export async function getChatsByUserId({ .limit(1); if (!selectedChat) { - throw new Error(`Chat with id ${endingBefore} not found`); + throw new ChatSDKError( + 'not_found:database', + `Chat with id ${endingBefore} not found`, + ); } filteredChats = await query(lt(chat.createdAt, selectedChat.createdAt)); @@ -181,8 +192,10 @@ export async function getChatsByUserId({ hasMore, }; } catch (error) { - console.error('Failed to get chats by user from database'); - throw error; + throw new ChatSDKError( + 'bad_request:database', + 'Failed to get chats by user id', + ); } } @@ -191,8 +204,7 @@ export async function getChatById({ id }: { id: string }) { const [selectedChat] = await db.select().from(chat).where(eq(chat.id, id)); return selectedChat; } catch (error) { - console.error('Failed to get chat by id from database'); - throw error; + throw new ChatSDKError('bad_request:database', 'Failed to get chat by id'); } } @@ -204,8 +216,7 @@ export async function saveMessages({ try { return await db.insert(message).values(messages); } catch (error) { - console.error('Failed to save messages in database', error); - throw error; + throw new ChatSDKError('bad_request:database', 'Failed to save messages'); } } @@ -217,8 +228,10 @@ export async function getMessagesByChatId({ id }: { id: string }) { .where(eq(message.chatId, id)) .orderBy(asc(message.createdAt)); } catch (error) { - console.error('Failed to get messages by chat id from database', error); - throw error; + throw new ChatSDKError( + 'bad_request:database', + 'Failed to get messages by chat id', + ); } } @@ -249,8 +262,7 @@ export async function voteMessage({ isUpvoted: type === 'up', }); } catch (error) { - console.error('Failed to upvote message in database', error); - throw error; + throw new ChatSDKError('bad_request:database', 'Failed to vote message'); } } @@ -258,8 +270,10 @@ export async function getVotesByChatId({ id }: { id: string }) { try { return await db.select().from(vote).where(eq(vote.chatId, id)); } catch (error) { - console.error('Failed to get votes by chat id from database', error); - throw error; + throw new ChatSDKError( + 'bad_request:database', + 'Failed to get votes by chat id', + ); } } @@ -289,8 +303,7 @@ export async function saveDocument({ }) .returning(); } catch (error) { - console.error('Failed to save document in database'); - throw error; + throw new ChatSDKError('bad_request:database', 'Failed to save document'); } } @@ -304,8 +317,10 @@ export async function getDocumentsById({ id }: { id: string }) { return documents; } catch (error) { - console.error('Failed to get document by id from database'); - throw error; + throw new ChatSDKError( + 'bad_request:database', + 'Failed to get documents by id', + ); } } @@ -319,8 +334,10 @@ export async function getDocumentById({ id }: { id: string }) { return selectedDocument; } catch (error) { - console.error('Failed to get document by id from database'); - throw error; + throw new ChatSDKError( + 'bad_request:database', + 'Failed to get document by id', + ); } } @@ -346,10 +363,10 @@ export async function deleteDocumentsByIdAfterTimestamp({ .where(and(eq(document.id, id), gt(document.createdAt, timestamp))) .returning(); } catch (error) { - console.error( - 'Failed to delete documents by id after timestamp from database', + throw new ChatSDKError( + 'bad_request:database', + 'Failed to delete documents by id after timestamp', ); - throw error; } } @@ -361,8 +378,10 @@ export async function saveSuggestions({ try { return await db.insert(suggestion).values(suggestions); } catch (error) { - console.error('Failed to save suggestions in database'); - throw error; + throw new ChatSDKError( + 'bad_request:database', + 'Failed to save suggestions', + ); } } @@ -377,10 +396,10 @@ export async function getSuggestionsByDocumentId({ .from(suggestion) .where(and(eq(suggestion.documentId, documentId))); } catch (error) { - console.error( - 'Failed to get suggestions by document version from database', + throw new ChatSDKError( + 'bad_request:database', + 'Failed to get suggestions by document id', ); - throw error; } } @@ -388,8 +407,10 @@ export async function getMessageById({ id }: { id: string }) { try { return await db.select().from(message).where(eq(message.id, id)); } catch (error) { - console.error('Failed to get message by id from database'); - throw error; + throw new ChatSDKError( + 'bad_request:database', + 'Failed to get message by id', + ); } } @@ -424,10 +445,10 @@ export async function deleteMessagesByChatIdAfterTimestamp({ ); } } catch (error) { - console.error( - 'Failed to delete messages by id after timestamp from database', + throw new ChatSDKError( + 'bad_request:database', + 'Failed to delete messages by chat id after timestamp', ); - throw error; } } @@ -441,8 +462,10 @@ export async function updateChatVisiblityById({ try { return await db.update(chat).set({ visibility }).where(eq(chat.id, chatId)); } catch (error) { - console.error('Failed to update chat visibility in database'); - throw error; + throw new ChatSDKError( + 'bad_request:database', + 'Failed to update chat visibility by id', + ); } } @@ -470,10 +493,10 @@ export async function getMessageCountByUserId({ return stats?.count ?? 0; } catch (error) { - console.error( - 'Failed to get message count by user id for the last 24 hours from database', + throw new ChatSDKError( + 'bad_request:database', + 'Failed to get message count by user id', ); - throw error; } } @@ -489,8 +512,10 @@ export async function createStreamId({ .insert(stream) .values({ id: streamId, chatId, createdAt: new Date() }); } catch (error) { - console.error('Failed to create stream id in database'); - throw error; + throw new ChatSDKError( + 'bad_request:database', + 'Failed to create stream id', + ); } } @@ -505,7 +530,9 @@ export async function getStreamIdsByChatId({ chatId }: { chatId: string }) { return streamIds.map(({ id }) => id); } catch (error) { - console.error('Failed to get stream ids by chat id from database'); - throw error; + throw new ChatSDKError( + 'bad_request:database', + 'Failed to get stream ids by chat id', + ); } } diff --git a/lib/errors.ts b/lib/errors.ts new file mode 100644 index 0000000..339a272 --- /dev/null +++ b/lib/errors.ts @@ -0,0 +1,132 @@ +export type ErrorType = + | 'bad_request' + | 'unauthorized' + | 'forbidden' + | 'not_found' + | 'rate_limit' + | 'offline'; + +export type Surface = + | 'chat' + | 'auth' + | 'api' + | 'stream' + | 'database' + | 'history' + | 'vote' + | 'document' + | 'suggestions'; + +export type ErrorCode = `${ErrorType}:${Surface}`; + +export type ErrorVisibility = 'response' | 'log' | 'none'; + +export const visibilityBySurface: Record = { + database: 'log', + chat: 'response', + auth: 'response', + stream: 'response', + api: 'response', + history: 'response', + vote: 'response', + document: 'response', + suggestions: 'response', +}; + +export class ChatSDKError extends Error { + public type: ErrorType; + public surface: Surface; + public statusCode: number; + + constructor(errorCode: ErrorCode, cause?: string) { + super(); + + const [type, surface] = errorCode.split(':'); + + this.type = type as ErrorType; + this.cause = cause; + this.surface = surface as Surface; + this.message = getMessageByErrorCode(errorCode); + this.statusCode = getStatusCodeByType(this.type); + } + + public toResponse() { + const code: ErrorCode = `${this.type}:${this.surface}`; + const visibility = visibilityBySurface[this.surface]; + + const { message, cause, statusCode } = this; + + if (visibility === 'log') { + console.error({ + code, + message, + cause, + }); + + return Response.json( + { code: '', message: 'Something went wrong. Please try again later.' }, + { status: statusCode }, + ); + } + + return Response.json({ code, message, cause }, { status: statusCode }); + } +} + +export function getMessageByErrorCode(errorCode: ErrorCode): string { + if (errorCode.includes('database')) { + return 'An error occurred while executing a database query.'; + } + + switch (errorCode) { + case 'bad_request:api': + return "The request couldn't be processed. Please check your input and try again."; + + case 'unauthorized:auth': + return 'You need to sign in before continuing.'; + case 'forbidden:auth': + return 'Your account does not have access to this feature.'; + + case 'rate_limit:chat': + return 'You have exceeded your maximum number of messages for the day. Please try again later.'; + case 'not_found:chat': + return 'The requested chat was not found. Please check the chat ID and try again.'; + case 'forbidden:chat': + return 'This chat belongs to another user. Please check the chat ID and try again.'; + case 'unauthorized:chat': + return 'You need to sign in to view this chat. Please sign in and try again.'; + case 'offline:chat': + return "We're having trouble sending your message. Please check your internet connection and try again."; + + case 'not_found:document': + return 'The requested document was not found. Please check the document ID and try again.'; + case 'forbidden:document': + return 'This document belongs to another user. Please check the document ID and try again.'; + case 'unauthorized:document': + return 'You need to sign in to view this document. Please sign in and try again.'; + case 'bad_request:document': + return 'The request to create or update the document was invalid. Please check your input and try again.'; + + default: + return 'Something went wrong. Please try again later.'; + } +} + +function getStatusCodeByType(type: ErrorType) { + switch (type) { + case 'bad_request': + return 400; + case 'unauthorized': + return 401; + case 'forbidden': + return 403; + case 'not_found': + return 404; + case 'rate_limit': + return 429; + case 'offline': + return 503; + default: + return 500; + } +} diff --git a/lib/utils.ts b/lib/utils.ts index 6b017db..e116c74 100644 --- a/lib/utils.ts +++ b/lib/utils.ts @@ -2,32 +2,44 @@ import type { CoreAssistantMessage, CoreToolMessage, UIMessage } from 'ai'; import { type ClassValue, clsx } from 'clsx'; import { twMerge } from 'tailwind-merge'; import type { Document } from '@/lib/db/schema'; +import { ChatSDKError, type ErrorCode } from './errors'; export function cn(...inputs: ClassValue[]) { return twMerge(clsx(inputs)); } -interface ApplicationError extends Error { - info: string; - status: number; -} - export const fetcher = async (url: string) => { - const res = await fetch(url); + const response = await fetch(url); - if (!res.ok) { - const error = new Error( - 'An error occurred while fetching the data.', - ) as ApplicationError; + if (!response.ok) { + const { code, cause } = await response.json(); + throw new ChatSDKError(code as ErrorCode, cause); + } - error.info = await res.json(); - error.status = res.status; + return response.json(); +}; + +export async function fetchWithErrorHandlers( + input: RequestInfo | URL, + init?: RequestInit, +) { + try { + const response = await fetch(input, init); + + if (!response.ok) { + const { code, cause } = await response.json(); + throw new ChatSDKError(code as ErrorCode, cause); + } + + return response; + } catch (error: unknown) { + if (typeof navigator !== 'undefined' && !navigator.onLine) { + throw new ChatSDKError('offline:chat'); + } throw error; } - - return res.json(); -}; +} export function getLocalStorage(key: string) { if (typeof window !== 'undefined') { diff --git a/package.json b/package.json index 737b94f..f6e96b3 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "ai-chatbot", - "version": "3.0.22", + "version": "3.0.23", "private": true, "scripts": { "dev": "next dev --turbo", diff --git a/tests/e2e/session.test.ts b/tests/e2e/session.test.ts index 2e1b652..e3aae08 100644 --- a/tests/e2e/session.test.ts +++ b/tests/e2e/session.test.ts @@ -2,6 +2,7 @@ import { expect, test } from '../fixtures'; import { AuthPage } from '../pages/auth'; import { generateRandomTestUser } from '../helpers'; import { ChatPage } from '../pages/chat'; +import { getMessageByErrorCode } from '@/lib/errors'; test.describe .serial('Guest Session', () => { @@ -201,7 +202,7 @@ test.describe('Entitlements', () => { await chatPage.sendUserMessage('Why is the sky blue?'); await chatPage.expectToastToContain( - 'You have exceeded your maximum number of messages for the day! Please try again later.', + getMessageByErrorCode('rate_limit:chat'), ); }); }); diff --git a/tests/routes/chat.test.ts b/tests/routes/chat.test.ts index 3d744da..ec610c1 100644 --- a/tests/routes/chat.test.ts +++ b/tests/routes/chat.test.ts @@ -1,6 +1,7 @@ import { generateUUID } from '@/lib/utils'; import { expect, test } from '../fixtures'; import { TEST_PROMPTS } from '../prompts/routes'; +import { getMessageByErrorCode } from '@/lib/errors'; const chatIdsCreatedByAda: Array = []; @@ -14,8 +15,9 @@ test.describe }); expect(response.status()).toBe(400); - const text = await response.text(); - expect(text).toEqual('Invalid request body'); + const { code, message } = await response.json(); + expect(code).toEqual('bad_request:api'); + expect(message).toEqual(getMessageByErrorCode('bad_request:api')); }); test('Ada can invoke chat generation', async ({ adaContext }) => { @@ -55,8 +57,9 @@ test.describe }); expect(response.status()).toBe(403); - const text = await response.text(); - expect(text).toEqual('Forbidden'); + const { code, message } = await response.json(); + expect(code).toEqual('forbidden:chat'); + expect(message).toEqual(getMessageByErrorCode('forbidden:chat')); }); test("Babbage cannot delete Ada's chat", async ({ babbageContext }) => { @@ -67,8 +70,9 @@ test.describe ); expect(response.status()).toBe(403); - const text = await response.text(); - expect(text).toEqual('Forbidden'); + const { code, message } = await response.json(); + expect(code).toEqual('forbidden:chat'); + expect(message).toEqual(getMessageByErrorCode('forbidden:chat')); }); test('Ada can delete her own chat', async ({ adaContext }) => { diff --git a/tests/routes/document.test.ts b/tests/routes/document.test.ts index 715be14..22b6232 100644 --- a/tests/routes/document.test.ts +++ b/tests/routes/document.test.ts @@ -1,6 +1,7 @@ import type { Document } from '@/lib/db/schema'; import { generateUUID } from '@/lib/utils'; import { expect, test } from '../fixtures'; +import { getMessageByErrorCode } from '@/lib/errors'; const documentsCreatedByAda: Array = []; @@ -12,8 +13,9 @@ test.describe const response = await adaContext.request.get('/api/document'); expect(response.status()).toBe(400); - const text = await response.text(); - expect(text).toEqual('Missing id'); + const { code, message } = await response.json(); + expect(code).toEqual('bad_request:api'); + expect(message).toEqual(getMessageByErrorCode(code)); }); test('Ada cannot retrieve a document that does not exist', async ({ @@ -26,8 +28,9 @@ test.describe ); expect(response.status()).toBe(404); - const text = await response.text(); - expect(text).toEqual('Not found'); + const { code, message } = await response.json(); + expect(code).toEqual('not_found:document'); + expect(message).toEqual(getMessageByErrorCode(code)); }); test('Ada can create a document', async ({ adaContext }) => { @@ -118,8 +121,9 @@ test.describe const response = await adaContext.request.delete(`/api/document`); expect(response.status()).toBe(400); - const text = await response.text(); - expect(text).toEqual('Missing id'); + const { code, message } = await response.json(); + expect(code).toEqual('bad_request:api'); + expect(message).toEqual(getMessageByErrorCode(code)); }); test('Ada cannot delete a document without specifying a timestamp', async ({ @@ -132,8 +136,9 @@ test.describe ); expect(response.status()).toBe(400); - const text = await response.text(); - expect(text).toEqual('Missing timestamp'); + const { code, message } = await response.json(); + expect(code).toEqual('bad_request:api'); + expect(message).toEqual(getMessageByErrorCode(code)); }); test('Ada can delete a document by specifying id and timestamp', async ({ @@ -187,8 +192,9 @@ test.describe ); expect(response.status()).toBe(403); - const text = await response.text(); - expect(text).toEqual('Forbidden'); + const { code, message } = await response.json(); + expect(code).toEqual('forbidden:document'); + expect(message).toEqual(getMessageByErrorCode(code)); }); test("Ada's documents did not get updated", async ({ adaContext }) => {