From a72cb2ffa80b0c6a4c6c1ab5d69d330aabf19559 Mon Sep 17 00:00:00 2001 From: Graham Campbell Date: Thu, 11 Jun 2026 22:27:33 +0100 Subject: [PATCH] Improve AWS error reporting and document proxy support --- README.md | 6 ++- src/state/S3StateStorage.js | 3 +- src/state/utils/get-state-bucket-name.js | 3 +- src/state/utils/get-state-bucket-region.js | 4 +- src/utils/aws/get-aws-error-code.js | 3 ++ src/utils/aws/index.js | 2 + src/utils/tokenize-exception.js | 19 ++++++++- .../src/utils/aws/get-aws-error-code.test.js | 33 ++++++++++++++++ .../unit/src/utils/tokenize-exception.test.js | 39 ++++++++++++++++++- 9 files changed, 100 insertions(+), 12 deletions(-) create mode 100644 src/utils/aws/get-aws-error-code.js create mode 100644 test/unit/src/utils/aws/get-aws-error-code.test.js diff --git a/README.md b/README.md index 16023f9..dd0ac3c 100644 --- a/README.md +++ b/README.md @@ -11,6 +11,8 @@ osls-compose --help Available commands: `osls-compose`, `oslsc`. -Check out the [osls compose documentation](https://github.com/oss-serverless/osls/blob/main/docs/guides/compose.md). +Check out the [osls compose documentation](https://github.com/oss-serverless/osls/blob/4.x/docs/guides/compose.md). -

+Compose honors the same proxy, custom certificate authority, and timeout environment variables as osls (`HTTP_PROXY`/`HTTPS_PROXY`, `ca`/`cafile`, and `AWS_CLIENT_TIMEOUT`) for its own AWS requests, such as remote state access. See [Running behind a proxy](https://github.com/oss-serverless/osls/blob/4.x/docs/guides/credentials.md#running-behind-a-proxy). + +

diff --git a/src/state/S3StateStorage.js b/src/state/S3StateStorage.js index 468b304..fe069bf 100644 --- a/src/state/S3StateStorage.js +++ b/src/state/S3StateStorage.js @@ -7,8 +7,7 @@ const ServerlessError = require('../serverless-error'); const BaseStateStorage = require('./BaseStateStorage'); const normalizeState = require('./normalize-state'); const { buildClientConfig } = require('../utils/aws/config'); - -const getAwsErrorCode = (error) => error && (error.Code || error.code || error.name); +const { getAwsErrorCode } = require('../utils/aws'); class S3StateStorage extends BaseStateStorage { constructor(config = {}) { diff --git a/src/state/utils/get-state-bucket-name.js b/src/state/utils/get-state-bucket-name.js index ceda28c..18958d5 100644 --- a/src/state/utils/get-state-bucket-name.js +++ b/src/state/utils/get-state-bucket-name.js @@ -2,14 +2,13 @@ const crypto = require('crypto'); const { CloudFormation } = require('@aws-sdk/client-cloudformation'); -const { getAwsClientConfig } = require('../../utils/aws'); +const { getAwsClientConfig, getAwsErrorCode } = require('../../utils/aws'); const { sleep } = require('../../utils'); const getConfiguredStateBucketName = require('./get-configured-state-bucket-name'); const remoteStateCloudFormationTemplate = require('./remote-state-cloudformation-template.json'); const ServerlessError = require('../../serverless-error'); const COMPOSE_REMOTE_STATE_STACK_NAME = 'serverless-compose-state'; -const getAwsErrorCode = (error) => error && (error.Code || error.code || error.name); const getCloudFormationClient = (stateConfiguration = {}, context = {}) => { // We are enforcing us-east-1 as the intention (that might change in the future if we find a good reason) diff --git a/src/state/utils/get-state-bucket-region.js b/src/state/utils/get-state-bucket-region.js index c12c343..f142d34 100644 --- a/src/state/utils/get-state-bucket-region.js +++ b/src/state/utils/get-state-bucket-region.js @@ -1,11 +1,9 @@ 'use strict'; const { S3 } = require('@aws-sdk/client-s3'); -const { getAwsClientConfig } = require('../../utils/aws'); +const { getAwsClientConfig, getAwsErrorCode } = require('../../utils/aws'); const ServerlessError = require('../../serverless-error'); -const getAwsErrorCode = (error) => error && (error.Code || error.code || error.name); - const getStateBucketRegion = async (bucketName, stateConfiguration = {}, context = {}) => { const client = new S3( getAwsClientConfig({ diff --git a/src/utils/aws/get-aws-error-code.js b/src/utils/aws/get-aws-error-code.js new file mode 100644 index 0000000..d491766 --- /dev/null +++ b/src/utils/aws/get-aws-error-code.js @@ -0,0 +1,3 @@ +'use strict'; + +module.exports = (error) => error && (error.Code || error.code || error.name); diff --git a/src/utils/aws/index.js b/src/utils/aws/index.js index 3c43a8b..373f6b5 100644 --- a/src/utils/aws/index.js +++ b/src/utils/aws/index.js @@ -1,9 +1,11 @@ 'use strict'; const getAwsClientConfig = require('./get-client-config'); +const getAwsErrorCode = require('./get-aws-error-code'); const getCredentialProvider = require('./get-credential-provider'); module.exports = { getAwsClientConfig, + getAwsErrorCode, getCredentialProvider, }; diff --git a/src/utils/tokenize-exception.js b/src/utils/tokenize-exception.js index 7b1266d..9545518 100644 --- a/src/utils/tokenize-exception.js +++ b/src/utils/tokenize-exception.js @@ -2,18 +2,33 @@ const { inspect } = require('util'); const isError = require('type/error/is'); +const { hasOwn } = require('./safe-object'); const userErrorNames = new Set(['ServerlessError']); +// AWS SDK v3 service errors carry response metadata in `$metadata` +const isAwsSdkV3ServiceError = (exception) => + Boolean(hasOwn(exception, '$metadata') && exception.$metadata && exception.name); + +const toConstantCase = (name) => + name + .replace(/([A-Z]+)([A-Z][a-z])/g, '$1_$2') + .replace(/([a-z0-9])([A-Z])/g, '$1_$2') + .toUpperCase(); + module.exports = (exception) => { if (isError(exception)) { + const isAwsServiceError = isAwsSdkV3ServiceError(exception); return { title: exception.name.replace(/([A-Z])/g, ' $1').trim(), name: exception.name, stack: exception.stack, message: exception.message, - isUserError: userErrorNames.has(exception.name), - code: exception.code, + // AWS service errors (access denied, throttling exhaustion, ...) reflect the user's + // account or environment, not framework bugs - render them without a stack trace + isUserError: userErrorNames.has(exception.name) || isAwsServiceError, + code: + exception.code ?? (isAwsServiceError ? `AWS_${toConstantCase(exception.name)}` : undefined), }; } return { diff --git a/test/unit/src/utils/aws/get-aws-error-code.test.js b/test/unit/src/utils/aws/get-aws-error-code.test.js new file mode 100644 index 0000000..a322ef5 --- /dev/null +++ b/test/unit/src/utils/aws/get-aws-error-code.test.js @@ -0,0 +1,33 @@ +'use strict'; + +const chai = require('chai'); + +const getAwsErrorCode = require('../../../../../src/utils/aws/get-aws-error-code'); + +const expect = chai.expect; + +describe('test/unit/src/utils/aws/get-aws-error-code.test.js', () => { + it('prefers the capitalized Code property', () => { + const error = Object.assign(new Error('failed'), { + Code: 'ValidationError', + code: 'lower', + name: 'SomeName', + }); + expect(getAwsErrorCode(error)).to.equal('ValidationError'); + }); + + it('falls back to the lowercase code property', () => { + const error = Object.assign(new Error('failed'), { code: 'NoSuchKey' }); + expect(getAwsErrorCode(error)).to.equal('NoSuchKey'); + }); + + it('falls back to the error name', () => { + const error = Object.assign(new Error('failed'), { name: 'AccessDenied' }); + expect(getAwsErrorCode(error)).to.equal('AccessDenied'); + }); + + it('returns a falsy value for missing errors', () => { + expect(getAwsErrorCode(null)).to.equal(null); + expect(getAwsErrorCode(undefined)).to.equal(undefined); + }); +}); diff --git a/test/unit/src/utils/tokenize-exception.test.js b/test/unit/src/utils/tokenize-exception.test.js index 93d0816..af8685e 100644 --- a/test/unit/src/utils/tokenize-exception.test.js +++ b/test/unit/src/utils/tokenize-exception.test.js @@ -5,7 +5,7 @@ const expect = require('chai').expect; const ServerlessError = require('../../../../src/serverless-error'); const tokenizeError = require('../../../../src/utils/tokenize-exception'); -describe('test/unit/lib/utils/tokenize-exception.test.js', () => { +describe('test/unit/src/utils/tokenize-exception.test.js', () => { it('Should tokenize user error', () => { const errorTokens = tokenizeError(new ServerlessError('Some error', 'ERR_CODE')); expect(errorTokens.title).to.equal('Serverless Error'); @@ -25,6 +25,43 @@ describe('test/unit/lib/utils/tokenize-exception.test.js', () => { expect(errorTokens.isUserError).to.equal(false); }); + it('Should tokenize AWS SDK service error as user error', () => { + const exception = Object.assign( + new Error('User is not authorized to perform: cloudformation:CreateStack'), + { name: 'AccessDenied', $metadata: { httpStatusCode: 403 } } + ); + const errorTokens = tokenizeError(exception); + expect(errorTokens.title).to.equal('Access Denied'); + expect(errorTokens.isUserError).to.equal(true); + expect(errorTokens.code).to.equal('AWS_ACCESS_DENIED'); + }); + + it('Should normalize AWS SDK service error names into codes', () => { + const exception = Object.assign(new Error('Rate exceeded'), { + name: 'ThrottlingException', + $metadata: { httpStatusCode: 429 }, + }); + expect(tokenizeError(exception).code).to.equal('AWS_THROTTLING_EXCEPTION'); + }); + + it('Should preserve explicit codes on AWS SDK service errors', () => { + const exception = Object.assign(new Error('Some error'), { + name: 'ThrottlingException', + code: 'CUSTOM_CODE', + $metadata: { httpStatusCode: 429 }, + }); + const errorTokens = tokenizeError(exception); + expect(errorTokens.isUserError).to.equal(true); + expect(errorTokens.code).to.equal('CUSTOM_CODE'); + }); + + it('Should not classify errors without $metadata as AWS service errors', () => { + const exception = Object.assign(new Error('Some error'), { name: 'AccessDenied' }); + const errorTokens = tokenizeError(exception); + expect(errorTokens.isUserError).to.equal(false); + expect(errorTokens.code).to.equal(undefined); + }); + it('Should tokenize non-error exception', () => { const errorTokens = tokenizeError(null); expect(errorTokens.title).to.equal('Exception');