fix(ux): human-readable API errors instead of raw api_error_NNN codes
All checks were successful
Deploy to Production / deploy (push) Successful in 1m20s

apiFetch threw new Error(api_error_<status>), 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) <noreply@anthropic.com>
This commit is contained in:
Marco Sadjadi 2026-05-31 21:08:39 +02:00
parent 2a12ea18cd
commit 3dc65e4f4d
2 changed files with 64 additions and 7 deletions

View File

@ -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));
}
}

View File

@ -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<string, string> = {
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 dont have access to that.',
not_found: 'We couldnt find that.',
org_not_found: 'We couldnt find your workspace.',
user_not_found: 'Account not found.',
invalid_input: 'Some details werent valid — please check and try again.',
invalid_id: 'That link looks invalid.',
invalid_query: 'That request wasnt valid.',
rate_limited: 'Youve hit a rate limit — wait a moment and try again.',
plan_limit_reached: 'Youve reached your plans 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 arent configured yet.',
no_active_subscription: 'No active subscription found.',
checkout_failed: 'Checkout couldnt start — please try again in a moment.',
google_oauth_not_configured: 'Google sign-in isnt available right now.',
taken_down: 'This template was taken down and cant be used.',
};
function statusFallback(status: number): string {
if (status === 400) return 'That request wasnt 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 dont have access to that.';
if (status === 404) return 'We couldnt 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<T = unknown>(path: string, init: RequestInit = {}): Promise<T> {
const res = await fetch(`${API_BASE}${path}`, {
credentials: 'include',
@ -15,9 +70,12 @@ export async function apiFetch<T = unknown>(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;