Enable useUnknownInCatchVariables in TypeScript (#68996)

* Enable `useUnknownInCatchVariables`

* Add `isErrnoException`

* Switch from unsafe `error.message` access to `getErrorMessage`/`ensureError`

* Make `serializeError` idempotent

* `TypeError` -> `unknown`

* Simplify `isErrnoException`, require `code`

* Create `isPermissionDeniedError` utility

* Make `isPermissionDeniedError` inline helper
This commit is contained in:
Grzegorz Zdunek
2026-07-31 09:01:36 +00:00
committed by GitHub
parent d1381cab23
commit 3d82a6efe4
59 changed files with 347 additions and 122 deletions
+1
View File
@@ -15,6 +15,7 @@
"strict": false,
"strictBindCallApply": true,
"noImplicitThis": true,
"useUnknownInCatchVariables": true,
"noEmitHelpers": true,
"resolveJsonModule": true,
"skipLibCheck": true,
@@ -310,10 +310,7 @@ function makeReviewStateOptions(
// Connect) means the reviewer can't see the eligible Access Lists, which
// is distinct from there being none. Detect it by status code rather than
// message text so it survives across versions; surface other errors as-is.
const err = fetchSuggestedAccessListsAttempt.error;
const isPermissionError =
err?.response?.status === 403 || err?.code === 'PERMISSION_DENIED';
msg = isPermissionError
msg = isPermissionDeniedError(fetchSuggestedAccessListsAttempt.error)
? "You don't have permission to view the Access Lists eligible for long-term approval of this request. You can still reject it."
: fetchSuggestedAccessListsAttempt.statusText;
} else if (request.resources.length === 0) {
@@ -395,3 +392,26 @@ const HorizontalLine = styled.div<{ height?: number }>`
function makeTraitLabel(traitKey: string, traitVals: string[]) {
return `${traitKey}: ${traitVals.sort().join(', ')}`;
}
/**
* Checks whether an error represents a permission denial from either the Web
* API or a gRPC service.
*
* TODO(gzdunek): Consider passing a permission-error predicate so that Web UI and Connect
* can identify their own native error type.
*/
function isPermissionDeniedError(error: unknown): boolean {
if (typeof error !== 'object' || error === null) {
return false;
}
if ('code' in error && error.code === 'PERMISSION_DENIED') {
return true;
}
return (
'response' in error &&
typeof error.response === 'object' &&
error.response !== null &&
'status' in error.response &&
error.response.status === 403
);
}
@@ -238,12 +238,22 @@ test('disables long-term approval and explains why when no Access List is sugges
// A permission failure (the reviewer can't read the eligible Access Lists) is a
// distinct state from there being none, and gets its own message.
test('shows a permission-specific message when the reviewer cannot view eligible Access Lists', async () => {
test.each([
[
'HTTP 403',
Object.assign(
new Error('access denied to perform action "read" on access list'),
{ response: { status: 403 } }
),
],
[
'gRPC PERMISSION_DENIED',
Object.assign(new Error('permission denied'), {
code: 'PERMISSION_DENIED',
}),
],
])('shows a permission-specific message for %s', async (_, permissionError) => {
const user = userEvent.setup();
const permissionError = Object.assign(
new Error('access denied to perform action "read" on access list'),
{ response: { status: 403 } }
);
render(
<RequestView
{...props}
@@ -30,7 +30,7 @@ import { Logger } from 'design/logger';
import type { ToastNotificationItem } from 'shared/components/ToastNotification';
import { Attempt } from 'shared/hooks/useAsync';
import { ClientScreenSpec, ClipboardData, TdpClient } from 'shared/libs/tdp';
import { isAbortError } from 'shared/utils/error';
import { getErrorMessage, isAbortError } from 'shared/utils/error';
declare global {
interface Window {
@@ -206,7 +206,7 @@ export default function useDesktopSession(
severity: 'warn',
content: {
title: 'Could not share a directory',
description: e.message,
description: getErrorMessage(e),
},
});
}
@@ -222,7 +222,7 @@ export default function useDesktopSession(
severity: 'warn',
content: {
title: 'Failed to unmount the shared directory',
description: e.message,
description: getErrorMessage(e),
},
});
}
@@ -22,9 +22,7 @@ import { FileTransferListeners } from './FileTransferStateless/types';
export interface FileTransferEventsEmitter extends FileTransferListeners {
emitProgress(progress: number): void;
emitError(error: Error): void;
emitError(error: unknown): void;
emitComplete(): void;
}
+5 -3
View File
@@ -18,6 +18,8 @@
import { useCallback, useEffect, useRef, useState } from 'react';
import { getErrorMessage } from 'shared/utils/error';
/**
* @deprecated Use TanStack Query (useQuery/useMutation) instead. See RFD 197.
*
@@ -209,7 +211,7 @@ export type Attempt<T> =
status: 'error';
data: null;
statusText: string;
error: any;
error: unknown;
};
export function hasFinished<T>(attempt: Attempt<T>): boolean {
@@ -240,12 +242,12 @@ export function makeProcessingAttempt<T>(): Attempt<T> {
};
}
export function makeErrorAttempt<T>(error: Error): Attempt<T> {
export function makeErrorAttempt<T>(error: unknown): Attempt<T> {
return {
data: null,
status: 'error',
error: error,
statusText: error.message,
statusText: getErrorMessage(error),
};
}
+7 -2
View File
@@ -19,6 +19,7 @@
import { useMemo, useState } from 'react';
import Logger from 'shared/libs/logger';
import { getErrorMessage } from 'shared/utils/error';
const logger = Logger.create('shared/hooks/useAttempt');
@@ -56,9 +57,13 @@ function makeActions(setState) {
setState({ ...defaultState });
}
function error(err: Error) {
function error(err: unknown) {
logger.error('attempt', err);
setState({ ...defaultState, isFailed: true, message: err.message });
setState({
...defaultState,
isFailed: true,
message: getErrorMessage(err),
});
}
function run(fn: Callback) {
+3 -2
View File
@@ -19,6 +19,7 @@
import { useCallback, useState } from 'react';
import Logger from 'shared/libs/logger';
import { getErrorMessage } from 'shared/utils/error';
const logger = Logger.create('shared/hooks/useAttempt');
@@ -33,9 +34,9 @@ export default function useAttemptNext(status = '' as Attempt['status']) {
statusText: '',
}));
const handleError = useCallback((err: Error) => {
const handleError = useCallback((err: unknown) => {
logger.error('attempt', err);
setAttempt({ status: 'failed', statusText: err.message });
setAttempt({ status: 'failed', statusText: getErrorMessage(err) });
}, []);
const run = useCallback((fn: Callback) => {
@@ -26,6 +26,7 @@ import {
import { Attempt } from 'shared/hooks/useAttemptNext';
import { isAbortError } from 'shared/utils/abortError';
import { getErrorMessage } from 'shared/utils/error';
// eslint-disable-next-line no-restricted-imports -- FIXME
import { ResourcesResponse } from 'teleport/services/agents';
@@ -147,7 +148,11 @@ export function useKeyBasedPagination<T>({
}
setState({
...stateRef.current,
attempt: { status: 'failed', statusText: err.message, statusCode },
attempt: {
status: 'failed',
statusText: getErrorMessage(err),
statusCode,
},
});
}
},
+3 -3
View File
@@ -26,7 +26,7 @@ import init, {
} from 'shared/libs/ironrdp/pkg/ironrdp';
// Inlines the wasm module as a static asset bundled with our app.
import wasmUrl from 'shared/libs/ironrdp/pkg/ironrdp_bg.wasm?inline';
import { ensureError, isAbortError } from 'shared/utils/error';
import { ensureError, getErrorMessage, isAbortError } from 'shared/utils/error';
import {
Alert,
@@ -737,7 +737,7 @@ export class TdpClient extends EventEmitter<EventMap> {
path: req.path,
},
});
this.handleWarning(e.message, TdpClientEvent.CLIENT_WARNING);
this.handleWarning(getErrorMessage(e), TdpClientEvent.CLIENT_WARNING);
}
}
@@ -762,7 +762,7 @@ export class TdpClient extends EventEmitter<EventMap> {
directoryId: req.directoryId,
errCode: SharedDirectoryErrCode.Failed,
});
this.handleWarning(e.message, TdpClientEvent.CLIENT_WARNING);
this.handleWarning(getErrorMessage(e), TdpClientEvent.CLIENT_WARNING);
}
}
+76 -1
View File
@@ -16,7 +16,7 @@
* along with this program. If not, see <http://www.gnu.org/licenses/>.
*/
import { ensureError, isAbortError } from './error';
import { ensureError, isAbortError, isErrnoException } from './error';
class CustomErrorClass extends Error {
constructor(message: string) {
@@ -115,6 +115,81 @@ describe('isAbortError', () => {
});
});
describe('isErrnoException', () => {
const cases: {
name: string;
input: unknown;
code: string;
expected: boolean;
}[] = [
{
name: 'Error with a matching code',
input: Object.assign(new Error('failed'), { code: 'ENOENT' }),
code: 'ENOENT',
expected: true,
},
{
name: 'AbortError with a matching code',
input: Object.assign(new Error('aborted'), {
name: 'AbortError',
code: 'ABORT_ERR',
}),
code: 'ABORT_ERR',
expected: true,
},
{
name: 'Error with a non-matching code',
input: Object.assign(new Error('failed'), { code: 'EPERM' }),
code: 'ENOENT',
expected: false,
},
{
name: 'Error without a code, expected code',
input: new Error('failed'),
code: 'ENOENT',
expected: false,
},
{
name: 'serialized error with a code',
input: {
name: 'Error',
message: 'failed',
code: 'ENOENT',
},
code: 'ENOENT',
expected: true,
},
{
name: 'object with a non-string code',
input: { code: 123 },
code: 'ENOENT',
expected: false,
},
{
name: 'string',
input: 'ENOENT',
code: 'ENOENT',
expected: false,
},
{
name: 'null',
input: null,
code: 'ENOENT',
expected: false,
},
{
name: 'undefined',
input: undefined,
code: 'ENOENT',
expected: false,
},
];
test.each(cases)('$name', ({ input, code, expected }) => {
expect(isErrnoException(input, code)).toBe(expected);
});
});
function newDOMAbortError() {
return new DOMException('Aborted', 'AbortError');
}
+17
View File
@@ -83,3 +83,20 @@ export class AbortError extends DOMException {
super(message, 'AbortError');
}
}
/**
* Checks whether an error has the expected Node.js system error code (errno exception).
* Works with both Error instances and serialized errors.
*
* This uses the conventional `error.code` check rather than attempting to
* identify the error's runtime class. Callers should pass a known system error
* code such as `ENOENT`, `EPERM`, or `ESRCH`.
*/
export function isErrnoException(error: unknown, code: string): boolean {
return (
!!error &&
typeof error === 'object' &&
'code' in error &&
error.code === code
);
}
@@ -22,6 +22,7 @@ import { useLocation, useParams } from 'react-router';
import { Flex, Indicator } from 'design';
import { AccessDenied } from 'design/CardError';
import useAttempt from 'shared/hooks/useAttemptNext';
import { getErrorMessage } from 'shared/utils/error';
import AuthnDialog from 'teleport/components/AuthnDialog';
import { CreateAppSessionParams, UrlLauncherParams } from 'teleport/config';
@@ -207,7 +208,7 @@ export function AppLauncher({
// `fetch` returns `TypeError` when there is a network error.
statusText = `Unable to access "${fqdn}". This may happen if your Teleport Proxy is using an untrusted or self-signed certificate. Please ensure Teleport Proxy service uses a valid certificate or access the application domain directly (https://${fqdn}${port}) and accept the certificate exception from your browser.`;
} else if (isRedirectFlow) {
statusText = `Error while authenticating a required app: ${err.message}`;
statusText = `Error while authenticating a required app: ${getErrorMessage(err)}`;
} else if (err instanceof Error) {
statusText = err.message;
}
@@ -354,6 +355,6 @@ function getNewAuthExchangeUrl({
return url;
}
function throwFailedToParseUrlError(err: TypeError) {
throw Error(`Failed to parse URL: ${err.message}`);
function throwFailedToParseUrlError(err: unknown) {
throw Error(`Failed to parse URL: ${getErrorMessage(err)}`);
}
@@ -32,6 +32,7 @@ import Link from 'design/Link';
import FieldInput from 'shared/components/FieldInput';
import Select from 'shared/components/Select';
import Validation, { Validator } from 'shared/components/Validation';
import { getErrorMessage } from 'shared/utils/error';
import cfg from 'teleport/config';
@@ -308,10 +309,16 @@ const EnterpriseHostError = () => {
</Box>
);
};
const InvalidHostError = ({ rule, error }: { rule: string; error: string }) => {
const InvalidHostError = ({
rule,
error,
}: {
rule: string;
error: unknown;
}) => {
return (
<Box>
Invalid address {rule}: {error}
Invalid address {rule}: {getErrorMessage(error)}
</Box>
);
};
@@ -18,6 +18,7 @@
import { Option } from 'shared/components/Select';
import { Rule } from 'shared/components/Validation/rules';
import { getErrorMessage } from 'shared/utils/error';
import { RefType } from 'teleport/services/bot/types';
@@ -113,6 +114,6 @@ export const requireValidRepository: Rule = value => () => {
return { valid: true };
} catch (e) {
return { valid: false, message: e?.message };
return { valid: false, message: getErrorMessage(e) };
}
};
@@ -22,6 +22,7 @@ import { useParams } from 'react-router';
import { Flex, Indicator } from 'design';
import { AccessDenied, BadRequest } from 'design/CardError';
import useAttempt from 'shared/hooks/useAttemptNext';
import { getErrorMessage } from 'shared/utils/error';
import AuthnDialog from 'teleport/components/AuthnDialog';
import { useMfa, shouldShowMfaPrompt } from 'teleport/lib/useMfa';
@@ -74,7 +75,7 @@ export function BrowserMfa({ onRedirect = redirectTo }: BrowserMfaProps) {
} catch (err) {
setAttempt({
status: 'failed',
statusText: err.message,
statusText: getErrorMessage(err),
});
}
}
@@ -26,6 +26,7 @@ import { IconTooltip } from 'design/Tooltip';
import TextEditor from 'shared/components/TextEditor';
import Validation, { Validator } from 'shared/components/Validation';
import { useAsync } from 'shared/hooks/useAsync';
import { getErrorMessage } from 'shared/utils/error';
import { TextSelectCopyMulti } from 'teleport/components/TextSelectCopy';
import cfg from 'teleport/config';
@@ -79,7 +80,7 @@ export function CreateAppAccess() {
resourceName: app.name,
});
} catch (err) {
emitErrorEvent(err.message);
emitErrorEvent(getErrorMessage(err));
throw err;
}
});
@@ -330,7 +330,7 @@ export function useCreateDatabase() {
setAttempt({ status: '' });
}
function handleRequestError(err: Error, preErrMsg = '') {
function handleRequestError(err: unknown, preErrMsg = '') {
const message = getErrMessage(err);
setAttempt({ status: 'failed', statusText: `${preErrMsg}${message}` });
emitErrorEvent(`${preErrMsg}${message}`);
@@ -38,6 +38,7 @@ import { TextSelectCopyMulti } from 'shared/components/TextSelectCopy';
import Validation, { Validator } from 'shared/components/Validation';
import { Rule } from 'shared/components/Validation/rules';
import { makeEmptyAttempt, useAsync } from 'shared/hooks/useAsync';
import { getErrorMessage } from 'shared/utils/error';
import { LabelsInput } from 'teleport/components/LabelsInput';
import cfg from 'teleport/config';
@@ -176,7 +177,7 @@ export function DiscoveryConfigSsm() {
},
});
} catch (err) {
emitErrorEvent(err.message);
emitErrorEvent(getErrorMessage(err));
throw err;
}
}
@@ -19,6 +19,7 @@
import { useState } from 'react';
import useAttempt from 'shared/hooks/useAttemptNext';
import { getErrorMessage } from 'shared/utils/error';
import {
getDatabaseProtocol,
@@ -107,7 +108,7 @@ export function useConnectionDiagnostic() {
}
} catch (err) {
handleError(err);
emitErrorEvent(err.message);
emitErrorEvent(getErrorMessage(err));
}
return { mfaRequired: false };
@@ -28,6 +28,7 @@ import Dialog, {
} from 'design/Dialog';
import Validation, { Validator } from 'shared/components/Validation';
import { Attempt, useAsync } from 'shared/hooks/useAsync';
import { getErrorMessage } from 'shared/utils/error';
import { CatchError } from 'teleport/components/CatchError';
import cfg from 'teleport/config';
@@ -417,12 +418,16 @@ const AttemptAlert = ({ attempt }: { attempt?: Attempt<unknown> }) => {
};
/** Renders an alert if there is an error. */
const ErrorAlert = ({ error }: { error: Error }) =>
error && (
<Danger mt={3} dismissible details={error.cause?.toString()}>
{error.message}
</Danger>
const ErrorAlert = ({ error }: { error: unknown }) => {
const details = error instanceof Error ? error.cause?.toString() : '';
return (
error && (
<Danger mt={3} dismissible details={details}>
{getErrorMessage(error)}
</Danger>
)
);
};
const ShowHide = styled(Flex)<{ hidden: boolean }>`
display: ${props => (props.hidden ? 'none' : '')};
@@ -85,7 +85,7 @@ export class TtyPlayer extends Player<TtyEvent> {
this.terminal.loadAddon(imageAddon);
this.addons.push(imageAddon);
} catch (e) {
this.logger.error(`Failed to load image addon: ${e.message}`);
this.logger.error('Failed to load image addon', e);
}
let webglAddon: WebglAddon | undefined;
@@ -23,6 +23,7 @@ import styled from 'styled-components';
import { Box, ButtonSecondary, Flex, Menu, MenuItem, Text } from 'design';
import { ChevronDown } from 'design/Icon';
import { HoverTooltip } from 'design/Tooltip';
import { getErrorMessage } from 'shared/utils/error';
import cfg from 'teleport/config';
import { Cluster } from 'teleport/services/clusters';
@@ -84,7 +85,7 @@ export function ClusterDropdown({
try {
return clusterLoader.fetchClusters(signal);
} catch (err) {
onError(err.message);
onError(getErrorMessage(err));
}
}
@@ -115,7 +116,7 @@ export function ClusterDropdown({
const res = await loadClusters(signal.signal);
setOptions(createOptions(res));
} catch (err) {
onError(err.message);
onError(getErrorMessage(err));
}
}
@@ -19,6 +19,7 @@
import { useCallback, useEffect, useState } from 'react';
import { Attempt, makeEmptyAttempt, useAsync } from 'shared/hooks/useAsync';
import { getErrorMessage } from 'shared/utils/error';
import auth from 'teleport/services/auth';
import { MfaChallengeScope } from 'teleport/services/auth/auth';
@@ -145,17 +146,18 @@ type challengeState = {
deviceUsage: DeviceUsage;
};
function getReAuthenticationErrorMessage(err: Error): string {
if (err.message.includes('attempt was made to use an object that is not')) {
function getReAuthenticationErrorMessage(err: unknown): string {
const message = getErrorMessage(err);
if (message.includes('attempt was made to use an object that is not')) {
// Catch a webauthn frontend error that occurs on Firefox and replace it with a more helpful error message.
return 'The two-factor device you used is not registered on this account. You must verify using a device that has already been registered.';
}
if (err.message === 'invalid totp token') {
if (message === 'invalid totp token') {
// This message relies on the status message produced by the auth server in
// lib/auth/Server.checkOTP function. Please keep these in sync.
return 'Invalid authenticator code';
}
return err.message;
return message;
}
@@ -132,7 +132,7 @@ export default class TtyTerminal implements TerminalSearcher {
this._imageAddon = new ImageAddon();
this.term.loadAddon(this._imageAddon);
} catch (e) {
logger.error('Failed to load image addon:', e.message);
logger.error('Failed to load image addon', e);
}
try {
+2 -1
View File
@@ -24,6 +24,7 @@ import {
makeEmptyAttempt,
useAsync,
} from 'shared/hooks/useAsync';
import { getErrorMessage } from 'shared/utils/error';
import { EventEmitterMfaSender } from 'teleport/lib/EventEmitterMfaSender';
import { TermEvent } from 'teleport/lib/term/enums';
@@ -163,7 +164,7 @@ export function useMfa(props?: MfaProps): MfaState {
setMfaAttempt({
data: null,
status: 'error',
statusText: err.message,
statusText: getErrorMessage(err),
error: err,
});
}
@@ -17,6 +17,8 @@
*/
import 'whatwg-fetch';
import { getErrorMessage } from 'shared/utils/error';
import auth, { MfaChallengeScope } from 'teleport/services/auth/auth';
import websession from 'teleport/services/websession';
@@ -293,7 +295,7 @@ const api = {
} catch (err) {
// error reading JSON
const message = response.ok
? err.message
? getErrorMessage(err)
: `${response.status} - ${response.url}`;
throw new ApiError({ message, response, opts: { cause: err } });
}
@@ -455,8 +457,8 @@ export function getHostName() {
return location.hostname + (location.port ? ':' + location.port : '');
}
export function isAdminActionRequiresMfaError(err: Error) {
return err.message.includes(
export function isAdminActionRequiresMfaError(err: unknown) {
return getErrorMessage(err).includes(
'admin-level API request requires MFA verification'
);
}
@@ -20,6 +20,7 @@ import { MutationFunction } from '@tanstack/react-query';
import cfg from 'teleport/config';
import api from 'teleport/services/api';
import { ApiError } from 'teleport/services/api/parseError';
import {
canUseV1Edit,
canUseV2Edit,
@@ -67,7 +68,7 @@ export async function getBot(
.then(makeBot);
} catch (err) {
// capture the not found error response and return null instead of throwing
if (err?.response?.status === 404) {
if (err instanceof ApiError && err.response.status === 404) {
return null;
}
throw err;
@@ -16,7 +16,7 @@
* along with this program. If not, see <http://www.gnu.org/licenses/>.
*/
import { getErrMessage } from 'shared/utils/errorType';
import { getErrorMessage } from 'shared/utils/error';
import { App } from 'teleport/services/apps/types';
import {
@@ -124,7 +124,7 @@ export const withUnsupportedOktaPluginUpdateErrorConversion = (
err: unknown
) => {
if (err instanceof ApiError && err.response.status === 404) {
const msg = getErrMessage(err);
const msg = getErrorMessage(err);
throw new Error(
`Could not update Okta plugin: ${msg}. Your proxy may be behind the minimum required version (v17.3.0) to support Okta plugin updates with this web client.`
);
@@ -136,7 +136,7 @@ export const withUnsupportedOktaPluginCreateErrorConversion = (
err: unknown
) => {
if (err instanceof ApiError) {
const msg = getErrMessage(err);
const msg = getErrorMessage(err);
if (msg.match(/missing okta (?:organization url|api token)/gi)) {
throw new Error(
`Could not create Okta plugin: ${msg}. Your proxy may be behind the minimum required version (v17.3.0) to support Okta plugin creation with this web client.`
@@ -147,7 +147,7 @@ export const withUnsupportedOktaPluginCreateErrorConversion = (
};
type Base = {
err: Error;
err: unknown;
};
type CreateJoinToken = Base & {
@@ -212,7 +212,10 @@ export function useV1Fallback() {
async function tryV1Fallback(props: CreateJoinToken): Promise<JoinToken>;
async function tryV1Fallback(props: FallbackProps) {
if (!props.err.message.includes(ProxyRequiresUpgrade) || hasLabels(props)) {
if (
!getErrorMessage(props.err).includes(ProxyRequiresUpgrade) ||
hasLabels(props)
) {
throw props.err;
}
@@ -25,6 +25,8 @@ import { tmpdir } from 'node:os';
import path from 'node:path';
import process from 'node:process';
import { isErrnoException } from 'shared/utils/error';
const stdio = 'pipe'; // Change to 'inherit' for easier debugging.
let logsDir: string;
@@ -197,7 +199,7 @@ const isRunning = (pid: number) => {
try {
return process.kill(pid, 0);
} catch (error) {
if (error.code === 'ESRCH') {
if (isErrnoException(error, 'ESRCH')) {
return false;
}
+6 -4
View File
@@ -30,7 +30,7 @@ export type DeepLinkParseResult =
// ergonomic. Unfortunately, `if (!result.ok)` doesn't narrow down the type properly with
// strictNullChecks off. https://github.com/microsoft/TypeScript/issues/10564
| DeepLinkParseResultSuccess
| ParseError<'malformed-url', { error: TypeError }>
| ParseError<'malformed-url', { error: unknown }>
| ParseError<'unknown-protocol', { protocol: string }>
| ParseError<'unsupported-url'>;
@@ -69,9 +69,11 @@ export function parseDeepLink(rawUrl: string): DeepLinkParseResult {
try {
parsedURL = new URL(rawUrl);
} catch (error) {
// `error instanceof TypeError` doesn't work in tests. The URL constructor shouldn't throw other
// errors anyway.
return { status: 'error', reason: 'malformed-url', error };
return {
status: 'error',
reason: 'malformed-url',
error,
};
}
if (parsedURL.protocol !== `${CUSTOM_PROTOCOL}:`) {
+7 -3
View File
@@ -23,7 +23,11 @@ import path from 'node:path';
import { app, dialog, nativeTheme } from 'electron';
import { CUSTOM_PROTOCOL } from 'shared/deepLinks';
import { ensureError } from 'shared/utils/error';
import {
ensureError,
getErrorMessage,
isErrnoException,
} from 'shared/utils/error';
import { parseDeepLink } from 'teleterm/deepLinks';
import Logger from 'teleterm/logger';
@@ -366,7 +370,7 @@ function launchDeepLink(
break;
}
case 'malformed-url': {
reason = `malformed URL (${result.error.message})`;
reason = `malformed URL (${getErrorMessage(result.error)})`;
break;
}
default: {
@@ -429,7 +433,7 @@ async function migrateOldTshHomeOnce(
try {
await fs.stat(oldTshHome);
} catch (err) {
if (err.code === 'ENOENT') {
if (isErrnoException(err, 'ENOENT')) {
logger.info(
'Old tsh directory does not exist, marking migration as processed'
);
@@ -26,6 +26,7 @@ import { createUnzip } from 'node:zlib';
import { extract } from 'tar-fs';
import { isErrnoException } from 'shared/utils/error';
import { compareSemVers } from 'shared/utils/semVer';
import Logger from 'teleterm/logger';
@@ -149,7 +150,7 @@ async function isCorrectAgentVersionAlreadyDownloaded(
return agentVersion.stdout.trim() === neededVersion;
} catch (e) {
// When the agent is being downloaded for the first time, the binary does not yet exist.
if (e.code !== 'ENOENT') {
if (!isErrnoException(e, 'ENOENT')) {
throw e;
}
return false;
@@ -20,7 +20,6 @@ import {
Cluster,
LoggedInUser,
} from 'gen-proto-ts/teleport/lib/teleterm/v1/cluster_pb';
import { ensureError } from 'shared/utils/error';
import Logger from 'teleterm/logger';
import type { IAwaitableSender } from 'teleterm/mainProcess/awaitableSender';
@@ -381,7 +380,7 @@ export class ClusterLifecycleManager {
);
const serialized: ProfileWatcherError = {
reason: watcherError.reason,
error: serializeError(ensureError(watcherError.error)),
error: serializeError(watcherError.error),
};
this.windowsManager
.getWindow()
@@ -22,6 +22,7 @@ import path from 'node:path';
import { promisify } from 'node:util';
import * as connectMyComputer from 'shared/connectMyComputer';
import { isErrnoException } from 'shared/utils/error';
import { RuntimeSettings } from 'teleterm/mainProcess/types';
import { RootClusterUri, routing } from 'teleterm/ui/uri';
@@ -127,7 +128,7 @@ export async function isAgentConfigFileCreated(
await fs.access(configFile);
return true;
} catch (e) {
if (e.code === 'ENOENT') {
if (isErrnoException(e, 'ENOENT')) {
return false;
}
throw e;
@@ -64,3 +64,17 @@ test('serializes and deserializes RPC error', () => {
'is-resolvable-with-relogin': ['1'],
});
});
test('serializing an already serialized RPC error preserves custom fields', () => {
const err = new RpcError('Session expired', 'UNAUTHENTICATED', {
'is-resolvable-with-relogin': ['1'],
});
const serialized = serializeError(err);
const serializedAgain = serializeError(structuredClone(serialized));
expect(serializedAgain['code']).toBe('UNAUTHENTICATED');
expect(serializedAgain['meta']).toEqual({
'is-resolvable-with-relogin': ['1'],
});
});
@@ -16,16 +16,34 @@
* along with this program. If not, see <http://www.gnu.org/licenses/>.
*/
import { ensureError } from 'shared/utils/error';
export type SerializedError = {
name: string;
message: string;
stack?: string;
cause?: unknown;
toStringResult?: string;
$serializedError?: true;
};
/** Serializes an Error into a plain object for transport through Electron IPC. */
export function serializeError(error: Error): SerializedError {
function isSerialized(error: unknown): error is SerializedError {
return (
!!error &&
typeof error === 'object' &&
error['$serializedError'] === true &&
typeof error['name'] === 'string' &&
typeof error['message'] === 'string'
);
}
/** Serializes an error into a plain object for transport through Electron IPC. */
export function serializeError(error: unknown): SerializedError {
if (isSerialized(error)) {
return error;
}
const errorInstance = ensureError(error);
const {
name,
cause,
@@ -34,7 +52,7 @@ export function serializeError(error: Error): SerializedError {
// functions must be skipped, otherwise structuredClone will fail to clone the object
toString,
...enumerableFields
} = error;
} = errorInstance;
return {
name,
message,
@@ -42,14 +60,23 @@ export function serializeError(error: Error): SerializedError {
stack,
// Calling the destructured function directly could result in the following error:
// Method Error.prototype.toString called on incompatible receiver undefined
toStringResult: error.toString?.(),
toStringResult: errorInstance.toString?.(),
...enumerableFields,
$serializedError: true,
};
}
/** Deserializes a plain object back into an Error instance. */
export function deserializeError(serialized: SerializedError): Error {
const { name, cause, stack, message, toStringResult, ...rest } = serialized;
const {
name,
cause,
stack,
message,
toStringResult,
$serializedError,
...rest
} = serialized;
const error = new Error(message);
error.name = name;
error.cause = cause;
@@ -19,6 +19,8 @@
import crypto from 'crypto';
import fs from 'fs';
import { getErrorMessage } from 'shared/utils/error';
const UUID_V4_REGEX =
/^[0-9A-F]{8}-[0-9A-F]{4}-4[0-9A-F]{3}-[89AB][0-9A-F]{3}-[0-9A-F]{12}$/i;
@@ -46,7 +48,7 @@ function writeInstallationId(filePath: string): string {
fs.writeFileSync(filePath, newId);
} catch (error) {
throw new Error(
`Could not write installation_id to ${filePath}, ${error.message}`,
`Could not write installation_id to ${filePath}, ${getErrorMessage(error)}`,
{ cause: error }
);
}
@@ -18,8 +18,6 @@
import { ipcRenderer } from 'electron';
import { ensureError } from 'shared/utils/error';
import Logger from 'teleterm/logger';
import type { Message, MessageAck } from 'teleterm/mainProcess/awaitableSender';
import { CreateAgentConfigFileArgs } from 'teleterm/mainProcess/createAgentConfigFile';
@@ -325,7 +323,7 @@ function startAwaitableSenderListener<T>(
try {
await listener(msg.payload as T);
} catch (e) {
ack.error = serializeError(ensureError(e));
ack.error = serializeError(e);
}
try {
@@ -23,6 +23,7 @@ import os from 'node:os';
import path from 'node:path';
import { Cluster } from 'gen-proto-ts/teleport/lib/teleterm/v1/cluster_pb';
import { isErrnoException } from 'shared/utils/error';
import { wait } from 'shared/utils/wait';
import Logger, { NullService } from 'teleterm/logger';
@@ -65,7 +66,7 @@ async function mockTshClient(tshDir: string, initial: { clusters: Cluster[] }) {
try {
paths = await fs.readdir(tshDir);
} catch (err) {
if (err.code === 'ENOENT') {
if (isErrnoException(err, 'ENOENT')) {
throw {
name: 'RpcError',
code: 'NOT_FOUND',
@@ -88,7 +89,7 @@ async function mockTshClient(tshDir: string, initial: { clusters: Cluster[] }) {
// The file with the cluster disappeared between fs.readdir above and fs.readFile.
// This is possible in tests where we call `void tshClientMock.removeCluster` without
// awaiting.
if (err.code === 'ENOENT') {
if (isErrnoException(err, 'ENOENT')) {
return null;
}
throw err;
@@ -20,6 +20,7 @@ import { watch, type WatchEventType } from 'node:fs';
import { access } from 'node:fs/promises';
import { Cluster } from 'gen-proto-ts/teleport/lib/teleterm/v1/cluster_pb';
import { isErrnoException } from 'shared/utils/error';
import { debounce } from 'shared/utils/highbar';
import { wait } from 'shared/utils/wait';
@@ -129,7 +130,7 @@ export async function* watchProfiles({
if (
isTshdRpcError(error, 'NOT_FOUND') ||
error instanceof FileSystemEventsOverflowError ||
error?.code === 'EPERM'
isErrnoException(error, 'EPERM')
) {
const ok = await pathExists(tshDirectory);
if (!ok) {
@@ -161,7 +162,7 @@ async function pathExists(dirPath: string): Promise<boolean> {
await access(dirPath);
return true;
} catch (error) {
if (error.code === 'ENOENT') {
if (isErrnoException(error, 'ENOENT')) {
return false;
}
throw error;
@@ -246,7 +246,8 @@ export class WindowsManager {
await this.whenFrontendAppIsReady();
} catch (error) {
this.logger.error(
`Could not send the deep link to the frontend app: ${error.message}`
'Could not send the deep link to the frontend app',
error
);
return;
}
@@ -24,7 +24,7 @@ import { NsisUpdater } from 'electron-updater';
import { DownloadUpdateOptions } from 'electron-updater/out/AppUpdater';
import { InstallOptions } from 'electron-updater/out/BaseUpdater';
import { getErrorMessage } from 'shared/utils/error';
import { ensureError, getErrorMessage } from 'shared/utils/error';
import {
TSH_AUTOUPDATE_ENV_VAR,
@@ -100,7 +100,7 @@ export class NsisDualModeUpdater extends NsisUpdater {
[TSH_AUTOUPDATE_ENV_VAR]: TSH_AUTOUPDATE_OFF,
});
} catch (error) {
this.dispatchError(error);
this.dispatchError(ensureError(error));
const errorMessage = getErrorMessage(error);
if (!errorMessage.includes('failed to ensure service is running')) {
// If not a problem with starting the service, keep the app open and surface the error in the UI.
@@ -33,7 +33,7 @@ const logger = new Logger('ConfigService');
export type FileLoadingError = {
source: 'file-loading';
error: Error;
error: unknown;
};
export type ValidationError = {
@@ -21,6 +21,7 @@ import fs from 'node:fs';
import fsAsync from 'node:fs/promises';
import path from 'node:path';
import { isErrnoException } from 'shared/utils/error';
import { debounce } from 'shared/utils/highbar';
import Logger from 'teleterm/logger';
@@ -52,7 +53,7 @@ export interface FileStorage {
getFileName(): string;
/** Returns the error that could occur while reading and parsing the file. */
getFileLoadingError(): Error | undefined;
getFileLoadingError(): unknown;
}
/**
@@ -80,7 +81,7 @@ export function createFileStorage(opts: {
const { filePath } = opts;
let state: any, error: Error | undefined;
let state: any, error: unknown;
try {
state = loadStateSync(filePath);
} catch (e) {
@@ -127,7 +128,7 @@ export function createFileStorage(opts: {
return path.basename(opts.filePath);
}
function getFileLoadingError(): Error | undefined {
function getFileLoadingError(): unknown {
return error;
}
@@ -161,7 +162,7 @@ function readOrCreateFileSync(filePath: string): string {
try {
return fs.readFileSync(filePath, { encoding: 'utf-8' });
} catch (error) {
if (error?.code === 'ENOENT') {
if (isErrnoException(error, 'ENOENT')) {
fs.writeFileSync(filePath, defaultValue);
return defaultValue;
}
@@ -20,6 +20,7 @@ import { watch, type Stats } from 'fs';
import { readFile, rename, stat, writeFile } from 'fs/promises';
import path from 'path';
import { isErrnoException } from 'shared/utils/error';
import { wait } from 'shared/utils/wait';
import { makeCert } from './makeCert';
@@ -68,7 +69,7 @@ export async function readGrpcCert(
try {
stats = await stat(fullPath);
} catch (error) {
if (error?.code === 'ENOENT') {
if (isErrnoException(error, 'ENOENT')) {
return false;
}
throw error;
@@ -28,8 +28,6 @@ import {
UnaryCall,
} from '@protobuf-ts/runtime-rpc';
import { ensureError } from 'shared/utils/error';
import {
serializeError,
type SerializedError,
@@ -314,7 +312,7 @@ export function isRpcErrorReloginResolvable(error: unknown): boolean {
}
function cloneError(error: unknown): SerializedError {
return serializeError(ensureError(error));
return serializeError(error);
}
function cloneRequests<O extends object>(
@@ -24,6 +24,7 @@ import { promisify } from 'node:util';
import * as nodePTY from 'node-pty';
import which from 'which';
import { getErrorMessage } from 'shared/utils/error';
import { wait } from 'shared/utils/wait';
import Logger from 'teleterm/logger';
@@ -257,11 +258,11 @@ export class PtyProcess extends EventEmitter implements IPtyProcess {
}
}
private handleStartError(error: Error) {
private handleStartError(error: unknown) {
const command = `${this.options.path} ${this.options.args.join(' ')}`;
this.emit(
TermEventEnum.StartError,
`Cannot execute ${command}: ${error.message}`
`Cannot execute ${command}: ${getErrorMessage(error)}`
);
}
@@ -20,6 +20,7 @@ import { useCallback, useEffect, useState } from 'react';
import styled from 'styled-components';
import { Indicator } from 'design';
import { getErrorMessage } from 'shared/utils/error';
import { useAppContext } from 'teleterm/ui/appContextProvider';
import { useLogger } from 'teleterm/ui/hooks/useLogger';
@@ -57,10 +58,10 @@ export const AppInitializer = () => {
success: true,
});
} catch (error) {
logger.error(error?.message);
logger.error('Failed to initialize app', error);
setShouldShowUi(true);
appContext?.notificationsService.notifyError(error?.message);
appContext?.notificationsService.notifyError(getErrorMessage(error));
appContext?.mainProcessClient.signalUserInterfaceReadiness({
success: false,
});
@@ -22,6 +22,7 @@ import styled from 'styled-components';
import { Alert, Box, ButtonPrimary, Flex, H1, Text } from 'design';
import * as Alerts from 'design/Alert';
import { Attempt, makeEmptyAttempt, useAsync } from 'shared/hooks/useAsync';
import { getErrorMessage } from 'shared/utils/error';
import { wait } from 'shared/utils/wait';
import {
@@ -244,7 +245,7 @@ function AgentSetup() {
!isRpcErrorReloginResolvable(error)
) {
throw new Error(
`Cannot set up the role: ${error.message}. Contact your administrator for permissions to manage users and roles.`,
`Cannot set up the role: ${getErrorMessage(error)}. Contact your administrator for permissions to manage users and roles.`,
{ cause: error }
);
}
@@ -431,7 +432,7 @@ function AgentSetup() {
const { agentBinaryPath } = mainProcessClient.getRuntimeSettings();
notificationsService.notifyError({
title: 'Could not remove the agent binary',
description: `Please try removing the binary manually to continue. The binary is at ${agentBinaryPath}. The error message was: ${error.message}`,
description: `Please try removing the binary manually to continue. The binary is at ${agentBinaryPath}. The error message was: ${getErrorMessage(error)}`,
});
return;
}
@@ -36,6 +36,7 @@ import * as icons from 'design/Icon';
import type { IconProps } from 'design/Icon/Icon';
import Indicator from 'design/Indicator';
import { MenuIcon } from 'shared/components/MenuAction';
import { getErrorMessage } from 'shared/utils/error';
import type * as tsh from 'teleterm/services/tshd/types';
import { useAppContext } from 'teleterm/ui/appContextProvider';
@@ -112,7 +113,7 @@ export function Status(props: { closeDocument?: () => void }) {
} catch (e) {
ctx.notificationsService.notifyError({
title: 'Failed to open agent logs directory',
description: `${e.message}\n\nNote: the logs directory is created only after the agent process successfully spawns.`,
description: `${getErrorMessage(e)}\n\nNote: the logs directory is created only after the agent process successfully spawns.`,
});
}
}
@@ -22,6 +22,7 @@ import {
DeepURL,
VnetDeepURL,
} from 'shared/deepLinks';
import { getErrorMessage } from 'shared/utils/error';
import { DeepLinkParseResult } from 'teleterm/deepLinks';
import { IAppContext } from 'teleterm/ui/types';
@@ -59,7 +60,7 @@ export async function launchDeepLink(
break;
}
case 'malformed-url': {
reason = `The URL of the link appears to be malformed. ${result.error.message}`;
reason = `The URL of the link appears to be malformed. ${getErrorMessage(result.error)}`;
break;
}
default: {
@@ -238,14 +238,15 @@ export default class TtyTerminal implements TerminalSearcher {
this.term.onData(data => {
this.ptyProcess.write(data).catch(error => {
this.logger.error(`Failed to write to the PTY process: ${error}`);
this.logger.error('Failed to write to the PTY process', error);
});
});
this.term.onResize(size => {
this.ptyProcess.resize(size.cols, size.rows).catch(error => {
this.logger.error(
`Failed to send resize request to the PTY process: ${error}`
'Failed to send resize request to the PTY process',
error
);
});
});
@@ -259,7 +260,7 @@ export default class TtyTerminal implements TerminalSearcher {
// The shared process version of PtyProcess knows whether it was started or not (the status
// field), so it's a matter of exposing this field through gRPC and reading it here.
this.ptyProcess.start(this.term.cols, this.term.rows).catch(error => {
this.logger.error(`Failed to start the PTY process: ${error}`);
this.logger.error('Failed to start the PTY process', error);
});
window.addEventListener('resize', this.debouncedResize);
@@ -89,7 +89,7 @@ export function useDocumentTerminal(doc: types.DocumentTerminal) {
return () => {
if (attempt.status === 'success') {
void attempt.data.ptyProcess.dispose().catch(error => {
logger.error(`Failed to dispose of the PTY process: ${error}`);
logger.error('Failed to dispose of the PTY process', error);
});
}
};
@@ -36,6 +36,7 @@ import { copyToClipboard } from 'design/utils/copyToClipboard';
import { Timestamp } from 'gen-proto-ts/google/protobuf/timestamp_pb';
import * as diag from 'gen-proto-ts/teleport/lib/vnet/diag/v1/diag_pb';
import { CanceledError, useAsync } from 'shared/hooks/useAsync';
import { getErrorMessage } from 'shared/utils/error';
import { pluralize } from 'shared/utils/text';
import {
@@ -122,7 +123,7 @@ export function DocumentVnetDiagReport(props: {
previousSaveToFileNotificationIdRef.current =
notificationsService.notifyError({
title: 'Could not save the report to a file.',
description: error?.message,
description: getErrorMessage(error),
});
return;
}
@@ -16,6 +16,8 @@
* along with this program. If not, see <http://www.gnu.org/licenses/>.
*/
import { getErrorMessage } from 'shared/utils/error';
import { IAppContext } from 'teleterm/ui/types';
import { ClusterUri, RootClusterUri, routing } from 'teleterm/ui/uri';
@@ -35,7 +37,7 @@ const commands = {
error => {
ctx.notificationsService.notifyError({
title: 'Could not install tsh in PATH',
description: `Ran into an error: ${error}`,
description: `Ran into an error: ${getErrorMessage(error)}`,
});
}
);
@@ -57,7 +59,7 @@ const commands = {
error => {
ctx.notificationsService.notifyError({
title: 'Could not remove tsh from PATH',
description: `Ran into an error: ${error}`,
description: `Ran into an error: ${getErrorMessage(error)}`,
});
}
);
@@ -26,7 +26,7 @@ import {
ReviewAccessRequestRequest,
} from 'gen-proto-ts/teleport/lib/teleterm/v1/service_pb';
import { useStore } from 'shared/libs/stores';
import { AbortError, isAbortError } from 'shared/utils/error';
import { AbortError, getErrorMessage, isAbortError } from 'shared/utils/error';
import type { State as ClustersState } from 'teleterm/mainProcess/clusterStore';
import { MainProcessClient } from 'teleterm/mainProcess/types';
@@ -89,7 +89,7 @@ export class ClustersService extends ImmutableStore<ClustersServiceState> {
const notificationId = this.notificationsService.notifyError({
title: `Could not synchronize cluster ${clusterName}`,
description: e.message,
description: getErrorMessage(e),
action: {
content: 'Retry',
onClick: () => {
@@ -112,7 +112,7 @@ export class ClustersService extends ImmutableStore<ClustersServiceState> {
const notificationId = this.notificationsService.notifyError({
title: `Could not start headless requests watcher for ${clusterName}`,
description: e.message,
description: getErrorMessage(e),
action: {
content: 'Retry',
onClick: () => {
@@ -166,7 +166,7 @@ export class ClustersService extends ImmutableStore<ClustersServiceState> {
}
const notificationId = this.notificationsService.notifyError({
title: 'Could not fetch root clusters',
description: error.message,
description: getErrorMessage(error),
action: {
content: 'Retry',
onClick: () => {
@@ -193,7 +193,7 @@ export class ClustersService extends ImmutableStore<ClustersServiceState> {
} catch (error) {
const notificationId = this.notificationsService.notifyError({
title: 'Could not synchronize database connections',
description: error.message,
description: getErrorMessage(error),
action: {
content: 'Retry',
onClick: () => {
@@ -301,7 +301,7 @@ export class ClustersService extends ImmutableStore<ClustersServiceState> {
const notificationId = this.notificationsService.notifyError({
title,
description: error.message,
description: getErrorMessage(error),
action: {
content: 'Retry',
onClick: () => {
@@ -19,6 +19,7 @@
import { Timestamp } from 'gen-proto-ts/google/protobuf/timestamp_pb';
import { SubmitConnectEventRequest } from 'gen-proto-ts/prehog/v1alpha/connect_pb';
import { Cluster } from 'gen-proto-ts/teleport/lib/teleterm/v1/cluster_pb';
import { getErrorMessage } from 'shared/utils/error';
import Logger from 'teleterm/logger';
import { RuntimeSettings } from 'teleterm/mainProcess/types';
@@ -304,9 +305,9 @@ export class UsageService {
} catch (e) {
this.notificationsService.notifyWarning({
title: 'Failed to report usage event',
description: e.message,
description: getErrorMessage(e),
});
this.logger.warn(`Failed to report usage event`, e.message);
this.logger.warn('Failed to report usage event', e);
}
}
@@ -54,7 +54,7 @@ export async function retryWithRelogin<T>(
resourceUri: ClusterOrResourceUri,
actionToRetry: () => Promise<T>
): Promise<T> {
let retryableErrorFromActionToRetry: Error;
let retryableErrorFromActionToRetry: unknown;
try {
return await actionToRetry();
} catch (error) {