mirror of
https://github.com/tale/headplane.git
synced 2026-08-18 09:03:14 +00:00
feat: better error handling for oidc api key
This also ships with a better error page
This commit is contained in:
@@ -0,0 +1,57 @@
|
||||
import type { HeadscaleConnectionError } from './error';
|
||||
|
||||
/**
|
||||
* Represents an error returned by the Headscale API.
|
||||
*/
|
||||
export interface HeadscaleAPIError {
|
||||
requestUrl: `${string} ${string}`;
|
||||
statusCode: number;
|
||||
rawData: string;
|
||||
data: Record<string, unknown> | null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Type guard to check if an error is a HeadscaleAPIError.
|
||||
* @param error - The error to check.
|
||||
* @returns True if the error is a HeadscaleAPIError, false otherwise.
|
||||
*/
|
||||
export function isApiError(error: unknown): error is HeadscaleAPIError {
|
||||
return (
|
||||
error != null &&
|
||||
typeof error === 'object' &&
|
||||
'requestUrl' in error &&
|
||||
'statusCode' in error &&
|
||||
'rawData' in error &&
|
||||
'data' in error
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Type guard to check if an error is a HeadscaleConnectionError.
|
||||
* @param error - The error to check.
|
||||
* @returns True if the error is a HeadscaleConnectionError, false otherwise.
|
||||
*/
|
||||
export function isConnectionError(
|
||||
error: unknown,
|
||||
): error is HeadscaleConnectionError {
|
||||
return (
|
||||
error != null &&
|
||||
typeof error === 'object' &&
|
||||
'requestUrl' in error &&
|
||||
'errorCode' in error &&
|
||||
'errorMessage' in error &&
|
||||
'extraData' in error
|
||||
);
|
||||
}
|
||||
|
||||
export function isDataUnauthorizedError(error: unknown): boolean {
|
||||
return (
|
||||
error != null &&
|
||||
typeof error === 'object' &&
|
||||
'data' in error &&
|
||||
typeof error.data === 'object' &&
|
||||
error.data != null &&
|
||||
'statusCode' in error.data &&
|
||||
error.data.statusCode === 401
|
||||
);
|
||||
}
|
||||
@@ -1,123 +1,72 @@
|
||||
import { data } from 'react-router';
|
||||
import { errors } from 'undici';
|
||||
|
||||
/**
|
||||
* Helper function that determines if an error is a Node.js exception
|
||||
* @param - The error to check
|
||||
* @returns True if the error is a Node.js exception, false otherwise
|
||||
*/
|
||||
function isNodeNetworkError(error: unknown): error is NodeJS.ErrnoException {
|
||||
if (typeof error !== 'object' || error === null) {
|
||||
return false;
|
||||
}
|
||||
|
||||
const keys = Object.keys(error as Record<string, unknown>);
|
||||
return keys.includes('code') && keys.includes('errno');
|
||||
return (
|
||||
error != null &&
|
||||
typeof error === 'object' &&
|
||||
'code' in error &&
|
||||
'errno' in error
|
||||
);
|
||||
}
|
||||
|
||||
export function friendlyError(givenError: unknown) {
|
||||
let error: unknown = givenError;
|
||||
/**
|
||||
* A friendly error representation for Headscale connection issues.
|
||||
*/
|
||||
export interface HeadscaleConnectionError {
|
||||
requestUrl: string;
|
||||
errorCode: string;
|
||||
errorMessage: string;
|
||||
extraData: Record<string, unknown> | null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Convert an Undici error into a friendly HeadscaleAPIError.
|
||||
* This is used to avoid exposing rough error edges to the user.
|
||||
*
|
||||
* @param error - The Undici error to convert.
|
||||
* @param requestUrl - The URL of the request that caused the error.
|
||||
* @returns A friendly HeadscaleAPIError.
|
||||
*/
|
||||
export function undiciToFriendlyError(
|
||||
error: unknown,
|
||||
requestUrl: string,
|
||||
): HeadscaleConnectionError {
|
||||
// MARK: Do we need to go deeper into causes here?
|
||||
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,
|
||||
});
|
||||
if (error instanceof errors.UndiciError) {
|
||||
return {
|
||||
requestUrl,
|
||||
errorCode: error.code,
|
||||
errorMessage: error.message,
|
||||
extraData: null,
|
||||
};
|
||||
}
|
||||
|
||||
if (isNodeNetworkError(error)) {
|
||||
return {
|
||||
requestUrl,
|
||||
errorCode: error.code ?? 'UNKNOWN_NODE_NETWORK_ERROR',
|
||||
errorMessage: error.message,
|
||||
extraData: {
|
||||
syscall: error.syscall,
|
||||
path: error.path,
|
||||
errno: error.errno,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
requestUrl,
|
||||
errorCode: 'UNKNOWN_ERROR',
|
||||
errorMessage: 'An unknown error occured',
|
||||
extraData: null,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -2,11 +2,12 @@ import { createHash } from 'node:crypto';
|
||||
import { readFile } from 'node:fs/promises';
|
||||
import { dereference } from '@readme/openapi-parser';
|
||||
import type { OpenAPIV2 } from 'openapi-types';
|
||||
import { data } from 'react-router';
|
||||
import { Agent, type Dispatcher, request } from 'undici';
|
||||
import log from '~/utils/log';
|
||||
import endpointSets, { RuntimeApiClient } from './endpoints';
|
||||
import { friendlyError } from './error';
|
||||
import ResponseError from './response-error';
|
||||
import { undiciToFriendlyError } from './error';
|
||||
import { HeadscaleAPIError } from './error-client';
|
||||
import { detectApiVersion, isAtLeast, type Version } from './version';
|
||||
|
||||
/**
|
||||
@@ -133,7 +134,11 @@ export async function createHeadscaleInterface(
|
||||
|
||||
return res;
|
||||
} catch (error) {
|
||||
throw friendlyError(error);
|
||||
const errorBody = undiciToFriendlyError(error, `${method} ${url}`);
|
||||
throw data(errorBody, {
|
||||
status: 502,
|
||||
statusText: 'Bad Gateway',
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
@@ -183,10 +188,27 @@ export async function createHeadscaleInterface(
|
||||
apiPath,
|
||||
res.statusCode,
|
||||
);
|
||||
throw new ResponseError(
|
||||
res.statusCode,
|
||||
await res.body.text(),
|
||||
`${method} ${apiPath}`,
|
||||
|
||||
const rawData = await res.body.text();
|
||||
const jsonData = (() => {
|
||||
try {
|
||||
return JSON.parse(rawData) as Record<string, unknown>;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
})();
|
||||
|
||||
throw data(
|
||||
{
|
||||
requestUrl: `${method} ${apiPath}`,
|
||||
statusCode: res.statusCode,
|
||||
rawData,
|
||||
data: jsonData,
|
||||
} satisfies HeadscaleAPIError,
|
||||
{
|
||||
status: 502,
|
||||
statusText: 'Bad Gateway',
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -3,6 +3,7 @@
|
||||
export default class ResponseError extends Error {
|
||||
status: number;
|
||||
response: string;
|
||||
requestUrl: string;
|
||||
responseObject?: Record<string, unknown>;
|
||||
|
||||
constructor(status: number, response: string, requestUrl: string) {
|
||||
@@ -10,6 +11,7 @@ export default class ResponseError extends Error {
|
||||
this.name = 'ResponseError';
|
||||
this.status = status;
|
||||
this.response = response;
|
||||
this.requestUrl = requestUrl;
|
||||
|
||||
try {
|
||||
// Try to parse the response as JSON to get a response object
|
||||
|
||||
Reference in New Issue
Block a user