Skip to content

Commit 93e5a09

Browse files
refactor(auth): redo DiskCacheError with proper context (#5558)
## Problem: The original implementation of DiskCacheError can be simplified and still achieve it's original purpose. ## Solution: During token refresh we eventually write to the disk cache. Sometimes this process fails due to filesystem errors. We dont need to care about the actual file system error, but only that the error came during a DiskCache operation (i.e when it attempts to save the token to disk) So instead, at the root of where the disk cache errors ocurr, we will wrap caught errors in a DiskCacheError. This adds context to the error and allows anything upstream to specifically check for that context, instead of needing to parse the error to see if it was filesystem related. Additionally not all file system errors were guaranteed to come from the disk cache, so this change allows us to target the right errors. Signed-off-by: Nikolas Komonen <[email protected]> --- <!--- REMINDER: Ensure that your PR meets the guidelines in CONTRIBUTING.md --> License: I confirm that my contribution is made under the terms of the Apache 2.0 license. Signed-off-by: Nikolas Komonen <[email protected]>
1 parent 59f5e03 commit 93e5a09

File tree

5 files changed

+57
-167
lines changed

5 files changed

+57
-167
lines changed

packages/core/src/auth/auth.ts

Lines changed: 5 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -13,7 +13,7 @@ import * as localizedText from '../shared/localizedText'
1313
import { Credentials } from '@aws-sdk/types'
1414
import { SsoAccessTokenProvider } from './sso/ssoAccessTokenProvider'
1515
import { Timeout } from '../shared/utilities/timeoutUtils'
16-
import { DiskCacheError, errorCode, isAwsError, isNetworkError, ToolkitError, UnknownError } from '../shared/errors'
16+
import { errorCode, isAwsError, isNetworkError, ToolkitError, UnknownError } from '../shared/errors'
1717
import { getCache } from './sso/cache'
1818
import { isNonNullable, Mutable } from '../shared/utilities/tsUtils'
1919
import { builderIdStartUrl, SsoToken, truncateStartUrl } from './sso/model'
@@ -64,6 +64,7 @@ import { telemetry } from '../shared/telemetry/telemetry'
6464
import { randomUUID } from '../shared/crypto'
6565
import { asStringifiedStack } from '../shared/telemetry/spans'
6666
import { withTelemetryContext } from '../shared/telemetry/util'
67+
import { DiskCacheError } from '../shared/utilities/cacheUtils'
6768

6869
interface AuthService {
6970
/**
@@ -854,11 +855,10 @@ export class Auth implements AuthService, ConnectionManager {
854855
})
855856
}
856857

857-
const possibleCacheError = DiskCacheError.instanceIf(e)
858-
if (possibleCacheError instanceof DiskCacheError) {
858+
if (e instanceof DiskCacheError) {
859859
throw new ToolkitError('Failed to update connection due to file system operation failures', {
860-
cause: possibleCacheError,
861-
code: possibleCacheError.code,
860+
cause: e,
861+
code: e.code,
862862
})
863863
}
864864
}

packages/core/src/auth/sso/ssoAccessTokenProvider.ts

Lines changed: 14 additions & 18 deletions
Original file line numberDiff line numberDiff line change
@@ -18,9 +18,8 @@ import {
1818
import { getCache } from './cache'
1919
import { hasProps, hasStringProps, RequiredProps, selectFrom } from '../../shared/utilities/tsUtils'
2020
import { OidcClient } from './clients'
21-
import { loadOr } from '../../shared/utilities/cacheUtils'
21+
import { DiskCacheError, loadOr } from '../../shared/utilities/cacheUtils'
2222
import {
23-
DiskCacheError,
2423
ToolkitError,
2524
getErrorMsg,
2625
getRequestId,
@@ -192,26 +191,21 @@ export abstract class SsoAccessTokenProvider {
192191

193192
return refreshed
194193
} catch (err) {
195-
const possibleCacheError = DiskCacheError.instanceIf(err)
196-
if (possibleCacheError instanceof DiskCacheError) {
194+
if (err instanceof DiskCacheError) {
197195
/**
198196
* Background:
199-
* - During token refresh when saving to our filesystem/cache there are sometimes file system
200-
* related errors.
197+
* - During token refresh the cache sometimes fails due to a file system error.
201198
* - When these errors ocurr it will cause the token refresh process to fail, and the users SSO
202199
* connection to become invalid.
203-
* - Because these filesystem errors do not indicate the SSO session is actually stale,
204-
* we want to catch these filesystem errors and not invalidate the users SSO connection since a
200+
* - Because these cache errors do not indicate the SSO session is actually stale,
201+
* we want to catch these errors and not invalidate the users SSO connection since a
205202
* subsequent attempt to refresh may succeed.
206203
* - To give the user a chance to resolve their filesystem related issue, we want to point them
207204
* to the logs where the error was logged. Hopefully they can use this information to fix the issue,
208205
* or at least hint for them to provide the logs in a bug report.
209206
*/
210-
void DiskCacheErrorMessage.instance.showMessageThrottled(possibleCacheError)
211-
throw possibleCacheError
212-
}
213-
214-
if (!isNetworkError(err)) {
207+
void DiskCacheErrorMessage.instance.showMessageThrottled(err)
208+
} else if (!isNetworkError(err)) {
215209
const reason = getTelemetryReason(err)
216210
telemetry.aws_refreshCredentials.emit({
217211
result: getTelemetryResult(err),
@@ -786,13 +780,13 @@ type ReAuthStateValue = {
786780
}
787781

788782
/**
789-
* Singleton class that manages showing the user a message during
790-
* failures that ocurr during SSO disk cache operations.
783+
* Singleton class that manages showing the user a message during {@link DiskCacheError} errors.
791784
*
792785
* Background:
793786
* - We need this {@link DiskCacheErrorMessage} specifically as a singleton since we want to ensure
794-
* that only 1 instance of this message appears at a time. There are cases where it shows multiple messages
795-
* if not used as a singleton.
787+
* that only 1 instance of this message appears at a time. The current implementation creates a new
788+
* {@link SsoAccessTokenProvider} instance each time a token is requested, and this can happen multiple
789+
* times in rapid succession.
796790
*/
797791
class DiskCacheErrorMessage {
798792
static #instance: DiskCacheErrorMessage
@@ -803,6 +797,8 @@ class DiskCacheErrorMessage {
803797
/**
804798
* Show a `"don't show again"`-able message which tells the user about a file system related error
805799
* with the sso cache.
800+
*
801+
* This message is throttled so we do not spam the user every time something requests a token.
806802
*/
807803
public showMessageThrottled(error: Error) {
808804
return this._showMessageThrottled(error)
@@ -827,7 +823,7 @@ class DiskCacheErrorMessage {
827823

828824
function showMessage() {
829825
return showViewLogsMessage(
830-
`File system related error during SSO cache operation:\n"${getErrorMsg(error, true)}"`,
826+
`Features using SSO will not work due to:\n"${getErrorMsg(error, true)}"`,
831827
'error',
832828
[dontShow]
833829
)

packages/core/src/shared/errors.ts

Lines changed: 0 additions & 59 deletions
Original file line numberDiff line numberDiff line change
@@ -1003,65 +1003,6 @@ export class AwsClientResponseError extends Error {
10031003
}
10041004
}
10051005

1006-
/**
1007-
* Represents a generalized error that happened during a disk cache operation.
1008-
*
1009-
* For example, when SSO refreshes a token a disk cache error can occur when it
1010-
* attempts to read/write the disk cache. These errors can be recoverable and do not
1011-
* imply that the SSO session is stale. So by wrapping those errors in an instance of
1012-
* this class it will help to distinguish them.
1013-
*/
1014-
export class DiskCacheError extends Error {
1015-
#code: string | undefined
1016-
1017-
/** Use {@link DiskCacheError.instanceIf()} to create an instance */
1018-
protected constructor(message: string, options?: { code?: string }) {
1019-
super(message)
1020-
this.#code = options?.code
1021-
}
1022-
1023-
/**
1024-
* We are seeing these errors in telemetry, but they have no error message attached.
1025-
* The best we can do is assume these are filesystem errors in the context that we see them.
1026-
*/
1027-
private static fileSystemErrorsWithoutMessage = ['EACCES', 'EBADF']
1028-
1029-
/**
1030-
* Return an instance of {@link DiskCacheError} that consists of the properties from the
1031-
* given error IF certain conditions are met. Otherwise, the original error is returned.
1032-
*
1033-
* - Also returns the original error if it is already a {@link DiskCacheError}.
1034-
*/
1035-
public static instanceIf<T>(err: T): DiskCacheError | T {
1036-
if (!(err instanceof Error) || err instanceof DiskCacheError) {
1037-
return err
1038-
}
1039-
1040-
const errorId = (err as any).code ?? err.name
1041-
1042-
if (DiskCacheError.fileSystemErrorsWithoutMessage.includes(errorId)) {
1043-
// The error message will probably be blank
1044-
const message = err.message ? err.message : 'No msg'
1045-
return new DiskCacheError(message, { code: errorId })
1046-
}
1047-
1048-
// These are errors we were seeing in telemetry
1049-
if (
1050-
isError(err, 'ENOSPC', 'no space left on device') ||
1051-
isError(err, 'EPERM', 'operation not permitted') ||
1052-
isError(err, 'EBUSY', 'resource busy or locked')
1053-
) {
1054-
return new DiskCacheError(err.message, { code: errorId })
1055-
}
1056-
1057-
return err // the error does no meet the conditions to be a DiskCacheError
1058-
}
1059-
1060-
public get code() {
1061-
return this.#code
1062-
}
1063-
}
1064-
10651006
/**
10661007
* Run a function and swallow any errors that are not specified by `shouldThrow`
10671008
*/

packages/core/src/shared/utilities/cacheUtils.ts

Lines changed: 26 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -5,7 +5,7 @@
55

66
import * as vscode from 'vscode'
77
import { dirname } from 'path'
8-
import { ToolkitError, isFileNotFoundError } from '../errors'
8+
import { ErrorInformation, ToolkitError, isFileNotFoundError } from '../errors'
99
import fs from '../../shared/fs/fs'
1010
import { isWeb } from '../extensionGlobals'
1111
import type { MapSync } from './map'
@@ -121,10 +121,7 @@ export function createDiskCache<V, K>(
121121
return
122122
}
123123
log(`read failed ${error}`, key)
124-
throw ToolkitError.chain(error, `Failed to read from "${target}"`, {
125-
code: 'FSReadFailed',
126-
details: { key },
127-
})
124+
throw createDiskCacheError(error, 'LOAD', target, key)
128125
}
129126
},
130127
save: async (key, data) => {
@@ -142,10 +139,7 @@ export function createDiskCache<V, K>(
142139
await fs.writeFile(target, JSON.stringify(data), { mode: 0o600, atomic: true })
143140
}
144141
} catch (error) {
145-
throw ToolkitError.chain(error, `Failed to save "${target}"`, {
146-
code: 'FSWriteFailed',
147-
details: { key },
148-
})
142+
throw createDiskCacheError(error, 'SAVE', target, key)
149143
}
150144

151145
log('saved', key)
@@ -160,15 +154,34 @@ export function createDiskCache<V, K>(
160154
return log('file not found', key)
161155
}
162156

163-
throw ToolkitError.chain(error, `Failed to delete "${target}"`, {
164-
code: 'FSDeleteFailed',
165-
details: { key },
166-
})
157+
throw createDiskCacheError(error, 'CLEAR', target, key)
167158
}
168159

169160
log(`deleted (reason: ${reason})`, key)
170161
},
171162
}
163+
164+
/** Helper to make a disk cache error */
165+
function createDiskCacheError(error: unknown, operation: 'LOAD' | 'SAVE' | 'CLEAR', target: string, key: K) {
166+
return DiskCacheError.chain(error, `${operation} failed for '${target}'`, {
167+
details: { key },
168+
})
169+
}
170+
}
171+
172+
/**
173+
* Represents a generalized error that happened during a disk cache operation.
174+
*
175+
* For example, when SSO refreshes a token a disk cache error can occur when it
176+
* attempts to read/write the disk cache. These errors can be recoverable and do not
177+
* imply that the SSO session is stale. So by creating a context specific instance it
178+
* will help to distinguish them when we need to decide if the SSO session is actually
179+
* stale.
180+
*/
181+
export class DiskCacheError extends ToolkitError.named('DiskCacheError') {
182+
public constructor(message: string, info?: Omit<ErrorInformation, 'code'>) {
183+
super(message, { ...info, code: 'DiskCacheError' })
184+
}
172185
}
173186

174187
export function createSecretsCache(

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

Lines changed: 12 additions & 72 deletions
Original file line numberDiff line numberDiff line change
@@ -19,7 +19,6 @@ import {
1919
ToolkitError,
2020
tryRun,
2121
UnknownError,
22-
DiskCacheError,
2322
getErrorId,
2423
} from '../../shared/errors'
2524
import { CancellationError } from '../../shared/utilities/timeoutUtils'
@@ -28,6 +27,7 @@ import { AWSError } from 'aws-sdk'
2827
import { AccessDeniedException } from '@aws-sdk/client-sso-oidc'
2928
import { OidcClient } from '../../auth/sso/clients'
3029
import { SamCliError } from '../../shared/sam/cli/samCliInvokerUtils'
30+
import { DiskCacheError } from '../../shared/utilities/cacheUtils'
3131

3232
class TestAwsError extends Error implements AWSError {
3333
constructor(
@@ -258,7 +258,7 @@ describe('ToolkitError', function () {
258258
it('maintains the prototype chain with a constructor', function () {
259259
const OopsieError = class extends ToolkitError.named('OopsieError') {
260260
public constructor() {
261-
super('oopsies')
261+
super('oopsies', { code: 'OopsieErrorCode' })
262262
}
263263
}
264264

@@ -267,6 +267,7 @@ describe('ToolkitError', function () {
267267
assert.ok(error instanceof OopsieError)
268268
assert.strictEqual(error.cause, undefined)
269269
assert.strictEqual(error.message, 'oopsies')
270+
assert.strictEqual(error.code, 'OopsieErrorCode')
270271
})
271272

272273
it('maintains the prototype chain without a constructor', function () {
@@ -284,6 +285,15 @@ describe('ToolkitError', function () {
284285
assert.strictEqual(error.cause?.message, 'oops')
285286
})
286287
})
288+
289+
describe(`${DiskCacheError.name}`, function () {
290+
it(`subclasses ${ToolkitError.named.name}()`, function () {
291+
const dce = new DiskCacheError('foo')
292+
assert.strictEqual(dce instanceof ToolkitError, true)
293+
assert.strictEqual(dce.code, 'DiskCacheError')
294+
assert.strictEqual(dce.name, 'DiskCacheError')
295+
})
296+
})
287297
})
288298

289299
describe('Telemetry', function () {
@@ -618,76 +628,6 @@ describe('util', function () {
618628
})
619629
})
620630

621-
describe(`${DiskCacheError.name}`, function () {
622-
function assertIsDiskCacheError(err: unknown, expected: { message: string; code: string }) {
623-
const cacheError = DiskCacheError.instanceIf(err)
624-
assert(cacheError instanceof DiskCacheError)
625-
assert.deepStrictEqual(cacheError.message, expected.message)
626-
assert.deepStrictEqual(cacheError.code, expected.code)
627-
}
628-
629-
it('returns the original error on non-disk cache errors', function () {
630-
const nonDiskCacheError = new Error('I am not a disk cache error')
631-
nonDiskCacheError.name = 'NotADiskCacheError'
632-
633-
const result = DiskCacheError.instanceIf(nonDiskCacheError)
634-
635-
assert.deepStrictEqual(result instanceof DiskCacheError, false)
636-
assert.deepStrictEqual(result, nonDiskCacheError)
637-
})
638-
639-
it('errors without a message', function () {
640-
const eacces = new Error()
641-
eacces.name = 'EACCES'
642-
assertIsDiskCacheError(eacces, { message: 'No msg', code: 'EACCES' })
643-
644-
const ebadf = new Error()
645-
ebadf.name = 'EBADF'
646-
assertIsDiskCacheError(ebadf, { message: 'No msg', code: 'EBADF' })
647-
})
648-
649-
/**
650-
* The error message is part of the heuristic to determine if certain errors
651-
* can be a {@link DiskCacheError}. This asserts that if the message changes, the
652-
* error can no longer be a {@link DiskCacheError}.
653-
*/
654-
function assertExpectedMessageRequired(error: Error) {
655-
error.message = 'Not the expected message'
656-
assert.deepStrictEqual(DiskCacheError.instanceIf(error) instanceof DiskCacheError, false)
657-
assert.deepStrictEqual(DiskCacheError.instanceIf(error), error)
658-
}
659-
660-
it('ENOSPC', function () {
661-
const error = new Error('Blah blah no space left on device blah blah')
662-
error.name = 'ENOSPC'
663-
assertIsDiskCacheError(error, {
664-
message: 'Blah blah no space left on device blah blah',
665-
code: 'ENOSPC',
666-
})
667-
assertExpectedMessageRequired(error)
668-
})
669-
670-
it('EPERM', function () {
671-
const error = new Error('Blah blah operation not permitted blah blah')
672-
error.name = 'EPERM'
673-
assertIsDiskCacheError(error, {
674-
message: 'Blah blah operation not permitted blah blah',
675-
code: 'EPERM',
676-
})
677-
assertExpectedMessageRequired(error)
678-
})
679-
680-
it('EBUSY', function () {
681-
const error = new Error('Blah blah resource busy or locked blah blah')
682-
error.name = 'EBUSY'
683-
assertIsDiskCacheError(error, {
684-
message: 'Blah blah resource busy or locked blah blah',
685-
code: 'EBUSY',
686-
})
687-
assertExpectedMessageRequired(error)
688-
})
689-
})
690-
691631
it('scrubNames()', async function () {
692632
const fakeUser = 'jdoe123'
693633
assert.deepStrictEqual(scrubNames('', fakeUser), '')

0 commit comments

Comments
 (0)