feat: better error handling for oidc api key

This also ships with a better error page
This commit is contained in:
Aarnav Tale
2025-11-28 17:54:33 -05:00
parent c0c4ecf631
commit d3d7c7cc0e
16 changed files with 476 additions and 256 deletions
+57
View File
@@ -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
);
}
+61 -112
View File
@@ -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,
};
}
+29 -7
View File
@@ -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