Skip to content

Commit a83c471

Browse files
committed
feat(errors): surface error_description
Problem: Some services such as OIDC supply the non-standard `error_description` SDK field on some errors. If it is present, it contains more useful information than the default `message` field. But our error handling does not surface this field. ref aws/aws-toolkit-jetbrains@cc9ed87 Solution: Introduce `getErrorMsg()` and use it in `resolveErrorMessageToDisplay()`.
1 parent c1b0a7c commit a83c471

File tree

2 files changed

+149
-68
lines changed

2 files changed

+149
-68
lines changed

packages/core/src/shared/errors.ts

Lines changed: 61 additions & 67 deletions
Original file line numberDiff line numberDiff line change
@@ -87,6 +87,18 @@ export interface ErrorInformation {
8787
readonly documentationUri?: vscode.Uri
8888
}
8989

90+
export class UnknownError extends Error {
91+
public override readonly name = 'UnknownError'
92+
93+
public constructor(public readonly cause: unknown) {
94+
super(String(cause))
95+
}
96+
97+
public static cast(obj: unknown): Error {
98+
return obj instanceof Error ? obj : new UnknownError(obj)
99+
}
100+
}
101+
90102
/**
91103
* Anonymous class with a pre-defined error name.
92104
*/
@@ -217,9 +229,44 @@ export class ToolkitError extends Error implements ErrorInformation {
217229
}
218230
}
219231

232+
export function getErrorMsg(err: Error | undefined): string | undefined {
233+
if (err === undefined) {
234+
return undefined
235+
}
236+
237+
// error_description is a non-standard SDK field added by (at least) OIDC service.
238+
// If present, it has better information, so prefer it to `message`.
239+
// https://github.com/aws/aws-toolkit-jetbrains/commit/cc9ed87fa9391dd39ac05cbf99b4437112fa3d10
240+
//
241+
// Example:
242+
//
243+
// [error] API response (oidc.us-east-1.amazonaws.com /token): {
244+
// name: 'InvalidGrantException',
245+
// '$fault': 'client',
246+
// '$metadata': {
247+
// httpStatusCode: 400,
248+
// requestId: '7f5af448-5af7-45f2-8e47-5808deaea4ab',
249+
// extendedRequestId: undefined,
250+
// cfId: undefined
251+
// },
252+
// error: 'invalid_grant',
253+
// error_description: 'Invalid refresh token provided',
254+
// message: 'UnknownError'
255+
// }
256+
const anyDesc = (err as any).error_description
257+
const errDesc = typeof anyDesc === 'string' ? anyDesc.trim() : ''
258+
const msg = errDesc !== '' ? errDesc : err.message?.trim()
259+
260+
if (typeof msg !== 'string') {
261+
return undefined
262+
}
263+
264+
return msg
265+
}
266+
220267
export function formatError(err: Error): string {
221268
const code = hasCode(err) && err.code !== err.name ? `[${err.code}]` : undefined
222-
const parts = [`${err.name}:`, err.message, code, formatDetails(err)]
269+
const parts = [`${err.name}:`, getErrorMsg(err), code, formatDetails(err)]
223270

224271
return parts.filter(isNonNullable).join(' ')
225272
}
@@ -249,18 +296,6 @@ function formatDetails(err: Error): string | undefined {
249296
return `(${joined})`
250297
}
251298

