fix: use descriptive error messages

This commit is contained in:
Aarnav Tale 2025-05-04 14:43:40 -04:00
parent 494efe0493
commit 9e0450b15b
No known key found for this signature in database
7 changed files with 286 additions and 135 deletions

View File

@ -8,7 +8,7 @@ interface Props {
type?: 'full' | 'embedded'; type?: 'full' | 'embedded';
} }
function getMessage(error: Error | unknown): { export function getErrorMessage(error: Error | unknown): {
title: string; title: string;
message: string; message: string;
} { } {
@ -45,10 +45,7 @@ function getMessage(error: Error | unknown): {
// If we are aggregate, concat into a single message // If we are aggregate, concat into a single message
if (rootError instanceof AggregateError) { if (rootError instanceof AggregateError) {
return { throw new Error('Unhandled AggregateError');
title: 'Errors',
message: rootError.errors.map((error) => error.message).join('\n'),
};
} }
return { return {
@ -60,7 +57,7 @@ function getMessage(error: Error | unknown): {
export function ErrorPopup({ type = 'full' }: Props) { export function ErrorPopup({ type = 'full' }: Props) {
const error = useRouteError(); const error = useRouteError();
const routing = isRouteErrorResponse(error); const routing = isRouteErrorResponse(error);
const { title, message } = getMessage(error); const { title, message } = getErrorMessage(error);
return ( return (
<div <div

View File

@ -1,24 +1,34 @@
import { CircleX } from 'lucide-react';
import Link from '~/components/Link'; import Link from '~/components/Link';
import cn from '~/utils/cn'; import cn from '~/utils/cn';
interface FooterProps { interface FooterProps {
url: string; url: string;
debug: boolean; debug: boolean;
healthy: boolean;
} }
export default function Footer({ url, debug }: FooterProps) { export default function Footer({ url, debug, healthy }: FooterProps) {
return ( return (
<footer <footer
className={cn( className={cn(
'fixed bottom-0 left-0 z-40 w-full h-14', 'fixed w-full bottom-0 left-0 z-40 h-12',
'flex flex-col justify-center gap-1 shadow-inner', 'flex items-center justify-center',
'bg-headplane-100 dark:bg-headplane-950', 'bg-headplane-50 dark:bg-headplane-950',
'text-headplane-800 dark:text-headplane-200',
'dark:border-t dark:border-headplane-800', 'dark:border-t dark:border-headplane-800',
)} )}
> >
<p className="container text-xs"> <div
Headplane is entirely free to use. If you find it useful, consider{' '} className={cn(
'grid grid-rows-1 items-center container mx-auto',
!healthy && 'md:grid-cols-[1fr_auto] grid-cols-1',
)}
>
<div
className={cn('text-xs leading-none', !healthy && 'hidden md:block')}
>
<p>
Headplane is free. Please consider{' '}
<Link <Link
to="https://github.com/sponsors/tale" to="https://github.com/sponsors/tale"
name="Aarnav's GitHub Sponsors" name="Aarnav's GitHub Sponsors"
@ -27,7 +37,7 @@ export default function Footer({ url, debug }: FooterProps) {
</Link>{' '} </Link>{' '}
to support development.{' '} to support development.{' '}
</p> </p>
<p className="container text-xs opacity-75"> <p className="opacity-75">
Version: {__VERSION__} Version: {__VERSION__}
{' — '} {' — '}
Connecting to{' '} Connecting to{' '}
@ -41,9 +51,21 @@ export default function Footer({ url, debug }: FooterProps) {
> >
{url} {url}
</button> </button>
{/* Connecting to <strong className="blur-xs hover:blur-none">{url}</strong> */}
{debug && ' (Debug mode enabled)'} {debug && ' (Debug mode enabled)'}
</p> </p>
</div>
{!healthy ? (
<div
className={cn(
'flex gap-1.5 items-center p-2 rounded-xl text-sm',
'bg-red-500 text-white font-semibold',
)}
>
<CircleX size={16} strokeWidth={3} />
<p className="text-nowrap">Headscale is unreachable</p>
</div>
) : undefined}
</div>
</footer> </footer>
); );
} }

View File

@ -1,7 +1,14 @@
import { XCircleFillIcon } from '@primer/octicons-react'; import { XCircleFillIcon } from '@primer/octicons-react';
import { type LoaderFunctionArgs, redirect } from 'react-router'; import { ServerCrash } from 'lucide-react';
import {
type LoaderFunctionArgs,
isRouteErrorResponse,
redirect,
useRouteError,
} from 'react-router';
import { Outlet, useLoaderData } from 'react-router'; import { Outlet, useLoaderData } from 'react-router';
import { ErrorPopup } from '~/components/Error'; import Card from '~/components/Card';
import { ErrorPopup, getErrorMessage } from '~/components/Error';
import type { LoadContext } from '~/server'; import type { LoadContext } from '~/server';
import ResponseError from '~/server/headscale/api-error'; import ResponseError from '~/server/headscale/api-error';
import cn from '~/utils/cn'; import cn from '~/utils/cn';
@ -37,29 +44,8 @@ export async function loader({
} }
export default function Layout() { export default function Layout() {
const { healthy } = useLoaderData<typeof loader>();
return ( return (
<> <>
{!healthy ? (
<div
className={cn(
'fixed bottom-0 right-0 z-50 w-fit h-14',
'flex flex-col justify-center gap-1',
)}
>
<div
className={cn(
'flex items-center gap-1.5 mr-1.5 py-2 px-1.5',
'border rounded-lg text-white bg-red-500',
'border-red-600 dark:border-red-400 shadow-sm',
)}
>
<XCircleFillIcon className="w-4 h-4 text-white" />
Headscale is unreachable
</div>
</div>
) : undefined}
<main className="container mx-auto overscroll-contain mt-4 mb-24"> <main className="container mx-auto overscroll-contain mt-4 mb-24">
<Outlet /> <Outlet />
</main> </main>

View File

@ -116,6 +116,7 @@ export async function loader({
), ),
}, },
onboarding: request.url.endsWith('/onboarding'), onboarding: request.url.endsWith('/onboarding'),
healthy: await context.client.healthcheck(),
}; };
} catch { } catch {
// No session, so we can just return // No session, so we can just return

View File

@ -164,7 +164,3 @@ export default function Page() {
</> </>
); );
} }
export function ErrorBoundary() {
return <ErrorPopup type="embedded" />;
}

View File

@ -1,8 +1,132 @@
import { readFile } from 'node:fs/promises'; import { readFile } from 'node:fs/promises';
import { data } from 'react-router';
import { Agent, Dispatcher, request } from 'undici'; import { Agent, Dispatcher, request } from 'undici';
import { errors } from 'undici';
import log from '~/utils/log'; import log from '~/utils/log';
import ResponseError from './api-error'; import ResponseError from './api-error';
function isNodeNetworkError(error: unknown): error is NodeJS.ErrnoException {
const keys = Object.keys(error as Record<string, unknown>);
return (
typeof error === 'object' &&
error !== null &&
keys.includes('code') &&
keys.includes('errno')
);
}
function friendlyError(givenError: unknown) {
let error: unknown = givenError;
if (error instanceof AggregateError) {
error = error.errors[0];
}
switch (true) {
case error instanceof errors.BodyTimeoutError:
case error instanceof errors.ConnectTimeoutError:
case error instanceof errors.HeadersTimeoutError:
return data('Timed out waiting for a response from the Headscale API', {
statusText: 'Request Timeout',
status: 408,
});
case error instanceof errors.SocketError:
case error instanceof errors.SecureProxyConnectionError:
case error instanceof errors.ClientClosedError:
case error instanceof errors.ClientDestroyedError:
case error instanceof errors.RequestAbortedError:
return data('The Headscale API is not reachable', {
statusText: 'Service Unavailable',
status: 503,
});
case error instanceof errors.InvalidArgumentError:
case error instanceof errors.InvalidReturnValueError:
case error instanceof errors.NotSupportedError:
return data('Unable to make a request (this is most likely a bug)', {
statusText: 'Internal Server Error',
status: 500,
});
case error instanceof errors.HeadersOverflowError:
case error instanceof errors.RequestContentLengthMismatchError:
case error instanceof errors.ResponseContentLengthMismatchError:
case error instanceof errors.ResponseExceededMaxSizeError:
return data('The Headscale API returned a malformed response', {
statusText: 'Bad Gateway',
status: 502,
});
case isNodeNetworkError(error):
if (error.code === 'ECONNREFUSED') {
return data('The Headscale API is not reachable', {
statusText: 'Service Unavailable',
status: 503,
});
}
if (error.code === 'ENOTFOUND') {
return data('The Headscale API is not reachable', {
statusText: 'Service Unavailable',
status: 503,
});
}
if (error.code === 'EAI_AGAIN') {
return data('The Headscale API is not reachable', {
statusText: 'Service Unavailable',
status: 503,
});
}
if (error.code === 'ETIMEDOUT') {
return data('Timed out waiting for a response from the Headscale API', {
statusText: 'Request Timeout',
status: 408,
});
}
if (error.code === 'ECONNRESET') {
return data('The Headscale API is not reachable', {
statusText: 'Service Unavailable',
status: 503,
});
}
if (error.code === 'EPIPE') {
return data('The Headscale API is not reachable', {
statusText: 'Service Unavailable',
status: 503,
});
}
if (error.code === 'ENETUNREACH') {
return data('The Headscale API is not reachable', {
statusText: 'Service Unavailable',
status: 503,
});
}
if (error.code === 'ENETRESET') {
return data('The Headscale API is not reachable', {
statusText: 'Service Unavailable',
status: 503,
});
}
return data('The Headscale API is not reachable', {
statusText: 'Service Unavailable',
status: 503,
});
default:
return data((error as Error).message ?? 'An unknown error occurred', {
statusText: 'Internal Server Error',
status: 500,
});
}
}
export async function createApiClient(base: string, certPath?: string) { export async function createApiClient(base: string, certPath?: string) {
if (!certPath) { if (!certPath) {
return new ApiClient(new Agent(), base); return new ApiClient(new Agent(), base);
@ -37,7 +161,8 @@ export class ApiClient {
const method = options?.method ?? 'GET'; const method = options?.method ?? 'GET';
log.debug('api', '%s %s', method, url); log.debug('api', '%s %s', method, url);
return await request(new URL(url, this.base), { try {
const res = await request(new URL(url, this.base), {
dispatcher: this.agent, dispatcher: this.agent,
headers: { headers: {
...options?.headers, ...options?.headers,
@ -47,11 +172,23 @@ export class ApiClient {
body: options?.body, body: options?.body,
method, method,
}); });
return res;
} catch (error: unknown) {
throw friendlyError(error);
}
} }
async healthcheck() { async healthcheck() {
try { try {
const res = await this.defaultFetch('/health'); const res = await request(new URL('/health', this.base), {
dispatcher: this.agent,
headers: {
Accept: 'application/json',
'User-Agent': `Headplane/${__VERSION__}`,
},
});
return res.statusCode === 200; return res.statusCode === 200;
} catch (error) { } catch (error) {
log.debug('api', 'Healthcheck failed %o', error); log.debug('api', 'Healthcheck failed %o', error);

View File

@ -75,6 +75,7 @@ export async function createOidcClient(
} }
log.debug('config', 'Running OIDC discovery for %s', config.issuer); log.debug('config', 'Running OIDC discovery for %s', config.issuer);
try {
const oidc = await client.discovery( const oidc = await client.discovery(
new URL(config.issuer), new URL(config.issuer),
config.client_id, config.client_id,
@ -88,7 +89,10 @@ export async function createOidcClient(
'config', 'config',
'Issuer discovery did not return `authorization_endpoint`', 'Issuer discovery did not return `authorization_endpoint`',
); );
log.error('config', 'OIDC server does not support authorization code flow'); log.error(
'config',
'OIDC server does not support authorization code flow',
);
return; return;
} }
@ -132,7 +136,10 @@ export async function createOidcClient(
} }
if (!metadata.userinfo_endpoint) { if (!metadata.userinfo_endpoint) {
log.error('config', 'Issuer discovery did not return `userinfo_endpoint`'); log.error(
'config',
'Issuer discovery did not return `userinfo_endpoint`',
);
log.error('config', 'OIDC server does not support userinfo endpoint'); log.error('config', 'OIDC server does not support userinfo endpoint');
return; return;
} }
@ -147,4 +154,9 @@ export async function createOidcClient(
log.debug('config', 'Token endpoint: %s', metadata.token_endpoint); log.debug('config', 'Token endpoint: %s', metadata.token_endpoint);
log.debug('config', 'Userinfo endpoint: %s', metadata.userinfo_endpoint); log.debug('config', 'Userinfo endpoint: %s', metadata.userinfo_endpoint);
return oidc; return oidc;
} catch (error) {
log.error('config', 'Failed to discover OIDC issuer');
log.error('config', 'Error: %s', error);
log.debug('config', 'Error details: %o', error);
}
} }