this repo has no description
0
fork

Configure Feed

Select the types of activity you want to include in your feed.

Add Upstash rate limiting to AI chat endpoints with error toast notifications (#14)

## Add AI rate limiting and improve API error handling

### Rate Limiting

Adds per-user rate limiting for AI endpoints using Upstash Redis and `@upstash/ratelimit`. Users are limited to **10 requests per minute** and **120 requests per hour**. When a limit is exceeded, the API returns a `429` response with a human-readable retry message (e.g. "Rate limit exceeded. Please try again in 2 minutes.") along with standard `Retry-After`, `X-RateLimit-Limit`, `X-RateLimit-Remaining`, and `X-RateLimit-Reset` headers. Rate limiting is enforced before inserting user messages, so messages are only persisted when the request is allowed through.

Also fixes a bug where the title generation condition checked `messages.length === 1` instead of `messages.length === 0`, and corrects the message list passed to `streamChat` to include the new user message that was previously being omitted.

### API Error Handling

Introduces a shared `ApiError` class and `expectApiResponse` helper that extracts error messages from API responses (preferring the `message` field from JSON bodies) and throws typed errors. All hooks now use this helper instead of inline `!response.ok` checks.

The `QueryClient` is configured with global `QueryCache` and `MutationCache` error handlers that display `ApiError` messages as toast notifications via `sonner`, giving users visible feedback when API calls fail, including rate limit errors.

### Environment

Adds `.env.example` files for both `apps/api` and `apps/web` documenting the required environment variables, including the new `UPSTASH_REDIS_REST_URL` and `UPSTASH_REDIS_REST_TOKEN` variables. The `.gitignore` is updated to allow `.env.example` files to be committed.

authored by

James Blair and committed by
GitHub
ad50a76f 4ca41cc7

+351 -114
+1
.gitignore
··· 2 2 node_modules/ 3 3 .tanstack/ 4 4 .env* 5 + !.env.example 5 6 *.db
+7
apps/api/.env.example
··· 1 + DB_FILE_NAME=sqlite.db 2 + OPENAI_API_KEY= 3 + CLERK_PUBLISHABLE_KEY= 4 + CLERK_SECRET_KEY= 5 + UPSTASH_REDIS_REST_URL= 6 + UPSTASH_REDIS_REST_TOKEN= 7 + CLERK_AUTHORIZED_PARTIES=http://localhost:3000
+2
apps/api/package.json
··· 18 18 "@clerk/backend": "^3.4.1", 19 19 "@hono/zod-validator": "^0.7.6", 20 20 "@t3-oss/env-core": "^0.13.11", 21 + "@upstash/ratelimit": "^2.0.6", 22 + "@upstash/redis": "^1.35.6", 21 23 "ai": "^6.0.168", 22 24 "drizzle-orm": "^0.45.2", 23 25 "hono": "^4.12.15",
+2
apps/api/src/env.ts
··· 7 7 OPENAI_API_KEY: z.string().min(1), 8 8 CLERK_PUBLISHABLE_KEY: z.string().min(1), 9 9 CLERK_SECRET_KEY: z.string().min(1), 10 + UPSTASH_REDIS_REST_URL: z.url(), 11 + UPSTASH_REDIS_REST_TOKEN: z.string().min(1), 10 12 CLERK_AUTHORIZED_PARTIES: z 11 13 .string() 12 14 .min(1)
+32
apps/api/src/lib/ai/ratelimit.ts
··· 1 + import { Ratelimit } from "@upstash/ratelimit"; 2 + import { Redis } from "@upstash/redis"; 3 + import { env } from "../../env"; 4 + 5 + const redis = new Redis({ 6 + url: env.UPSTASH_REDIS_REST_URL, 7 + token: env.UPSTASH_REDIS_REST_TOKEN, 8 + }); 9 + 10 + const rateLimits = [ 11 + new Ratelimit({ 12 + redis, 13 + limiter: Ratelimit.fixedWindow(10, "1 m"), 14 + prefix: "ratelimit:ai:minute", 15 + analytics: true, 16 + }), 17 + new Ratelimit({ 18 + redis, 19 + limiter: Ratelimit.fixedWindow(120, "1 h"), 20 + prefix: "ratelimit:ai:hour", 21 + analytics: true, 22 + }), 23 + ] as const; 24 + 25 + export async function getExceededRateLimit({ userId }: { userId: string }) { 26 + const identifier = `user:${userId}`; 27 + const results = await Promise.all( 28 + rateLimits.map((ratelimit) => ratelimit.limit(identifier)), 29 + ); 30 + 31 + return results.find((result) => !result.success) ?? null; 32 + }
+68 -15
apps/api/src/routes/chats.ts
··· 2 2 import { and, asc, desc, eq } from "drizzle-orm"; 3 3 import { Hono } from "hono"; 4 4 import { streamChat } from "../lib/ai/chat"; 5 + import { getExceededRateLimit } from "../lib/ai/ratelimit"; 5 6 import { generateChatTitle } from "../lib/ai/title"; 6 7 import { characters } from "../lib/characters"; 7 8 import { db, schema } from "../lib/db"; 8 9 import { nanoid } from "../lib/id"; 9 10 import { chatSchema, messageSchema } from "../lib/schema"; 10 11 import { requireAuth } from "../middlewares/auth"; 12 + 13 + function formatRetryAfter(seconds: number) { 14 + if (seconds <= 60) { 15 + return `${seconds} second${seconds === 1 ? "" : "s"}`; 16 + } 17 + 18 + const minutes = Math.ceil(seconds / 60); 19 + 20 + return `${minutes} minute${minutes === 1 ? "" : "s"}`; 21 + } 22 + 23 + function createRateLimitResponse( 24 + limit: NonNullable<Awaited<ReturnType<typeof getExceededRateLimit>>>, 25 + ) { 26 + const retryAfterSeconds = Math.max( 27 + 0, 28 + Math.ceil((limit.reset - Date.now()) / 1000), 29 + ); 30 + 31 + return new Response( 32 + JSON.stringify({ 33 + message: `Rate limit exceeded. Please try again in ${formatRetryAfter(retryAfterSeconds)}.`, 34 + }), 35 + { 36 + status: 429, 37 + headers: { 38 + "Content-Type": "application/json; charset=utf-8", 39 + "Retry-After": String(retryAfterSeconds), 40 + "X-RateLimit-Limit": String(limit.limit), 41 + "X-RateLimit-Remaining": String(limit.remaining), 42 + "X-RateLimit-Reset": String(limit.reset), 43 + }, 44 + }, 45 + ); 46 + } 11 47 12 48 const app = new Hono<{ Variables: { userId: string } }>() 13 49 .use("*", requireAuth) ··· 61 97 62 98 if (!chat) return c.json({ message: "Not found" }, 404); 63 99 64 - db.insert(schema.messages) 65 - .values({ 66 - id: nanoid(), 67 - chatId: id, 68 - userId, 69 - role: "user", 70 - ...body, 71 - }) 72 - .run(); 73 - 74 100 const character = characters.find((c) => c.id === chat.characterId); 75 101 76 102 if (!character) return c.json({ message: "Invalid character" }, 400); ··· 84 110 }); 85 111 86 112 const titleResultPromise = 87 - messages.length === 1 ? generateChatTitle(body.text) : undefined; 113 + messages.length === 0 ? generateChatTitle(body.text) : undefined; 88 114 const system = "prompt" in character ? character.prompt : undefined; 115 + const rateLimit = await getExceededRateLimit({ userId }); 116 + 117 + if (rateLimit) return createRateLimitResponse(rateLimit); 118 + 119 + db.insert(schema.messages) 120 + .values({ 121 + id: nanoid(), 122 + chatId: id, 123 + userId, 124 + role: "user", 125 + ...body, 126 + }) 127 + .run(); 89 128 90 129 const res = streamChat({ 91 130 system, 92 131 modelId: chat.modelId, 93 - messages: messages.map((message) => ({ 94 - role: message.role, 95 - content: message.text, 96 - })), 132 + messages: [ 133 + ...messages.map((message) => ({ 134 + role: message.role, 135 + content: message.text, 136 + })), 137 + { 138 + role: "user", 139 + content: body.text, 140 + }, 141 + ], 97 142 }); 98 143 99 144 const stream = new ReadableStream({ ··· 174 219 } 175 220 176 221 const system = "prompt" in character ? character.prompt : undefined; 222 + const rateLimit = await getExceededRateLimit({ userId }); 223 + 224 + if (rateLimit) return createRateLimitResponse(rateLimit); 225 + 177 226 const res = streamChat({ 178 227 system, 179 228 modelId: chat.modelId, ··· 269 318 .run(); 270 319 271 320 const system = "prompt" in character ? character.prompt : undefined; 321 + const rateLimit = await getExceededRateLimit({ userId }); 322 + 323 + if (rateLimit) return createRateLimitResponse(rateLimit); 324 + 272 325 const res = streamChat({ 273 326 system, 274 327 modelId: chat.modelId,
+1
apps/web/.env.example
··· 1 + VITE_CLERK_PUBLISHABLE_KEY=
+2
apps/web/package.json
··· 20 20 "class-variance-authority": "^0.7.1", 21 21 "hono": "^4.12.15", 22 22 "lucide-react": "^1.11.0", 23 + "next-themes": "^0.4.6", 23 24 "react": "^19.2.0", 24 25 "react-dom": "^19.2.0", 25 26 "react-markdown": "^10.1.0", 26 27 "shadcn": "^4.5.0", 28 + "sonner": "^2.0.7", 27 29 "tailwindcss": "^4.1.18", 28 30 "tw-animate-css": "^1.4.0", 29 31 "zod": "^4.3.6"
-1
apps/web/src/components/chat-form.tsx
··· 113 113 placeholder="Write your message here..." 114 114 className="wrap-anywhere max-h-32 min-h-16 rounded-none border-none bg-transparent p-0 focus-visible:ring-0" 115 115 value={text} 116 - disabled={isAuthUnavailable} 117 116 onChange={(event) => setText(event.currentTarget.value)} 118 117 onKeyDown={(event) => { 119 118 if (event.key === "Enter" && !event.shiftKey) {
+40
apps/web/src/components/ui/sonner.tsx
··· 1 + import { 2 + CircleCheckIcon, 3 + InfoIcon, 4 + Loader2Icon, 5 + OctagonXIcon, 6 + TriangleAlertIcon, 7 + } from "lucide-react"; 8 + import type * as React from "react"; 9 + import { Toaster as Sonner, type ToasterProps } from "sonner"; 10 + 11 + const Toaster = ({ ...props }: ToasterProps) => { 12 + return ( 13 + <Sonner 14 + className="toaster group" 15 + icons={{ 16 + success: <CircleCheckIcon className="size-4" />, 17 + info: <InfoIcon className="size-4" />, 18 + warning: <TriangleAlertIcon className="size-4" />, 19 + error: <OctagonXIcon className="size-4" />, 20 + loading: <Loader2Icon className="size-4 animate-spin" />, 21 + }} 22 + style={ 23 + { 24 + "--normal-bg": "var(--popover)", 25 + "--normal-text": "var(--popover-foreground)", 26 + "--normal-border": "var(--border)", 27 + "--border-radius": "var(--radius)", 28 + } as React.CSSProperties 29 + } 30 + toastOptions={{ 31 + classNames: { 32 + toast: "cn-toast", 33 + }, 34 + }} 35 + {...props} 36 + /> 37 + ); 38 + }; 39 + 40 + export { Toaster };
+5 -5
apps/web/src/hooks/use-characters.ts
··· 1 1 import { useQuery } from "@tanstack/react-query"; 2 2 import { client } from "#/lib/api"; 3 + import { expectApiResponse } from "#/lib/api-error"; 3 4 4 5 export function useCharacters() { 5 6 return useQuery({ 6 7 queryKey: ["characters"], 7 8 queryFn: async () => { 8 - const response = await client.api.characters.$get(); 9 - 10 - if (!response.ok) { 11 - throw new Error("Failed to load characters"); 12 - } 9 + const response = await expectApiResponse( 10 + client.api.characters.$get(), 11 + "Failed to load characters", 12 + ); 13 13 14 14 return response.json(); 15 15 },
+14 -10
apps/web/src/hooks/use-chat.ts
··· 1 1 import { useAuth } from "@clerk/clerk-react"; 2 2 import { useQuery } from "@tanstack/react-query"; 3 3 import { client } from "#/lib/api"; 4 + import { ApiError, expectApiResponse } from "#/lib/api-error"; 4 5 import { chatQueryKey } from "#/lib/chat-query"; 5 6 6 7 interface UseChatOptions { ··· 14 15 enabled: isLoaded && isSignedIn, 15 16 queryKey: chatQueryKey(userId, id), 16 17 queryFn: async () => { 17 - const response = await client.api.chats[":id"].$get({ 18 - param: { id }, 19 - }); 18 + try { 19 + const response = await expectApiResponse( 20 + client.api.chats[":id"].$get({ 21 + param: { id }, 22 + }), 23 + "Failed to load chat", 24 + ); 20 25 21 - if (response.status === 404) { 22 - return undefined; 23 - } 26 + return response.json(); 27 + } catch (error) { 28 + if (error instanceof ApiError && error.status === 404) { 29 + return undefined; 30 + } 24 31 25 - if (!response.ok) { 26 - throw new Error("Failed to load chat"); 32 + throw error; 27 33 } 28 - 29 - return response.json(); 30 34 }, 31 35 }); 32 36
+5 -5
apps/web/src/hooks/use-chats.ts
··· 1 1 import { useAuth } from "@clerk/clerk-react"; 2 2 import { useQuery } from "@tanstack/react-query"; 3 3 import { client } from "#/lib/api"; 4 + import { expectApiResponse } from "#/lib/api-error"; 4 5 import { chatsQueryKey } from "#/lib/chat-query"; 5 6 6 7 export function useChats() { ··· 10 11 enabled: isLoaded && isSignedIn, 11 12 queryKey: chatsQueryKey(userId), 12 13 queryFn: async () => { 13 - const response = await client.api.chats.$get(); 14 - 15 - if (!response.ok) { 16 - throw new Error("Failed to load chats"); 17 - } 14 + const response = await expectApiResponse( 15 + client.api.chats.$get(), 16 + "Failed to load chats", 17 + ); 18 18 19 19 return response.json(); 20 20 },
+7 -7
apps/web/src/hooks/use-delete-chat.ts
··· 2 2 import { useMutation, useQueryClient } from "@tanstack/react-query"; 3 3 import { useNavigate } from "@tanstack/react-router"; 4 4 import { client } from "#/lib/api"; 5 + import { expectApiResponse } from "#/lib/api-error"; 5 6 import { chatQueryKey, chatsQueryKey } from "#/lib/chat-query"; 6 7 import type { ChatSummary } from "#/lib/types"; 7 8 ··· 12 13 13 14 return useMutation({ 14 15 mutationFn: async (id: string) => { 15 - const response = await client.api.chats[":id"].$delete({ 16 - param: { id }, 17 - }); 18 - 19 - if (!response.ok) { 20 - throw new Error("Failed to delete chat"); 21 - } 16 + await expectApiResponse( 17 + client.api.chats[":id"].$delete({ 18 + param: { id }, 19 + }), 20 + "Failed to delete chat", 21 + ); 22 22 23 23 return id; 24 24 },
+8 -10
apps/web/src/hooks/use-edit-last-message.ts
··· 5 5 useQueryClient, 6 6 } from "@tanstack/react-query"; 7 7 import { client } from "#/lib/api"; 8 + import { expectApiResponse } from "#/lib/api-error"; 8 9 import { chatQueryKey, chatsQueryKey } from "#/lib/chat-query"; 9 10 import type { Chat, ChatMessage } from "#/lib/types"; 10 11 ··· 42 43 } 43 44 44 45 async function editLastMessage(chatId: string, text: string) { 45 - const response = await client.api.chats[":id"].messages["edit-last"].$post({ 46 - param: { id: chatId }, 47 - json: { text }, 48 - }); 49 - 50 - if (!response.ok) { 51 - throw new Error("Failed to edit message"); 52 - } 53 - 54 - return response; 46 + return expectApiResponse( 47 + client.api.chats[":id"].messages["edit-last"].$post({ 48 + param: { id: chatId }, 49 + json: { text }, 50 + }), 51 + "Failed to edit message", 52 + ); 55 53 } 56 54 57 55 async function readTextStream(
+5 -5
apps/web/src/hooks/use-models.ts
··· 1 1 import { useQuery } from "@tanstack/react-query"; 2 2 import { client } from "#/lib/api"; 3 + import { expectApiResponse } from "#/lib/api-error"; 3 4 4 5 export function useModels() { 5 6 return useQuery({ 6 7 queryKey: ["models"], 7 8 queryFn: async () => { 8 - const response = await client.api.models.$get(); 9 - 10 - if (!response.ok) { 11 - throw new Error("Failed to load models"); 12 - } 9 + const response = await expectApiResponse( 10 + client.api.models.$get(), 11 + "Failed to load models", 12 + ); 13 13 14 14 return response.json(); 15 15 },
+7 -9
apps/web/src/hooks/use-regenerate-last-message.ts
··· 5 5 useQueryClient, 6 6 } from "@tanstack/react-query"; 7 7 import { client } from "#/lib/api"; 8 + import { expectApiResponse } from "#/lib/api-error"; 8 9 import { chatQueryKey, chatsQueryKey } from "#/lib/chat-query"; 9 10 import type { Chat, ChatMessage } from "#/lib/types"; 10 11 ··· 42 43 } 43 44 44 45 async function regenerateLastMessage(chatId: string) { 45 - const response = await client.api.chats[":id"].messages.regenerate.$post({ 46 - param: { id: chatId }, 47 - }); 48 - 49 - if (!response.ok) { 50 - throw new Error("Failed to regenerate message"); 51 - } 52 - 53 - return response; 46 + return expectApiResponse( 47 + client.api.chats[":id"].messages.regenerate.$post({ 48 + param: { id: chatId }, 49 + }), 50 + "Failed to regenerate message", 51 + ); 54 52 } 55 53 56 54 async function readTextStream(
+8 -8
apps/web/src/hooks/use-rename-chat.ts
··· 1 1 import { useAuth } from "@clerk/clerk-react"; 2 2 import { useMutation, useQueryClient } from "@tanstack/react-query"; 3 3 import { client } from "#/lib/api"; 4 + import { expectApiResponse } from "#/lib/api-error"; 4 5 import { chatsQueryKey } from "#/lib/chat-query"; 5 6 import type { ChatSummary } from "#/lib/types"; 6 7 ··· 10 11 11 12 return useMutation({ 12 13 mutationFn: async ({ id, title }: { id: string; title: string }) => { 13 - const response = await client.api.chats[":id"].$patch({ 14 - param: { id }, 15 - json: { title }, 16 - }); 17 - 18 - if (!response.ok) { 19 - throw new Error("Failed to rename chat"); 20 - } 14 + const response = await expectApiResponse( 15 + client.api.chats[":id"].$patch({ 16 + param: { id }, 17 + json: { title }, 18 + }), 19 + "Failed to rename chat", 20 + ); 21 21 22 22 return response.json(); 23 23 },
+18 -21
apps/web/src/hooks/use-send-message.ts
··· 6 6 } from "@tanstack/react-query"; 7 7 import { useNavigate } from "@tanstack/react-router"; 8 8 import { client } from "#/lib/api"; 9 + import { expectApiResponse } from "#/lib/api-error"; 9 10 import { chatQueryKey, chatsQueryKey } from "#/lib/chat-query"; 10 11 import type { 11 12 CharacterId, ··· 89 90 } 90 91 91 92 async function sendMessageToChat(chatId: string, text: string) { 92 - const response = await client.api.chats[":id"].messages.$post({ 93 - param: { id: chatId }, 94 - json: { text }, 95 - }); 96 - 97 - if (!response.ok) { 98 - throw new Error("Failed to send message"); 99 - } 100 - 101 - return response; 93 + return expectApiResponse( 94 + client.api.chats[":id"].messages.$post({ 95 + param: { id: chatId }, 96 + json: { text }, 97 + }), 98 + "Failed to send message", 99 + ); 102 100 } 103 101 104 102 async function readTextStream( ··· 144 142 throw new Error("Character is required"); 145 143 } 146 144 147 - const response = await client.api.chats.$post({ 148 - json: { 149 - title: "New chat", 150 - characterId, 151 - modelId, 152 - }, 153 - }); 154 - 155 - if (!response.ok) { 156 - throw new Error("Failed to create chat"); 157 - } 145 + const response = await expectApiResponse( 146 + client.api.chats.$post({ 147 + json: { 148 + title: "New chat", 149 + characterId, 150 + modelId, 151 + }, 152 + }), 153 + "Failed to create chat", 154 + ); 158 155 159 156 const chat = await response.json(); 160 157
+8 -8
apps/web/src/hooks/use-update-chat-character.ts
··· 1 1 import { useAuth } from "@clerk/clerk-react"; 2 2 import { useMutation, useQueryClient } from "@tanstack/react-query"; 3 3 import { client } from "#/lib/api"; 4 + import { expectApiResponse } from "#/lib/api-error"; 4 5 import { chatQueryKey, chatsQueryKey } from "#/lib/chat-query"; 5 6 import type { 6 7 Chat, ··· 18 19 19 20 return useMutation({ 20 21 mutationFn: async ({ id, characterId }: UpdateChatCharacterInput) => { 21 - const response = await client.api.chats[":id"].$patch({ 22 - param: { id }, 23 - json: { characterId }, 24 - }); 25 - 26 - if (!response.ok) { 27 - throw new Error("Failed to update chat character"); 28 - } 22 + const response = await expectApiResponse( 23 + client.api.chats[":id"].$patch({ 24 + param: { id }, 25 + json: { characterId }, 26 + }), 27 + "Failed to update chat character", 28 + ); 29 29 30 30 return response.json(); 31 31 },
+8 -8
apps/web/src/hooks/use-update-chat-model.ts
··· 1 1 import { useAuth } from "@clerk/clerk-react"; 2 2 import { useMutation, useQueryClient } from "@tanstack/react-query"; 3 3 import { client } from "#/lib/api"; 4 + import { expectApiResponse } from "#/lib/api-error"; 4 5 import { chatQueryKey, chatsQueryKey } from "#/lib/chat-query"; 5 6 import type { 6 7 Chat, ··· 17 18 18 19 return useMutation({ 19 20 mutationFn: async ({ id, modelId }: UpdateChatModelInput) => { 20 - const response = await client.api.chats[":id"].$patch({ 21 - param: { id }, 22 - json: { modelId }, 23 - }); 24 - 25 - if (!response.ok) { 26 - throw new Error("Failed to update chat model"); 27 - } 21 + const response = await expectApiResponse( 22 + client.api.chats[":id"].$patch({ 23 + param: { id }, 24 + json: { modelId }, 25 + }), 26 + "Failed to update chat model", 27 + ); 28 28 29 29 return response.json(); 30 30 },
+61
apps/web/src/lib/api-error.ts
··· 1 + export class ApiError extends Error { 2 + status?: number; 3 + 4 + constructor(message: string, options?: { status?: number }) { 5 + super(message); 6 + this.name = "ApiError"; 7 + this.status = options?.status; 8 + } 9 + } 10 + 11 + async function getResponseErrorMessage(response: Response) { 12 + const contentType = response.headers.get("Content-Type") ?? ""; 13 + 14 + if (contentType.includes("application/json")) { 15 + const body = (await response.json()) as { message?: unknown }; 16 + 17 + if (typeof body.message === "string" && body.message.trim().length > 0) { 18 + return body.message; 19 + } 20 + } 21 + 22 + const text = await response.text(); 23 + 24 + if (text.trim().length > 0) { 25 + return text; 26 + } 27 + 28 + return undefined; 29 + } 30 + 31 + export async function expectApiResponse( 32 + responsePromise: Promise<Response>, 33 + fallbackMessage: string, 34 + ) { 35 + try { 36 + const response = await responsePromise; 37 + 38 + if (!response.ok) { 39 + const message = 40 + (await getResponseErrorMessage(response)) ?? fallbackMessage; 41 + 42 + throw new ApiError(message, { status: response.status }); 43 + } 44 + 45 + return response; 46 + } catch (error) { 47 + if (error instanceof ApiError) { 48 + throw error; 49 + } 50 + 51 + throw new ApiError(fallbackMessage); 52 + } 53 + } 54 + 55 + export function getApiErrorMessage(error: unknown) { 56 + if (error instanceof ApiError) { 57 + return error.message; 58 + } 59 + 60 + return null; 61 + }
+23 -2
apps/web/src/lib/query.ts
··· 1 - import { QueryClient } from "@tanstack/react-query"; 1 + import { MutationCache, QueryCache, QueryClient } from "@tanstack/react-query"; 2 + import { toast } from "sonner"; 3 + import { getApiErrorMessage } from "./api-error"; 4 + 5 + function toastApiError(error: unknown) { 6 + const message = getApiErrorMessage(error); 7 + 8 + if (message) { 9 + toast.error(message); 10 + } 11 + } 2 12 3 - export const queryClient = new QueryClient(); 13 + export const queryClient = new QueryClient({ 14 + mutationCache: new MutationCache({ 15 + onError: (error) => { 16 + toastApiError(error); 17 + }, 18 + }), 19 + queryCache: new QueryCache({ 20 + onError: (error) => { 21 + toastApiError(error); 22 + }, 23 + }), 24 + });
+2
apps/web/src/routes/__root.tsx
··· 2 2 import { QueryClientProvider } from "@tanstack/react-query"; 3 3 import { createRootRoute, Outlet } from "@tanstack/react-router"; 4 4 import { useEffect, useRef } from "react"; 5 + import { Toaster } from "#/components/ui/sonner"; 5 6 import { setAuthTokenGetter } from "#/lib/api"; 6 7 import { queryClient } from "#/lib/query"; 7 8 ··· 38 39 <QueryClientProvider client={queryClient}> 39 40 <AuthSync /> 40 41 <Outlet /> 42 + <Toaster /> 41 43 </QueryClientProvider> 42 44 ); 43 45 }
+17
bun.lock
··· 15 15 "@ai-sdk/openai": "^3.0.53", 16 16 "@clerk/backend": "^3.4.1", 17 17 "@hono/zod-validator": "^0.7.6", 18 + "@t3-oss/env-core": "^0.13.11", 19 + "@upstash/ratelimit": "^2.0.6", 20 + "@upstash/redis": "^1.35.6", 18 21 "ai": "^6.0.168", 19 22 "drizzle-orm": "^0.45.2", 20 23 "hono": "^4.12.15", ··· 42 45 "class-variance-authority": "^0.7.1", 43 46 "hono": "^4.12.15", 44 47 "lucide-react": "^1.11.0", 48 + "next-themes": "^0.4.6", 45 49 "react": "^19.2.0", 46 50 "react-dom": "^19.2.0", 47 51 "react-markdown": "^10.1.0", 48 52 "shadcn": "^4.5.0", 53 + "sonner": "^2.0.7", 49 54 "tailwindcss": "^4.1.18", 50 55 "tw-animate-css": "^1.4.0", 51 56 "zod": "^4.3.6", ··· 451 456 452 457 "@ungap/structured-clone": ["@ungap/structured-clone@1.3.0", "", {}, "sha512-WmoN8qaIAo7WTYWbAZuG8PYEhn5fkz7dZrqTBZ7dtt//lL2Gwms1IcnQ5yHqjDfX8Ft5j4YzDM23f87zBfDe9g=="], 453 458 459 + "@upstash/core-analytics": ["@upstash/core-analytics@0.0.10", "", { "dependencies": { "@upstash/redis": "^1.28.3" } }, "sha512-7qJHGxpQgQr9/vmeS1PktEwvNAF7TI4iJDi8Pu2CFZ9YUGHZH4fOP5TfYlZ4aVxfopnELiE4BS4FBjyK7V1/xQ=="], 460 + 461 + "@upstash/ratelimit": ["@upstash/ratelimit@2.0.8", "", { "dependencies": { "@upstash/core-analytics": "^0.0.10" }, "peerDependencies": { "@upstash/redis": "^1.34.3" } }, "sha512-YSTMBJ1YIxsoPkUMX/P4DDks/xV5YYCswWMamU8ZIfK9ly6ppjRnVOyBhMDXBmzjODm4UQKcxsJPvaeFAijp5w=="], 462 + 463 + "@upstash/redis": ["@upstash/redis@1.37.0", "", { "dependencies": { "uncrypto": "^0.1.3" } }, "sha512-LqOJ3+XWPLSZ2rGSed5DYG3ixybxb8EhZu3yQqF7MdZX1wLBG/FRcI6xcUZXHy/SS7mmXWyadrud0HJHkOc+uw=="], 464 + 454 465 "@vercel/oidc": ["@vercel/oidc@3.2.0", "", {}, "sha512-UycprH3T6n3jH0k44NHMa7pnFHGu/N05MjojYr+Mc6I7obkoLIJujSWwin1pCvdy/eOxrI/l3uDLQsmcrOb4ug=="], 455 466 456 467 "@vitejs/plugin-react": ["@vitejs/plugin-react@6.0.1", "", { "dependencies": { "@rolldown/pluginutils": "1.0.0-rc.7" }, "peerDependencies": { "@rolldown/plugin-babel": "^0.1.7 || ^0.2.0", "babel-plugin-react-compiler": "^1.0.0", "vite": "^8.0.0" }, "optionalPeers": ["@rolldown/plugin-babel", "babel-plugin-react-compiler"] }, "sha512-l9X/E3cDb+xY3SWzlG1MOGt2usfEHGMNIaegaUGFsLkb3RCn/k8/TOXBcab+OndDI4TBtktT8/9BwwW8Vi9KUQ=="], ··· 955 966 956 967 "negotiator": ["negotiator@1.0.0", "", {}, "sha512-8Ofs/AUQh8MaEcrlq5xOX0CQ9ypTF5dl78mjlMNfOK08fzpgTHQRQPBxcPlEtIw0yRpws+Zo/3r+5WRby7u3Gg=="], 957 968 969 + "next-themes": ["next-themes@0.4.6", "", { "peerDependencies": { "react": "^16.8 || ^17 || ^18 || ^19 || ^19.0.0-rc", "react-dom": "^16.8 || ^17 || ^18 || ^19 || ^19.0.0-rc" } }, "sha512-pZvgD5L0IEvX5/9GWyHMf3m8BKiVQwsCMHfoFosXtXBMnaS0ZnIJ9ST4b4NqLVKDEm8QBxoNNGNaBv2JNF6XNA=="], 970 + 958 971 "node-domexception": ["node-domexception@1.0.0", "", {}, "sha512-/jKZoMpw0F8GRwl4/eLROPA3cfcXtLApP0QzLmUT/HuPCZWyB7IY9ZrMeKw2O/nFIqPQB3PVM9aYm0F312AXDQ=="], 959 972 960 973 "node-fetch": ["node-fetch@3.3.2", "", { "dependencies": { "data-uri-to-buffer": "^4.0.0", "fetch-blob": "^3.1.4", "formdata-polyfill": "^4.0.10" } }, "sha512-dRB78srN/l6gqWulah9SrxeYnxeddIG30+GOqK/9OlLVyLg3HPnr6SqOWTWOXKRwC2eGYCkZ59NNuSgvSrpgOA=="], ··· 1107 1120 1108 1121 "sisteransi": ["sisteransi@1.0.5", "", {}, "sha512-bLGGlR1QxBcynn2d5YmDX4MGjlZvy2MRBDRNHLJ8VI6l6+9FUiyTFNJ0IveOSP0bcXgVDPRcfGqA0pjaqUpfVg=="], 1109 1122 1123 + "sonner": ["sonner@2.0.7", "", { "peerDependencies": { "react": "^18.0.0 || ^19.0.0 || ^19.0.0-rc", "react-dom": "^18.0.0 || ^19.0.0 || ^19.0.0-rc" } }, "sha512-W6ZN4p58k8aDKA4XPcx2hpIQXBRAgyiWVkYhT7CvK6D3iAu7xjvVyhQHg2/iaKJZ1XVJ4r7XuwGL+WGEK37i9w=="], 1124 + 1110 1125 "source-map": ["source-map@0.6.1", "", {}, "sha512-UjgapumWlbMhkBgzT7Ykc5YXUT46F0iKu8SGXq0bcwP5dz/h0Plj6enJqjz1Zbq2l5WaqYnrVbwWOWMyF3F47g=="], 1111 1126 1112 1127 "source-map-js": ["source-map-js@1.2.1", "", {}, "sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA=="], ··· 1182 1197 "type-fest": ["type-fest@5.6.0", "", { "dependencies": { "tagged-tag": "^1.0.0" } }, "sha512-8ZiHFm91orbSAe2PSAiSVBVko18pbhbiB3U9GglSzF/zCGkR+rxpHx6sEMCUm4kxY4LjDIUGgCfUMtwfZfjfUA=="], 1183 1198 1184 1199 "type-is": ["type-is@2.0.1", "", { "dependencies": { "content-type": "^1.0.5", "media-typer": "^1.1.0", "mime-types": "^3.0.0" } }, "sha512-OZs6gsjF4vMp32qrCbiVSkrFmXtG/AZhY3t0iAMrMBiAZyV9oALtXO8hsrHbMXF9x6L3grlFuwW2oAz7cav+Gw=="], 1200 + 1201 + "uncrypto": ["uncrypto@0.1.3", "", {}, "sha512-Ql87qFHB3s/De2ClA9e0gsnS6zXG27SkTiSJwjCc9MebbfapQfuPzumMIUMi38ezPZVNFcHI9sUIepeQfw8J8Q=="], 1185 1202 1186 1203 "undici-types": ["undici-types@6.21.0", "", {}, "sha512-iwDZqg0QAGrg9Rav5H4n0M64c3mkR59cJ6wQp+7C4nI0gsmExaedaYLNO44eT4AtBBwjbTiGPMlt2Md0T9H9JQ=="], 1187 1204