252-
export class UnknownError extends Error {
253-
public override readonly name = 'UnknownError'
254-
255-
public constructor(public readonly cause: unknown) {
256-
super(String(cause))
257-
}
258-
259-
public static cast(obj: unknown): Error {
260-
return obj instanceof Error ? obj : new UnknownError(obj)
261-
}
262-
}
263-
264299
export function getTelemetryResult(error: unknown | undefined): Result {
265300
if (error === undefined) {
266301
return 'Succeeded'
@@ -273,38 +308,10 @@ export function getTelemetryResult(error: unknown | undefined): Result {
273308

274309
/** Gets the (partial) error message detail for the `reasonDesc` field. */
275310
export function getTelemetryReasonDesc(err: unknown | undefined): string | undefined {
276-
if (err === undefined) {
277-
return undefined
278-
}
311+
const msg = getErrorMsg(err as Error)
279312

280-
const e = err as any
281-
// error_description is a non-standard SDK field added by OIDC service.
282-
// It has improved messages, so prefer it if found.
283-
// https://github.com/aws/aws-toolkit-jetbrains/commit/cc9ed87fa9391dd39ac05cbf99b4437112fa3d10
284-
//
285-
// Example:
286-
//
287-
// [error] API response (oidc.us-east-1.amazonaws.com /token): {
288-
// name: 'InvalidGrantException',
289-
// '$fault': 'client',
290-
// '$metadata': {
291-
// httpStatusCode: 400,
292-
// requestId: '7f5af448-5af7-45f2-8e47-5808deaea4ab',
293-
// extendedRequestId: undefined,
294-
// cfId: undefined
295-
// },
296-
// error: 'invalid_grant',
297-
// error_description: 'Invalid refresh token provided',
298-
// message: 'UnknownError'
299-
// }
300-
const msg = e?.error_description?.trim() ? e?.error_description?.trim() : e?.message?.trim()
301-
302-
if (typeof msg === 'string') {
303-
// Truncate to 200 chars.
304-
return msg !== '' ? msg.substring(0, 200) : undefined
305-
}
306-
307-
return undefined
313+
// Truncate to 200 chars.
314+
return msg && msg.length > 0 ? msg.substring(0, 200) : undefined
308315
}
309316

310317
export function getTelemetryReason(error: unknown | undefined): string | undefined {
@@ -336,7 +343,8 @@ export function getTelemetryReason(error: unknown | undefined): string | undefin
336343
export function resolveErrorMessageToDisplay(error: unknown, defaultMessage: string): string {
337344
const mainMessage = error instanceof ToolkitError ? error.message : defaultMessage
338345
// We want to explicitly show certain AWS Error messages if they are raised
339-
const prioritizedMessage = findPrioritizedAwsError(error)?.message
346+
const awsError = findPrioritizedAwsError(error)
347+
const prioritizedMessage = getErrorMsg(awsError)
340348
return prioritizedMessage ? `${mainMessage}: ${prioritizedMessage}` : mainMessage
341349
}
342350

@@ -352,19 +360,10 @@ export const prioritizedAwsErrors: RegExp[] = [
352360
]
353361

354362
/**
355-
* Sometimes there are AWS specific errors that we want to explicitly
356-
* show to the user, these are 'prioritized' errors.
363+
* Tries to find the most useful/relevant error to surface to the user.
357364
*
358-
* In certain cases we may unknowingly wrap these errors in a Toolkit error
359-
* as the 'cause', in return masking the the underlying error from being
365+
* Errors may be wrapped anywhere in the codepath, which may prevent the root cause from being
360366
* reported to the user.
361-
*
362-
* Since we do not want developers to worry if they are allowed to wrap
363-
* a specific AWS error in a Toolkit error, we will instead handle
364-
* it in this function by extracting the 'prioritized' error if it is
365-
* found.
366-
*
367-
* @returns new ToolkitError with prioritized error message, otherwise original error
368367
*/
369368
export function findPrioritizedAwsError(
370369
error: unknown,
@@ -380,30 +379,25 @@ export function findPrioritizedAwsError(
380379
}
381380

382381
/**
383-
* This will search through the causal chain of errors (if it exists)
384-
* until it finds an {@link AWSError}.
382+
* Searches through the chain of errors (if any) until it finds an {@link AWSError}.
385383
*
386384
* {@link ToolkitError} instances can wrap a 'cause', which is the underlying
387385
* error that caused it.
388386
*
389-
* @returns AWSError if found, otherwise undefined
387+
* @returns AWSError, or undefined
390388
*/
391389
export function findAwsErrorInCausalChain(error: unknown): AWSError | undefined {
392390
let currentError = error
393391

394-
while (currentError !== undefined) {
392+
for (let i = 0; currentError && i < 100; i++) {
395393
if (isAwsError(currentError)) {
396394
return currentError
397395
}
398396

399-
// TODO: Base Error has 'cause' in ES2022. If we upgrade this can be made
400-
// non-ToolkitError specific
401-
if (currentError instanceof ToolkitError && currentError.cause !== undefined) {
402-
currentError = currentError.cause
403-
continue
397+
// Note: Base Error has 'cause' in ES2022. So does our own `ToolkitError`.
398+
if ((currentError as any).cause !== undefined) {
399+
currentError = (currentError as any).cause
404400
}
405-
406-
return undefined
407401
}
408402

409403
return undefined

packages/core/src/test/shared/errors.test.ts

Lines changed: 88 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -4,10 +4,20 @@
44
*/
55

66
import assert from 'assert'
7-
import { getTelemetryReason, getTelemetryResult, resolveErrorMessageToDisplay, ToolkitError } from '../../shared/errors'
7+
import vscode from 'vscode'
8+
import {
9+
findAwsErrorInCausalChain,
10+
findPrioritizedAwsError,
11+
getErrorMsg,
12+
getTelemetryReason,
13+
getTelemetryResult,
14+
resolveErrorMessageToDisplay,
15+
ToolkitError,
16+
} from '../../shared/errors'
817
import { CancellationError } from '../../shared/utilities/timeoutUtils'
918
import { UnauthorizedException } from '@aws-sdk/client-sso'
1019
import { AWSError } from 'aws-sdk'
20+
import { AccessDeniedException } from '@aws-sdk/client-sso-oidc'
1121

1222
describe('ToolkitError', function () {
1323
it('can store an error message', function () {
@@ -221,6 +231,83 @@ describe('Telemetry', function () {
221231
})
222232
})
223233

234+
describe('util', function () {
235+
// Error containing `error_description`.
236+
function fakeAwsError_accessDenied() {
237+
const e = new AccessDeniedException({
238+
error: 'access_denied',
239+
message: 'accessdenied message',
240+
$metadata: {
241+
attempts: 3,
242+
requestId: 'or62s79n-r9ps-41pq-n755-r6920p56r4so',
243+
totalRetryDelay: 3000,
244+
httpStatusCode: 403,
245+
},
246+
}) as any
247+
e.name = 'accessdenied-name'
248+
e.code = 'accessdenied-code'
249+
e.error_description = 'access_denied error_description'
250+
e.time = new Date()
251+
return e as AccessDeniedException
252+
}
253+
254+
// Error NOT containing `error_description`.
255+
function fakeAwsError_unauth() {
256+
const e = new UnauthorizedException({
257+
message: 'unauthorized message',
258+
$metadata: {
259+
attempts: 3,
260+
requestId: 'be62f79a-e9cf-41cd-a755-e6920c56e4fb',
261+
totalRetryDelay: 3000,
262+
httpStatusCode: 403,
263+
},
264+
}) as any
265+
e.name = 'unauthorized-name'
266+
e.code = 'unauthorized-code'
267+
e.time = new Date()
268+
return e as UnauthorizedException
269+
}
270+
271+
function fakeErrorChain() {
272+
try {
273+
throw new Error('generic error 1')
274+
} catch (e1) {
275+
try {
276+
const e = fakeAwsError_accessDenied()
277+
;(e as any).cause = e1
278+
throw e
279+
} catch (e2) {
280+
try {
281+
throw ToolkitError.chain(e2, 'ToolkitError message', {
282+
documentationUri: vscode.Uri.parse(
283+
'https://docs.aws.amazon.com/toolkit-for-vscode/latest/userguide/welcome.html'
284+
),
285+
})
286+
} catch (e3) {
287+
const e = fakeAwsError_unauth()
288+
;(e as any).cause = e3
289+
return e
290+
}
291+
}
292+
}
293+
}
294+
295+
it('getErrorMsg()', function () {
296+
assert.deepStrictEqual(getErrorMsg(fakeErrorChain()), 'unauthorized message')
297+
assert.deepStrictEqual(getErrorMsg(findAwsErrorInCausalChain(fakeErrorChain())), 'unauthorized message')
298+
assert.deepStrictEqual(getErrorMsg(findPrioritizedAwsError(fakeErrorChain())), 'x')
299+
})
300+
it('findAwsErrorInCausalChain()', function () {
301+
// assert.deepStrictEqual()
302+
})
303+
it('findPrioritizedAwsError()', function () {
304+
// assert.deepStrictEqual()
305+
})
306+
it('formatError()', function () {
307+
// assert.deepStrictEqual()
308+
})
309+
})
310+
224311
describe('resolveErrorMessageToDisplay()', function () {
225312
const defaultMessage = 'My Default Message!'
226313
const normalErrorMessage = 'Normal error message'

0 commit comments

Comments
 (0)