diff --git a/examples/s3-test-errors.js b/examples/s3-test-errors.js new file mode 100644 index 0000000..462654c --- /dev/null +++ b/examples/s3-test-errors.js @@ -0,0 +1,16 @@ +import { AWSConfig, S3Client } from '../dist/s3.js' + +const awsConfig = new AWSConfig({ + region: __ENV.AWS_REGION, + accessKeyId: __ENV.AWS_ACCESS_KEY_ID, + secretAccessKey: __ENV.AWS_SECRET_ACCESS_KEY, + sessionToken: __ENV.AWS_SESSION_TOKEN, +}) + +export default async function () { + const s3 = new S3Client(awsConfig) + + const bucket = 'test-js-' + const objects = await s3.listObjects(bucket) + console.log(objects) +} \ No newline at end of file diff --git a/src/internal/client.ts b/src/internal/client.ts index 0e8c3f8..7b48ca5 100644 --- a/src/internal/client.ts +++ b/src/internal/client.ts @@ -1,6 +1,21 @@ +import { RefinedResponse, ResponseType } from 'k6/http' + import { AWSConfig } from './config' import { Endpoint } from './endpoint' import { HTTPHeaders } from './http' +import { + // AWSError, + GeneralErrorKind, + DNSErrorKind, + TCPErrorKind, + TLSErrorKind, + HTTP2ErrorKind, + GeneralError, + DNSError, + TCPError, + TLSError, + HTTP2Error, +} from './error' /** * Class allowing to build requests targeting AWS APIs @@ -60,6 +75,64 @@ export class AWSClient { public set endpoint(endpoint: Endpoint) { this._endpoint = endpoint } + + /** + * Handles the k6 http response potential errors produced when making a + * request to an AWS service. + * + * Importantly, this method only handles errors that emerge from the k6 http client itself, and + * won't handle AWS specific errors. To handle AWS specific errors, client classes are + * expected to implement their own error handling logic by overriding this method. + * + * @param response {RefinedResponse} the response received by the k6 http client + * @param operation {string | undefined } the name of the operation that was attempted when the error occurred + * @param {boolean} returns true if an error was handled, false otherwise + */ + // eslint-disable-next-line @typescript-eslint/no-unused-vars + protected handleError(response: RefinedResponse, operation?: string): boolean { + const status: number = response.status + const errorCode: number = response.error_code + const errorMessage: string = response.error + + // We consider codes 200-299 as success. + // + // We do not consider 3xx as success as some services such as S3 can use + // 301 to indicate a bucket not found + if (status >= 200 && status < 300 && errorMessage == '' && errorCode === 0) { + return false + } + + switch (errorCode) { + case GeneralErrorKind.GenericError: + case GeneralErrorKind.NonTCPNetworkError: + case GeneralErrorKind.InvalidURL: + case GeneralErrorKind.HTTPRequestTimeout: + throw new GeneralError(errorCode); + case DNSErrorKind.GenericDNSError: + case DNSErrorKind.NoIPFound: + case DNSErrorKind.BlacklistedIP: + case DNSErrorKind.BlacklistedHostname: + throw new DNSError(errorCode); + case TCPErrorKind.GenericTCPError: + case TCPErrorKind.BrokenPipeOnWrite: + case TCPErrorKind.UnknownTCPError: + case TCPErrorKind.GeneralTCPDialError: + case TCPErrorKind.DialTimeoutError: + case TCPErrorKind.DialConnectionRefused: + case TCPErrorKind.DialUnknownError: + case TCPErrorKind.ResetByPeer: + throw new TCPError(errorCode); + case TLSErrorKind.GeneralTLSError: + case TLSErrorKind.UnknownAuthority: + case TLSErrorKind.CertificateHostnameMismatch: + throw new TLSError(errorCode); + case HTTP2ErrorKind.GenericHTTP2Error: + case HTTP2ErrorKind.GeneralHTTP2GoAwayError: + throw new HTTP2Error(errorCode); + } + + return true + } } /** diff --git a/src/internal/error.ts b/src/internal/error.ts index 1e7760a..820f48c 100644 --- a/src/internal/error.ts +++ b/src/internal/error.ts @@ -53,3 +53,114 @@ export class AWSError extends Error { } } } + +export class NetworkError extends Error { + code: K; + name: N; + + constructor(name: N, code: K) { + super(ErrorMessages[code] || 'An unknown error occurred') + this.name = name + this.code = code + } +} + +export class GeneralError extends NetworkError<'GeneralError', GeneralErrorKind> { + constructor(code: GeneralErrorKind) { + super('GeneralError', code) + } +} + +export class DNSError extends NetworkError<'DNSError', DNSErrorKind> { + constructor(code: DNSErrorKind) { + super('DNSError', code) + } +} + +export class TCPError extends NetworkError<'TCPError', TCPErrorKind> { + constructor(code: TCPErrorKind) { + super('TCPError', code) + } +} + +export class TLSError extends NetworkError<'TLSError', TLSErrorKind> { + constructor(code: TLSErrorKind) { + super('TLSError', code) + } +} + +export class HTTP2Error extends NetworkError<'HTTP2Error', HTTP2ErrorKind> { + constructor(code: HTTP2ErrorKind) { + super('HTTP2Error', code) + } +} + + +type NetworkErrorName = 'GeneralError' | 'DNSError' | 'TCPError' | 'TLSError' | 'HTTP2Error' + +type ErrorKind = + GeneralErrorKind | + DNSErrorKind | + TCPErrorKind | + TLSErrorKind | + HTTP2ErrorKind + +export enum GeneralErrorKind { + GenericError = 1000, + NonTCPNetworkError = 1010, + InvalidURL = 1020, + HTTPRequestTimeout = 1050, +} + +export enum DNSErrorKind { + GenericDNSError = 1100, + NoIPFound = 1101, + BlacklistedIP = 1110, + BlacklistedHostname = 1111, +} + +export enum TCPErrorKind { + GenericTCPError = 1200, + BrokenPipeOnWrite = 1201, + UnknownTCPError = 1202, + GeneralTCPDialError = 1210, + DialTimeoutError = 1211, + DialConnectionRefused = 1212, + DialUnknownError = 1213, + ResetByPeer = 1220, +} + +export enum TLSErrorKind { + GeneralTLSError = 1300, + UnknownAuthority = 1310, + CertificateHostnameMismatch = 1311, +} + +export enum HTTP2ErrorKind { + GenericHTTP2Error = 1600, + GeneralHTTP2GoAwayError = 1610, +} + +const ErrorMessages: { [key in ErrorKind]: string } = { + [GeneralErrorKind.GenericError]: 'A generic error that isn’t any of the ones listed below', + [GeneralErrorKind.NonTCPNetworkError]: 'A non-TCP network error - this is a placeholder and there is no error currently known to trigger it', + [GeneralErrorKind.InvalidURL]: 'An invalid URL was specified', + [GeneralErrorKind.HTTPRequestTimeout]: 'The HTTP request has timed out', + [DNSErrorKind.GenericDNSError]: 'A generic DNS error that isn’t any of the ones listed below', + [DNSErrorKind.NoIPFound]: 'No IP for the provided host was found', + [DNSErrorKind.BlacklistedIP]: 'Blacklisted IP was resolved or a connection to such was tried to be established', + [DNSErrorKind.BlacklistedHostname]: 'Blacklisted hostname using The Block Hostnames option', + [TCPErrorKind.GenericTCPError]: 'A generic TCP error that isn’t any of the ones listed below', + [TCPErrorKind.BrokenPipeOnWrite]: 'A “broken pipe” on write - the other side has likely closed the connection', + [TCPErrorKind.UnknownTCPError]: 'An unknown TCP error - We got an error that we don’t recognize but it is from the operating system and has errno set on it. The message in error includes the operation(write,read) and the errno, the OS, and the original message of the error', + [TCPErrorKind.GeneralTCPDialError]: 'General TCP dial error', + [TCPErrorKind.DialTimeoutError]: 'Dial timeout error - the timeout for the dial was reached', + [TCPErrorKind.DialConnectionRefused]: 'Dial connection refused - the connection was refused by the other party on dial', + [TCPErrorKind.DialUnknownError]: 'Dial unknown error', + [TCPErrorKind.ResetByPeer]: 'Reset by peer - the connection was reset by the other party, most likely a server', + [TLSErrorKind.GeneralTLSError]: 'General TLS error', + [TLSErrorKind.UnknownAuthority]: 'Unknown authority - the certificate issuer is unknown', + [TLSErrorKind.CertificateHostnameMismatch]: 'The certificate doesn’t match the hostname', + [HTTP2ErrorKind.GenericHTTP2Error]: 'A generic HTTP/2 error that isn’t any of the ones listed below', + [HTTP2ErrorKind.GeneralHTTP2GoAwayError]: 'A general HTTP/2 GoAway error', +}; \ No newline at end of file diff --git a/src/internal/event-bridge.ts b/src/internal/event-bridge.ts index aa8ab88..38b0bb7 100644 --- a/src/internal/event-bridge.ts +++ b/src/internal/event-bridge.ts @@ -70,18 +70,17 @@ export class EventBridgeClient extends AWSClient { const res = await http.asyncRequest(this.method, signedRequest.url, signedRequest.body, { headers: signedRequest.headers, }) - this._handle_error(EventBridgeOperation.PutEvents, res) + this.handleError(res, EventBridgeOperation.PutEvents) } - _handle_error( - operation: EventBridgeOperation, - response: RefinedResponse - ) { - const errorCode = response.error_code - if (errorCode === 0) { - return + + protected handleError(response: RefinedResponse, operation?: string): boolean { + const errored = super.handleError(response, operation); + if (!errored) { + return false } + const errorCode = response.error_code const error = response.json() as JSONObject if (errorCode >= 1400 && errorCode <= 1499) { // In the event of certain errors, the message is not set. @@ -95,16 +94,18 @@ export class EventBridgeClient extends AWSClient { } // Otherwise throw a standard service error - throw new EventBridgeServiceError(errorMessage, error.__type as string, operation) + throw new EventBridgeServiceError(errorMessage, error.__type as string, operation as EventBridgeOperation) } if (errorCode === 1500) { throw new EventBridgeServiceError( 'An error occured on the server side', 'InternalServiceError', - operation + operation as EventBridgeOperation ) } + + return true } } diff --git a/src/internal/kinesis.ts b/src/internal/kinesis.ts index f1f30d5..6cbc18c 100644 --- a/src/internal/kinesis.ts +++ b/src/internal/kinesis.ts @@ -281,23 +281,18 @@ export class KinesisClient extends AWSClient { headers: signedRequest.headers, }) - this._handle_error(action, res) + this.handleError(res, action) return res } - /** - * If the response is an error, throw an error - * - * @param {string} operation - The name of the operation that was called. - * @param response - RefinedResponse - * @returns The response is being returned. - */ - _handle_error(operation: string, response: RefinedResponse) { - const errorCode = response.error_code - if (errorCode === 0) { - return + + protected handleError(response: RefinedResponse, operation?: string): boolean { + const errored = super.handleError(response, operation); + if (!errored) { + return false } + const errorCode = response.error_code const error = response.json() as JSONObject if (errorCode >= 1400 && errorCode <= 1499) { // In the event of certain errors, the message is not set. @@ -311,16 +306,18 @@ export class KinesisClient extends AWSClient { } // Otherwise throw a standard service error - throw new KinesisServiceError(errorMessage, error.__type as string, operation) + throw new KinesisServiceError(errorMessage, error.__type as string, operation || 'Unknown') } if (errorCode === 1500) { throw new KinesisServiceError( 'An error occured on the server side', 'InternalServiceError', - operation + operation || 'Unknown' ) } + + return true } } diff --git a/src/internal/kms.ts b/src/internal/kms.ts index 175b6ae..866bf1c 100644 --- a/src/internal/kms.ts +++ b/src/internal/kms.ts @@ -69,7 +69,7 @@ export class KMSClient extends AWSClient { const res = await http.asyncRequest(this.method, signedRequest.url, signedRequest.body, { headers: signedRequest.headers, }) - this._handle_error(KMSOperation.ListKeys, res) + this.handleError(res, KMSOperation.ListKeys) const json: JSONArray = res.json('Keys') as JSONArray return json.map((k) => KMSKey.fromJSON(k as JSONObject)) @@ -114,17 +114,18 @@ export class KMSClient extends AWSClient { const res = await http.asyncRequest(this.method, signedRequest.url, signedRequest.body, { headers: signedRequest.headers, }) - this._handle_error(KMSOperation.GenerateDataKey, res) + this.handleError(res, KMSOperation.GenerateDataKey) return KMSDataKey.fromJSON(res.json() as JSONObject) } - _handle_error(operation: KMSOperation, response: RefinedResponse) { - const errorCode = response.error_code - if (errorCode === 0) { - return + protected handleError(response: RefinedResponse, operation?: string): boolean { + const errored = super.handleError(response, operation); + if (!errored) { + return false } + const errorCode = response.error_code const error = response.json() as JSONObject if (errorCode >= 1400 && errorCode <= 1499) { // In the event of certain errors, the message is not set. @@ -138,16 +139,18 @@ export class KMSClient extends AWSClient { } // Otherwise throw a standard service error - throw new KMSServiceError(errorMessage, error.__type as string, operation) + throw new KMSServiceError(errorMessage, error.__type as string, operation as KMSOperation) } if (errorCode === 1500) { throw new KMSServiceError( 'An error occured on the server side', 'InternalServiceError', - operation + operation as KMSOperation ) } + + return true } } diff --git a/src/internal/lambda.ts b/src/internal/lambda.ts index be44356..4a0639e 100644 --- a/src/internal/lambda.ts +++ b/src/internal/lambda.ts @@ -81,7 +81,7 @@ export class LambdaClient extends AWSClient { const res = await http.asyncRequest(this.method, signedRequest.url, signedRequest.body, { headers: signedRequest.headers, }) - this._handle_error(res) + this.handleError(res) const logResult = res.headers['X-Amz-Log-Result'] const response = { @@ -99,12 +99,11 @@ export class LambdaClient extends AWSClient { } } - private _handle_error(response: RefinedResponse) { - const errorCode: number = response.error_code - const errorMessage: string = response.error - if (errorMessage == '' && errorCode === 0) { - return + protected handleError(response: RefinedResponse, operation?: string): boolean { + const errored = super.handleError(response, operation); + if (!errored) { + return false; } const awsError = AWSError.parse(response) @@ -115,6 +114,8 @@ export class LambdaClient extends AWSClient { default: throw awsError } + + return true } } diff --git a/src/internal/s3.ts b/src/internal/s3.ts index f9debdf..8a32448 100644 --- a/src/internal/s3.ts +++ b/src/internal/s3.ts @@ -63,7 +63,7 @@ export class S3Client extends AWSClient { const res = await http.asyncRequest(method, signedRequest.url, signedRequest.body || null, { headers: signedRequest.headers, }) - this._handle_error('ListBuckets', res) + this.handleError(res, 'ListBuckets') const buckets: Array = [] @@ -122,7 +122,7 @@ export class S3Client extends AWSClient { const res = await http.asyncRequest(method, signedRequest.url, signedRequest.body || null, { headers: signedRequest.headers, }) - this._handle_error('ListObjectsV2', res) + this.handleError(res, 'ListObjectsV2') const objects: Array = [] @@ -185,7 +185,7 @@ export class S3Client extends AWSClient { const res = await http.asyncRequest(method, signedRequest.url, null, { headers: signedRequest.headers, }) - this._handle_error('GetObject', res) + this.handleError(res, 'GetObject') return new S3Object( objectKey, @@ -243,7 +243,7 @@ export class S3Client extends AWSClient { const res = await http.asyncRequest(method, signedRequest.url, signedRequest.body, { headers: signedRequest.headers, }) - this._handle_error('PutObject', res) + this.handleError(res, 'PutObject') } /** @@ -272,7 +272,7 @@ export class S3Client extends AWSClient { const res = await http.asyncRequest(method, signedRequest.url, signedRequest.body || null, { headers: signedRequest.headers, }) - this._handle_error('DeleteObject', res) + this.handleError(res, 'DeleteObject') } /** @@ -312,7 +312,7 @@ export class S3Client extends AWSClient { headers: signedRequest.headers, }) - this._handle_error('CopyObject', res) + this.handleError(res, 'CopyObject') } /** @@ -345,7 +345,7 @@ export class S3Client extends AWSClient { const res = await http.asyncRequest(method, signedRequest.url, signedRequest.body || null, { headers: signedRequest.headers, }) - this._handle_error('CreateMultipartUpload', res) + this.handleError(res, 'CreateMultipartUpload') return new S3MultipartUpload( objectKey, @@ -395,7 +395,7 @@ export class S3Client extends AWSClient { const res = await http.asyncRequest(method, signedRequest.url, signedRequest.body || null, { headers: signedRequest.headers, }) - this._handle_error('UploadPart', res) + this.handleError(res, 'UploadPart') return new S3Part(partNumber, res.headers['Etag']) } @@ -445,8 +445,7 @@ export class S3Client extends AWSClient { const res = await http.asyncRequest(method, signedRequest.url, signedRequest.body || null, { headers: signedRequest.headers, }) - - this._handle_error('CompleteMultipartUpload', res) + this.handleError(res, 'CompleteMultipartUpload') } /** @@ -480,18 +479,15 @@ export class S3Client extends AWSClient { const res = await http.asyncRequest(method, signedRequest.url, signedRequest.body || null, { headers: signedRequest.headers, }) - this._handle_error('AbortMultipartUpload', res) + this.handleError(res, 'AbortMultipartUpload') } - _handle_error(operation: S3Operation, response: RefinedResponse) { - const status: number = response.status - const errorCode: number = response.error_code - const errorMessage: string = response.error - - // We consider codes 200-299 as success - if (status >= 200 && status < 300 && errorMessage == '' && errorCode === 0) { - return - } + handleError(response: RefinedResponse, operation?: string): boolean { + // As we are overriding the AWSClient method: call the parent class handleError method + const errored = super.handleError(response); + if (!errored) { + return false; + } // A 301 response is returned when the bucket is not found. // Generally meaning that either the bucket name is wrong or the @@ -499,8 +495,9 @@ export class S3Client extends AWSClient { // // See: https://github.com/grafana/k6/issues/2474 // See: https://github.com/golang/go/issues/49281 - if (status == 301 || (errorMessage && errorMessage.startsWith('301'))) { - throw new S3ServiceError('Resource not found', 'ResourceNotFound', operation) + const errorMessage: string = response.error + if (response.status == 301 || (errorMessage && errorMessage.startsWith('301'))) { + throw new S3ServiceError('Resource not found', 'ResourceNotFound', operation as S3Operation) } const awsError = AWSError.parseXML(response.body as string) @@ -508,7 +505,7 @@ export class S3Client extends AWSClient { case 'AuthorizationHeaderMalformed': throw new InvalidSignatureError(awsError.message, awsError.code) default: - throw new S3ServiceError(awsError.message, awsError.code || 'unknown', operation) + throw new S3ServiceError(awsError.message, awsError.code || 'unknown', operation as S3Operation) } } } diff --git a/src/internal/secrets-manager.ts b/src/internal/secrets-manager.ts index b84a24d..14abeb6 100644 --- a/src/internal/secrets-manager.ts +++ b/src/internal/secrets-manager.ts @@ -70,7 +70,7 @@ export class SecretsManagerClient extends AWSClient { const res = await http.asyncRequest(this.method, signedRequest.url, signedRequest.body, { headers: signedRequest.headers, }) - this._handle_error(SecretsManagerOperation.ListSecrets, res) + this.handleError(res, SecretsManagerOperation.ListSecrets) const json: JSONArray = res.json('SecretList') as JSONArray return json.map((s) => Secret.fromJSON(s as JSONObject)) @@ -103,7 +103,7 @@ export class SecretsManagerClient extends AWSClient { headers: signedRequest.headers, }) - this._handle_error(SecretsManagerOperation.GetSecretValue, res) + this.handleError(res, SecretsManagerOperation.GetSecretValue) return Secret.fromJSON(res.json() as JSONObject) } @@ -162,7 +162,7 @@ export class SecretsManagerClient extends AWSClient { const res = await http.asyncRequest(this.method, signedRequest.url, signedRequest.body, { headers: signedRequest.headers, }) - this._handle_error(SecretsManagerOperation.CreateSecret, res) + this.handleError(res, SecretsManagerOperation.CreateSecret) return Secret.fromJSON(res.json() as JSONObject) } @@ -202,7 +202,7 @@ export class SecretsManagerClient extends AWSClient { const res = await http.asyncRequest(this.method, signedRequest.url, signedRequest.body, { headers: signedRequest.headers, }) - this._handle_error(SecretsManagerOperation.PutSecretValue, res) + this.handleError(res, SecretsManagerOperation.PutSecretValue) return Secret.fromJSON(res.json() as JSONObject) } @@ -251,18 +251,17 @@ export class SecretsManagerClient extends AWSClient { const res = await http.asyncRequest(this.method, signedRequest.url, signedRequest.body, { headers: signedRequest.headers, }) - this._handle_error(SecretsManagerOperation.DeleteSecret, res) + this.handleError(res, SecretsManagerOperation.DeleteSecret) } - _handle_error( - operation: SecretsManagerOperation, - response: RefinedResponse - ) { - const errorCode = response.error_code - if (errorCode === 0) { - return + + protected handleError(response: RefinedResponse, operation?: string): boolean { + const errored = super.handleError(response, operation) + if (!errored) { + return false } + const errorCode = response.error_code const error = response.json() as JSONObject if (errorCode >= 1400 && errorCode <= 1499) { // In the event of certain errors, the message is not set. @@ -276,16 +275,18 @@ export class SecretsManagerClient extends AWSClient { } // Otherwise throw a standard service error - throw new SecretsManagerServiceError(errorMessage, error.__type as string, operation) + throw new SecretsManagerServiceError(errorMessage, error.__type as string, operation as SecretsManagerOperation) } if (errorCode === 1500) { throw new SecretsManagerServiceError( 'An error occured on the server side', 'InternalServiceError', - operation + operation as SecretsManagerOperation ) } + + return true } } diff --git a/src/internal/signature.ts b/src/internal/signature.ts index 79407d3..847a722 100644 --- a/src/internal/signature.ts +++ b/src/internal/signature.ts @@ -231,7 +231,6 @@ export class SignatureV4 { if (this.credentials.sessionToken) { request.query[constants.AMZ_TOKEN_QUERY_PARAM] = this.credentials.sessionToken } - // Add base signing query parameters to the request, as described in the documentation // @see https://docs.aws.amazon.com/general/latest/gr/sigv4-add-signature-to-request.html request.query[constants.AMZ_ALGORITHM_QUERY_PARAM] = constants.SIGNING_ALGORITHM_IDENTIFIER @@ -270,7 +269,7 @@ export class SignatureV4 { ) // If a request path was provided, add it to the URL - let url = request.endpoint.href + let url = originalRequest.endpoint.href if (request.path) { // Ensure there is a trailing slash at the end of the URL // so that appending the path does not result in a malformed URL. diff --git a/src/internal/sqs.ts b/src/internal/sqs.ts index b65aef8..f75e9b0 100644 --- a/src/internal/sqs.ts +++ b/src/internal/sqs.ts @@ -235,6 +235,8 @@ export class SQSClient extends AWSClient { default: throw new SQSServiceError(errorMessage, error.__type as string, operation) } + + return true } } diff --git a/src/internal/ssm.ts b/src/internal/ssm.ts index b9e8aeb..d866d21 100644 --- a/src/internal/ssm.ts +++ b/src/internal/ssm.ts @@ -73,20 +73,18 @@ export class SystemsManagerClient extends AWSClient { const res = await http.asyncRequest(this.method, signedRequest.url, signedRequest.body, { headers: signedRequest.headers, }) - this._handle_error(SystemsManagerOperation.GetParameter, res) + this.handleError(res, SystemsManagerOperation.GetParameter) return SystemsManagerParameter.fromJSON(res.json() as JSONObject) } - _handle_error( - operation: SystemsManagerOperation, - response: RefinedResponse - ) { - const errorCode = response.error_code - if (errorCode === 0) { - return + protected handleError(response: RefinedResponse, operation?: string): boolean { + const errored = super.handleError(response, operation); + if (!errored) { + return false } + const errorCode = response.error_code const error = response.json() as JSONObject if (errorCode >= 1400 && errorCode <= 1499) { // In the event of certain errors, the message is not set. @@ -100,16 +98,18 @@ export class SystemsManagerClient extends AWSClient { } // Otherwise throw a standard service error - throw new SystemsManagerServiceError(errorMessage, error.__type as string, operation) + throw new SystemsManagerServiceError(errorMessage, error.__type as string, operation as SystemsManagerOperation) } if (errorCode === 1500) { throw new SystemsManagerServiceError( 'An error occured on the server side', 'InternalServiceError', - operation + operation as SystemsManagerOperation ) } + + return true } }