From 3dc65e4f4db00b280566324f9ab3dd43edfbee0f Mon Sep 17 00:00:00 2001 From: Marco Sadjadi Date: Sun, 31 May 2026 21:08:39 +0200 Subject: [PATCH] fix(ux): human-readable API errors instead of raw api_error_NNN codes apiFetch threw new Error(api_error_), so any UI fallback to (e).message showed a cryptic code, and some flows surfaced bare codes like slug_taken. Added a central humanizeError() in lib/api.ts: prefers the backend detail sentence, then a mapped friendly message per known code, then a status-based fallback - never a raw code. apiFetch now sets the thrown error message via it, so every (e).message fallback across the app becomes a real sentence. Wizard analyze/build now use humanizeError directly. Co-Authored-By: Claude Opus 4.8 (1M context) --- apps/web/app/(dashboard)/servers/new/page.tsx | 7 +- apps/web/lib/api.ts | 64 ++++++++++++++++++- 2 files changed, 64 insertions(+), 7 deletions(-) diff --git a/apps/web/app/(dashboard)/servers/new/page.tsx b/apps/web/app/(dashboard)/servers/new/page.tsx index e1376b3..fcac380 100644 --- a/apps/web/app/(dashboard)/servers/new/page.tsx +++ b/apps/web/app/(dashboard)/servers/new/page.tsx @@ -5,7 +5,7 @@ import { Input, Label, Textarea } from '@/components/input'; import { InstallSnippets } from '@/components/install-snippets'; import { StreamingLogs } from '@/components/streaming-logs'; import { Button } from '@/components/ui/button'; -import { apiFetch, apiSseStream } from '@/lib/api'; +import { apiFetch, apiSseStream, humanizeError } from '@/lib/api'; import { findSecretInPrompt } from '@bmm/types'; import { Loader2, RotateCcw, X } from 'lucide-react'; import Link from 'next/link'; @@ -302,8 +302,7 @@ function NewServerPageInner() { setStep('confirm'); return; } catch (e) { - const detail = (e as { detail?: { error?: string; detail?: string } }).detail; - setError(detail?.detail ?? detail?.error ?? (e as Error).message); + setError(humanizeError(e)); setStep('prompt'); return; } @@ -458,7 +457,7 @@ function NewServerPageInner() { setError(detail?.detail ?? 'Daily build limit reached — try again tomorrow or upgrade.'); return; } - setError(detail?.detail ?? code ?? (e as Error).message); + setError(humanizeError(e)); } } diff --git a/apps/web/lib/api.ts b/apps/web/lib/api.ts index 21c5385..8cec7b4 100644 --- a/apps/web/lib/api.ts +++ b/apps/web/lib/api.ts @@ -1,5 +1,60 @@ const API_BASE = process.env.NEXT_PUBLIC_API_URL ?? 'http://localhost:4000'; +// Friendly messages for known backend error codes. Anything not listed falls +// back to a status-based sentence — the user must never see a raw +// "api_error_409" or a bare code like "slug_taken". +const CODE_MESSAGES: Record = { + slug_taken: 'That slug is already taken by one of your servers — pick a different one.', + unauthorized: 'Your session expired — please sign in again.', + forbidden: 'You don’t have access to that.', + not_found: 'We couldn’t find that.', + org_not_found: 'We couldn’t find your workspace.', + user_not_found: 'Account not found.', + invalid_input: 'Some details weren’t valid — please check and try again.', + invalid_id: 'That link looks invalid.', + invalid_query: 'That request wasn’t valid.', + rate_limited: 'You’ve hit a rate limit — wait a moment and try again.', + plan_limit_reached: 'You’ve reached your plan’s limit — upgrade to add more.', + subscription_suspended: 'Your subscription is paused — see Settings → Billing.', + secret_in_prompt: + 'Your prompt looks like it contains an API key — remove it and add credentials in the next step.', + stripe_not_configured: 'Payments aren’t configured yet.', + no_active_subscription: 'No active subscription found.', + checkout_failed: 'Checkout couldn’t start — please try again in a moment.', + google_oauth_not_configured: 'Google sign-in isn’t available right now.', + taken_down: 'This template was taken down and can’t be used.', +}; + +function statusFallback(status: number): string { + if (status === 400) return 'That request wasn’t valid.'; + if (status === 401) return 'Your session expired — please sign in again.'; + if (status === 402) return 'A billing action is required to continue.'; + if (status === 403) return 'You don’t have access to that.'; + if (status === 404) return 'We couldn’t find that.'; + if (status === 409) return 'That conflicts with something that already exists.'; + if (status === 429) return 'Too many requests — please wait a moment.'; + if (status >= 500) return 'Something went wrong on our side — please try again.'; + return `Request failed (HTTP ${status}).`; +} + +/** + * Turn any thrown error (from apiFetch or a network failure) into a clear, + * human-readable sentence. Prefers an explicit backend `detail`, then a mapped + * error code, then a status-based fallback. Never returns a raw code. + */ +export function humanizeError(e: unknown): string { + const err = e as { detail?: { detail?: string; error?: string }; status?: number; message?: string }; + const d = err?.detail; + if (d && typeof d.detail === 'string' && d.detail.trim()) return d.detail; + if (d?.error) { + const mapped = CODE_MESSAGES[d.error]; + if (mapped) return mapped; + } + if (typeof err?.status === 'number') return statusFallback(err.status); + if (err?.message && !/^api_error_/.test(err.message)) return err.message; // network / TypeError + return 'Something went wrong — please try again.'; +} + export async function apiFetch(path: string, init: RequestInit = {}): Promise { const res = await fetch(`${API_BASE}${path}`, { credentials: 'include', @@ -15,9 +70,12 @@ export async function apiFetch(path: string, init: RequestInit = {} try { detail = await res.json(); } catch {} - const err = new Error(`api_error_${res.status}`); - (err as unknown as { detail?: unknown }).detail = detail; - (err as unknown as { status?: number }).status = res.status; + const err = new Error('') as Error & { detail?: unknown; status?: number }; + err.detail = detail; + err.status = res.status; + // Human-readable message so any `(e as Error).message` fallback in the UI + // shows a real sentence instead of "api_error_409". + err.message = humanizeError(err); throw err; } return (await res.json()) as T;