From 9e0450b15bf4b0fc700c7500be3a7d738bb92afc Mon Sep 17 00:00:00 2001 From: Aarnav Tale Date: Sun, 4 May 2025 14:43:40 -0400 Subject: [PATCH] fix: use descriptive error messages --- app/components/Error.tsx | 9 +- app/components/Footer.tsx | 84 +++++++++------ app/layouts/dashboard.tsx | 32 ++---- app/layouts/shell.tsx | 1 + app/routes/machines/overview.tsx | 4 - app/server/headscale/api-client.ts | 159 +++++++++++++++++++++++++++-- app/server/web/oidc.ts | 132 +++++++++++++----------- 7 files changed, 286 insertions(+), 135 deletions(-) diff --git a/app/components/Error.tsx b/app/components/Error.tsx index 5a6e4f2..c41f456 100644 --- a/app/components/Error.tsx +++ b/app/components/Error.tsx @@ -8,7 +8,7 @@ interface Props { type?: 'full' | 'embedded'; } -function getMessage(error: Error | unknown): { +export function getErrorMessage(error: Error | unknown): { title: string; message: string; } { @@ -45,10 +45,7 @@ function getMessage(error: Error | unknown): { // If we are aggregate, concat into a single message if (rootError instanceof AggregateError) { - return { - title: 'Errors', - message: rootError.errors.map((error) => error.message).join('\n'), - }; + throw new Error('Unhandled AggregateError'); } return { @@ -60,7 +57,7 @@ function getMessage(error: Error | unknown): { export function ErrorPopup({ type = 'full' }: Props) { const error = useRouteError(); const routing = isRouteErrorResponse(error); - const { title, message } = getMessage(error); + const { title, message } = getErrorMessage(error); return (
-

- Headplane is entirely free to use. If you find it useful, consider{' '} - +

- donating - {' '} - to support development.{' '} -

-

- Version: {__VERSION__} - {' — '} - Connecting to{' '} - - {/* Connecting to {url} */} - {debug && ' (Debug mode enabled)'} -

+

+ Headplane is free. Please consider{' '} + + donating + {' '} + to support development.{' '} +

+

+ Version: {__VERSION__} + {' — '} + Connecting to{' '} + + {debug && ' (Debug mode enabled)'} +

+
+ {!healthy ? ( +
+ +

Headscale is unreachable

+
+ ) : undefined} +
); } diff --git a/app/layouts/dashboard.tsx b/app/layouts/dashboard.tsx index 7e3b54b..ca53a7e 100644 --- a/app/layouts/dashboard.tsx +++ b/app/layouts/dashboard.tsx @@ -1,7 +1,14 @@ 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 { ErrorPopup } from '~/components/Error'; +import Card from '~/components/Card'; +import { ErrorPopup, getErrorMessage } from '~/components/Error'; import type { LoadContext } from '~/server'; import ResponseError from '~/server/headscale/api-error'; import cn from '~/utils/cn'; @@ -37,29 +44,8 @@ export async function loader({ } export default function Layout() { - const { healthy } = useLoaderData(); - return ( <> - {!healthy ? ( -
-
- - Headscale is unreachable -
-
- ) : undefined}
diff --git a/app/layouts/shell.tsx b/app/layouts/shell.tsx index ddb1e7e..7fb7a17 100644 --- a/app/layouts/shell.tsx +++ b/app/layouts/shell.tsx @@ -116,6 +116,7 @@ export async function loader({ ), }, onboarding: request.url.endsWith('/onboarding'), + healthy: await context.client.healthcheck(), }; } catch { // No session, so we can just return diff --git a/app/routes/machines/overview.tsx b/app/routes/machines/overview.tsx index 249a881..c12ab8e 100644 --- a/app/routes/machines/overview.tsx +++ b/app/routes/machines/overview.tsx @@ -164,7 +164,3 @@ export default function Page() { ); } - -export function ErrorBoundary() { - return ; -} diff --git a/app/server/headscale/api-client.ts b/app/server/headscale/api-client.ts index 3de5638..7536652 100644 --- a/app/server/headscale/api-client.ts +++ b/app/server/headscale/api-client.ts @@ -1,8 +1,132 @@ import { readFile } from 'node:fs/promises'; +import { data } from 'react-router'; import { Agent, Dispatcher, request } from 'undici'; +import { errors } from 'undici'; import log from '~/utils/log'; import ResponseError from './api-error'; +function isNodeNetworkError(error: unknown): error is NodeJS.ErrnoException { + const keys = Object.keys(error as Record); + 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) { if (!certPath) { return new ApiClient(new Agent(), base); @@ -37,21 +161,34 @@ export class ApiClient { const method = options?.method ?? 'GET'; log.debug('api', '%s %s', method, url); - return await request(new URL(url, this.base), { - dispatcher: this.agent, - headers: { - ...options?.headers, - Accept: 'application/json', - 'User-Agent': `Headplane/${__VERSION__}`, - }, - body: options?.body, - method, - }); + try { + const res = await request(new URL(url, this.base), { + dispatcher: this.agent, + headers: { + ...options?.headers, + Accept: 'application/json', + 'User-Agent': `Headplane/${__VERSION__}`, + }, + body: options?.body, + method, + }); + + return res; + } catch (error: unknown) { + throw friendlyError(error); + } } async healthcheck() { 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; } catch (error) { log.debug('api', 'Healthcheck failed %o', error); diff --git a/app/server/web/oidc.ts b/app/server/web/oidc.ts index 6e12bda..c8cbcb6 100644 --- a/app/server/web/oidc.ts +++ b/app/server/web/oidc.ts @@ -75,76 +75,88 @@ export async function createOidcClient( } log.debug('config', 'Running OIDC discovery for %s', config.issuer); - const oidc = await client.discovery( - new URL(config.issuer), - config.client_id, - secret, - clientAuthMethod(config.token_endpoint_auth_method)(secret), - ); - - const metadata = oidc.serverMetadata(); - if (!metadata.authorization_endpoint) { - log.error( - 'config', - 'Issuer discovery did not return `authorization_endpoint`', + try { + const oidc = await client.discovery( + new URL(config.issuer), + config.client_id, + secret, + clientAuthMethod(config.token_endpoint_auth_method)(secret), ); - log.error('config', 'OIDC server does not support authorization code flow'); - return; - } - if (!metadata.token_endpoint) { - log.error('config', 'Issuer discovery did not return `token_endpoint`'); - log.error('config', 'OIDC server does not support token exchange'); - return; - } - - // If this field is missing, assume the server supports all response types - // and that we can continue safely. - if (metadata.response_types_supported) { - if (!metadata.response_types_supported.includes('code')) { + const metadata = oidc.serverMetadata(); + if (!metadata.authorization_endpoint) { log.error( 'config', - 'Issuer discovery `response_types_supported` does not include `code`', - ); - log.error('config', 'OIDC server does not support code flow'); - return; - } - } - - if (metadata.token_endpoint_auth_methods_supported) { - if ( - !metadata.token_endpoint_auth_methods_supported.includes( - config.token_endpoint_auth_method, - ) - ) { - log.error( - 'config', - 'Issuer discovery `token_endpoint_auth_methods_supported` does not include `%s`', - config.token_endpoint_auth_method, + 'Issuer discovery did not return `authorization_endpoint`', ); log.error( 'config', - 'OIDC server does not support %s', - config.token_endpoint_auth_method, + 'OIDC server does not support authorization code flow', ); return; } - } - if (!metadata.userinfo_endpoint) { - log.error('config', 'Issuer discovery did not return `userinfo_endpoint`'); - log.error('config', 'OIDC server does not support userinfo endpoint'); - return; - } + if (!metadata.token_endpoint) { + log.error('config', 'Issuer discovery did not return `token_endpoint`'); + log.error('config', 'OIDC server does not support token exchange'); + return; + } - log.debug('config', 'OIDC client created successfully'); - log.info('config', 'Using %s as the OIDC issuer', config.issuer); - log.debug( - 'config', - 'Authorization endpoint: %s', - metadata.authorization_endpoint, - ); - log.debug('config', 'Token endpoint: %s', metadata.token_endpoint); - log.debug('config', 'Userinfo endpoint: %s', metadata.userinfo_endpoint); - return oidc; + // If this field is missing, assume the server supports all response types + // and that we can continue safely. + if (metadata.response_types_supported) { + if (!metadata.response_types_supported.includes('code')) { + log.error( + 'config', + 'Issuer discovery `response_types_supported` does not include `code`', + ); + log.error('config', 'OIDC server does not support code flow'); + return; + } + } + + if (metadata.token_endpoint_auth_methods_supported) { + if ( + !metadata.token_endpoint_auth_methods_supported.includes( + config.token_endpoint_auth_method, + ) + ) { + log.error( + 'config', + 'Issuer discovery `token_endpoint_auth_methods_supported` does not include `%s`', + config.token_endpoint_auth_method, + ); + log.error( + 'config', + 'OIDC server does not support %s', + config.token_endpoint_auth_method, + ); + return; + } + } + + if (!metadata.userinfo_endpoint) { + log.error( + 'config', + 'Issuer discovery did not return `userinfo_endpoint`', + ); + log.error('config', 'OIDC server does not support userinfo endpoint'); + return; + } + + log.debug('config', 'OIDC client created successfully'); + log.info('config', 'Using %s as the OIDC issuer', config.issuer); + log.debug( + 'config', + 'Authorization endpoint: %s', + metadata.authorization_endpoint, + ); + log.debug('config', 'Token endpoint: %s', metadata.token_endpoint); + log.debug('config', 'Userinfo endpoint: %s', metadata.userinfo_endpoint); + 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); + } }