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
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:
parent
2a12ea18cd
commit
3dc65e4f4d
@ -5,7 +5,7 @@ import { Input, Label, Textarea } from '@/components/input';
|
|||||||
import { InstallSnippets } from '@/components/install-snippets';
|
import { InstallSnippets } from '@/components/install-snippets';
|
||||||
import { StreamingLogs } from '@/components/streaming-logs';
|
import { StreamingLogs } from '@/components/streaming-logs';
|
||||||
import { Button } from '@/components/ui/button';
|
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 { findSecretInPrompt } from '@bmm/types';
|
||||||
import { Loader2, RotateCcw, X } from 'lucide-react';
|
import { Loader2, RotateCcw, X } from 'lucide-react';
|
||||||
import Link from 'next/link';
|
import Link from 'next/link';
|
||||||
@ -302,8 +302,7 @@ function NewServerPageInner() {
|
|||||||
setStep('confirm');
|
setStep('confirm');
|
||||||
return;
|
return;
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
const detail = (e as { detail?: { error?: string; detail?: string } }).detail;
|
setError(humanizeError(e));
|
||||||
setError(detail?.detail ?? detail?.error ?? (e as Error).message);
|
|
||||||
setStep('prompt');
|
setStep('prompt');
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
@ -458,7 +457,7 @@ function NewServerPageInner() {
|
|||||||
setError(detail?.detail ?? 'Daily build limit reached — try again tomorrow or upgrade.');
|
setError(detail?.detail ?? 'Daily build limit reached — try again tomorrow or upgrade.');
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
setError(detail?.detail ?? code ?? (e as Error).message);
|
setError(humanizeError(e));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@ -1,5 +1,60 @@
|
|||||||
const API_BASE = process.env.NEXT_PUBLIC_API_URL ?? 'http://localhost:4000';
|
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 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<T = unknown>(path: string, init: RequestInit = {}): Promise<T> {
|
export async function apiFetch<T = unknown>(path: string, init: RequestInit = {}): Promise<T> {
|
||||||
const res = await fetch(`${API_BASE}${path}`, {
|
const res = await fetch(`${API_BASE}${path}`, {
|
||||||
credentials: 'include',
|
credentials: 'include',
|
||||||
@ -15,9 +70,12 @@ export async function apiFetch<T = unknown>(path: string, init: RequestInit = {}
|
|||||||
try {
|
try {
|
||||||
detail = await res.json();
|
detail = await res.json();
|
||||||
} catch {}
|
} catch {}
|
||||||
const err = new Error(`api_error_${res.status}`);
|
const err = new Error('') as Error & { detail?: unknown; status?: number };
|
||||||
(err as unknown as { detail?: unknown }).detail = detail;
|
err.detail = detail;
|
||||||
(err as unknown as { status?: number }).status = res.status;
|
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;
|
throw err;
|
||||||
}
|
}
|
||||||
return (await res.json()) as T;
|
return (await res.json()) as T;
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user