import _ from 'lodash' import memoize from 'memoizee' import PromiseQueue from 'promise-queue' import sdk from './sdk-v2.js' import ServerlessError from '../serverless-error.js' import utils from '@serverlessinc/sf-core/src/utils.js' import HttpsProxyAgent from 'https-proxy-agent' import url from 'url' import https from 'https' import fs from 'fs' import deepSortObjectByKey from '../utils/deep-sort-object-by-key.js' import ensureString from 'type/string/ensure.js' import isObject from 'type/object/is.js' import wait from 'timers-ext/promise/sleep.js' const { log } = utils // Activate AWS SDK logging const awsLog = log.get('sls:aws:request') // Use HTTPS Proxy (Optional) const proxy = process.env.proxy || process.env.HTTP_PROXY || process.env.http_proxy || process.env.HTTPS_PROXY || process.env.https_proxy const proxyOptions = {} if (proxy) { // not relying on recommended WHATWG URL // due to missing support for it in https-proxy-agent // https://github.com/TooTallNate/node-https-proxy-agent/issues/117 Object.assign(proxyOptions, url.parse(proxy)) } const ca = process.env.ca || process.env.HTTPS_CA || process.env.https_ca let caCerts = [] if (ca) { // Can be a single certificate or multiple, comma separated. const caArr = ca.split(',') // Replace the newline -- https://stackoverflow.com/questions/30400341 caCerts = caCerts.concat(caArr.map((cert) => cert.replace(/\\n/g, '\n'))) } const cafile = process.env.cafile || process.env.HTTPS_CAFILE || process.env.https_cafile if (cafile) { // Can be a single certificate file path or multiple paths, comma separated. const caPathArr = cafile.split(',') caCerts = caCerts.concat( caPathArr.map((cafilePath) => fs.readFileSync(cafilePath.trim())), ) } if (caCerts.length > 0) { Object.assign(proxyOptions, { rejectUnauthorized: true, ca: caCerts, }) } // Passes also certifications if (proxy) { sdk.config.httpOptions.agent = new HttpsProxyAgent(proxyOptions) } else if (proxyOptions.ca) { // Update the agent -- http://docs.aws.amazon.com/sdk-for-javascript/v2/developer-guide/node-registering-certs.html sdk.config.httpOptions.agent = new https.Agent(proxyOptions) } // Configure the AWS Client timeout (Optional). The default is 120000 (2 minutes) const timeout = process.env.AWS_CLIENT_TIMEOUT || process.env.aws_client_timeout if (timeout) { sdk.config.httpOptions.timeout = parseInt(timeout, 10) } PromiseQueue.configure(Promise) const requestQueue = new PromiseQueue(2, Infinity) const MAX_RETRIES = (() => { const userValue = Number(process.env.SLS_AWS_REQUEST_MAX_RETRIES) return userValue >= 0 ? userValue : 4 })() const accelerationCompatibleS3Methods = new Set(['upload', 'putObject']) const shouldS3Accelerate = (method, params) => { if ( accelerationCompatibleS3Methods.has(method) && params && params.isS3TransferAccelerationEnabled ) { log.notice('Using S3 Transfer Acceleration Endpoint') return true } return false } const getServiceInstance = memoize( (service, method) => { const Service = _.get(sdk, service.name) // we translate params to an object for the service creation by selecting keys of interest const serviceParams = { ...service.params } if (service.name === 'S3') { serviceParams.useAccelerateEndpoint = shouldS3Accelerate( method, service.params, ) } return new Service(serviceParams) }, { normalizer: ([service, method]) => { return [JSON.stringify(deepSortObjectByKey(service)), method].join('|') }, }, ) const normalizerPattern = /(? { return name.replace(normalizerPattern, '_$1').toUpperCase() } let requestCounter = 0 /** Execute request to AWS service * @param {Object|string} [service] - Description of the service to call * @prop [service.name] - Name of the service to call, support subclasses * @prop [service.params] - Parameters to apply when creating the service and doing the request * @prop [service.params.credentials] - AWS Credentials to use * @prop [service.params.useCache ] - Wether to reuse result of the same request cached locally * @prop [service.params.region] - Region in which the call should be made (default to us-east-1) * @prop [service.params.isS3TransferAccelerationEnabled] - Use s3 acceleration when available for the request * @param {String} method - Method to call * @param {Array} args - Argument for the method call */ async function awsRequest(service, method, ...args) { // Checks regarding expectations on service object if (isObject(service)) { ensureString(service.name, { name: 'service.name' }) } else { ensureString(service, { name: 'service' }) service = { name: service } } const BASE_BACKOFF = 5000 const persistentRequest = async (f, numTry = 0) => { try { return await f() } catch (e) { const { providerError } = e if ( numTry < MAX_RETRIES && providerError && ((providerError.retryable && providerError.statusCode !== 403 && providerError.code !== 'CredentialsError' && providerError.code !== 'ExpiredTokenException') || providerError.statusCode === 429) ) { const nextTryNum = numTry + 1 const jitter = Math.random() * 3000 - 1000 // backoff is between 4 and 7 seconds const backOff = BASE_BACKOFF + jitter log.info( [ `Recoverable error occurred (${ e.message }), sleeping for ~${Math.round(backOff / 1000)} seconds.`, `Try ${nextTryNum} of ${MAX_RETRIES}`, ].join(' '), ) await wait(backOff) return persistentRequest(f, nextTryNum) } throw e } } const request = await requestQueue.add(() => persistentRequest(async () => { const requestId = ++requestCounter const awsService = getServiceInstance(service, method) awsLog.debug(`request: #${requestId} ${service.name}.${method}`, args) const req = awsService[method](...args) try { const result = await req.promise() awsLog.debug( `request result: #${requestId} ${service.name}.${method}`, result, ) return result } catch (err) { awsLog.debug( `request error: #${requestId} - ${service.name}.${method}`, err, ) let message = err.message != null ? err.message : String(err.code) if (message.startsWith('Missing credentials in config')) { // Credentials error // If failed at last resort (EC2 Metadata check) expose a meaningful error // with link to AWS documentation // Otherwise, it's likely that user relied on some AWS creds, which appeared not correct // therefore expose an AWS message directly let bottomError = err while ( bottomError.originalError && !bottomError.message.startsWith('EC2 Metadata') ) { bottomError = bottomError.originalError } const errorMessage = bottomError.message.startsWith('EC2 Metadata') ? [ 'AWS provider credentials not found.', ' Learn how to set up AWS provider credentials', ` in our docs here: http://slss.io/aws-creds-setup`, ].join('') : bottomError.message message = errorMessage // We do not want to trigger the retry mechanism for credential errors throw Object.assign( new ServerlessError(errorMessage, 'AWS_CREDENTIALS_NOT_FOUND'), { providerError: Object.assign({}, err, { retryable: false }), }, ) } const providerErrorCodeExtension = (() => { if (!err.code) return 'ERROR' if (typeof err.code === 'number') return `HTTP_${err.code}_ERROR` return normalizeErrorCodePostfix(err.code) })() if (err.stack) { log.debug(`${err.stack}\n${'-'.repeat(100)}`) } throw Object.assign( new ServerlessError( message, `AWS_${normalizeErrorCodePostfix( service.name, )}_${normalizeErrorCodePostfix( method, )}_${providerErrorCodeExtension}`, ), { providerError: err, providerErrorCodeExtension, }, ) } }), ) return request } awsRequest.memoized = memoize(awsRequest, { promise: true, normalizer: ([service, method, args]) => { if (!isObject(service)) service = { name: ensureString(service) } return [ JSON.stringify(deepSortObjectByKey(service)), method, JSON.stringify(deepSortObjectByKey(args)), ].join('|') }, }) export default awsRequest