@idempotender/middy v0.1.4
idempotender
Middy middleware for making AWS Lambda Functions imdepotent.
Create a DynamoDB table and add this middleware to Middy so it selects a certain input attribute as the idempotency key and your function will start being idempontent.
The overall steps that this middlware performs are:
- At the beginning of the function execution, it will extract a key from the input and look for that "execution" in DynamoDB
- In general, if the key exists in a DynamoDB table, it means this execution was already done, so the middleware get the previous output and return it to the Lambda function caller. The caller won't know it wasn't really executed, and will receive the same response as the first client, which is expected.
- If the key doesn't exist, then actual function handler will be run and at the end the middleware will save the output to DynamoDB table before returning it to the caller
- Imdepondenter will control a lock between two parallel requests so only one request will be processed at a time for a specific key and the second one will receive the same contents of the first request, but the actual handler logic will run only once
Usage
npm install --save @idempotender/middy
Create DynamoDB table with structure:
Resources:
IdempotencyExecutions:
Type: AWS::DynamoDB::Table
Properties:
AttributeDefinitions:
- AttributeName: Id
AttributeType: S
KeySchema:
- AttributeName: Id
KeyType: HASH
TimeToLiveSpecification:
AttributeName: executionTTL
Enabled: true
Example: Simple Lambda
In this example, we will use attribute 'param1' from event as the key of idempotency while hashing and using lock to avoid concurrency
Create Lambda function
import idempotenderMiddy from '@idempotender/middy';
const handler = middy((event, context) => {
console.log(`Running for '${event.param1}' on ${new Date()}`);
return { message: `This was run for '${event.param1}' on ${new Date()}` };
});
handler.use(
idempotenderMiddy({
keyJmespath: 'param1',
}),
);
Example: Lambda called via REST API
In this example, the Lambda is invoked through AWS API Gateway, so we select attributes 'method', 'path' and request 'body' from event as the key of idempotency.
The extracted key will then be hashed before being stored in database, so data is not exposed, but you have a very very tiny change of collision (we use hash-256).
Create AWS Lambda function exposed through AWS API Gateway
import idempotenderMiddy from '@idempotender/middy';
const handler = middy((event, context) => {
console.log('Will only execute this once for the same URL method + path + body contents');
return { message: `This was run for '${event.param1}' on ${new Date()}` };
});
handler.use(
idempotender({
keyJmespath: '[method, path, body]',
}),
);
Example: Lambda called via REST API with Idempotency-Key header
In this example, the Lambda is invoked through AWS API Gateway, and we will use Stripe style for controlling the idempotency key (header Idempotency-Key). See https://stripe.com/docs/api/idempotent_requests.
We won't use hash and the quality of the idempotency key is responsability of the caller.
The idempotency key will be valid for 24h, which means that another call with the same idempotency header after 24h will make the function run again.
Create AWS Lambda function exposed through AWS API Gateway
import idempotenderMiddy from '@idempotender/middy';
const handler = middy((event, context) => {
console.log('Will only execute this once per "Idempotency-Key" header value');
return { message: `This was run for '${event.param1}' on ${new Date()}` };
});
handler.use(
idempotenderMiddy({
keyHash: false,
executionTTL: 24 * 3600,
keyJmespath: "[headers['Idempotency-Key']]",
}),
);
Key selection
This is critical for a good Idempotency implementation, as it dictates what is the domain of an idempotent execution. keyJmespath (or a custom mapper) have to be specifically crafted for your application, as it requires specific knowledge about the input of the Lambda function and which attributes must be taken in consideration for selecting a key.
Select a key that uniquely identifies a specific call. Two call to the same key will result in the same contents, having only the first call actually processed and the second one returning the cached contents from the previous execution.
You might be tempted to use the entire input as the source of this key, but probably it will have timestamp based data about when the call was made, or client information (such as user agent, capabilities) that that not necessarily is used as the basis for defining idempotency
Use configuration keyJmespath or keyMapper to define how to extract the key from the input of the function
Idempotender prefixes the key with the number part of your Lambda ARN, so you can reuse the same DynamoDB for multiple Lambda functions without the risk of collision between them.
Identification of a successful execution
It's important to be clear about if the response of a execution indicates a successful execution so Idempotender decides to store its output or cancels the lock for a later retry by the client in case there is a temporary error going on.
If you don't evaluate clearly, an error "500" can be considered "normal" and then the actual execution won't be retried until the execution timeout expires (sometimes it can be only in 24h, for example) then your application can be stuck for a while.
The execution is considered failed:
- Always when the handler function throws an exception
- It is canceled even when another middleware changes the response on "onError" callback and middy doesn't rethrow the exception
- It means that maybe you can return a custom response when an exception happens and it wont prevent the idempotent execution to be canceled
- Throwing an exception is the best way to indicate that something unexpected happened, for example, X-Ray uses it to identify root causes
- Always when the response is "not valid"
- jmespath query from 'config.validResponseJmespath' is run against the response object to evaluate if it's valid or not
- This is evaluated only when the response is an object or a string that contains json contents
- Always when the handler function throws an exception
When an execution is failed, the lock will be cancelled and the execution response won't be saved/reused in later calls
Reference
- These are the default values of the configuration
const idem = idempotenderMiddy({
dynamoDBTableName: 'IdempotencyExecutions',
lockEnable: true,
lockTTL: 60,
executionTTL: 24 * 3600,
keyHash: true,
lockAcquireTimeout: 10,
keyJmespath: null,
keyMapper: null,
markIdempotentResponse: true,
validResponseJmespath: "statusCode >= `200` && statusCode < `300`" (if called from API GW)
}
- You must use idempotender middleware as the first middleware in the chain so that it can store the response after all other middlewares are executed and be the first to return when a idempotent call is detected
// example
handler = middy(lambdaHandler)
.use(idempotenderMiddy(config))
.use(httpErrorHandler())
.use(cors());
Config attributes:
dynamoDBTableName
DynamoDB table to use for controlling idempotency
Defaults to 'IdempotencyExecutions'
lockEnable
Avoids parallel concurrency situations in which, while the first execution is running, another request arrives (before the first is finished)
In this situation, the second request will be responded with error 409 (conflict) so the client can resubmit it again later (and then receive a valid response, cached from the first run - because of the idempotency)
Activating this may increase your DynamoDB costs by ~3x, but is recommended because it's safer
Defaults to 'true'
lockTTL
Time in seconds that the concurrency lock will be active. During this timespan, if another request arrives, before the first request is finished, it will receive a status 419 (see lockEnable)
Defaults to '10'
lockAcquireTimeout
Time in seconds waiting for an existing lock to be released in case of concurrency. If the lock is released in this period, we will try to get the last saved output from the other process (if it was saved) and return the previous output gracefully. If after lock is released the output was not saved, we will try to acquire the lock again.
Defaults to '15'
executionTTL
Time in seconds after execution is finished in which if another request with the same key arrives, will be responded with the first execution response.
The actual execution will be skipped, but the client will receive the same response as in the first call.
Defaults to '24 * 3600'
keyMapper
A function that receives the input data and returns the key used by the idempotency control
Defaults to a jmespath data extractor, controlled by option 'keyJmespath'
keyJmespath
jmespath expression used for extracting the database key from input data. Check https://jmespath.org/tutorial.html
Required if no custom keyMapper is used. Not used if 'keyMapper' is defined.
validResponseJmespath
jmespath expression that is executed against the response and returns a boolean value indicating if the contents are valid or not
If response is a string, Idempotender will try to parse it as json and do the check
If not defined and called from API GW, defaults to 'statusCode >= `200` && statusCode < `300'`'
keyHash
Whatever hash the key before storing in the database or not
Useful in cases where the key is too large or you don't want to expose the input parameters in plain text in DynamoDB
This is applied after keyMapper is executed
SHA-256 is used, which is very secure, but keep in mind that there is a very low possibility of collisions if this is enabled.
Defaults to 'true'
AWS inputs
When using Lambda with different callers, the input may have different data that you have to understand in order to create a good jmespath query for getting a good source of idempotency key.
See below some sample inputs depending on which service has called Lambda
AWS API Gateway sample request
{
"resource": "/",
"path": "/",
"httpMethod": "GET",
"requestContext": {
"resourcePath": "/",
"httpMethod": "GET",
"path": "/Prod/"
},
"headers": {
"accept": "text/html,application/xhtml+xml,application/xml;q=0.9,image/webp,image/apng,*/*;q=0.8,application/signed-exchange;v=b3;q=0.9",
"accept-encoding": "gzip, deflate, br",
"Host": "70ixmpl4fl.execute-api.us-east-2.amazonaws.com",
"User-Agent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/80.0.3987.132 Safari/537.36",
"X-Amzn-Trace-Id": "Root=1-5e66d96f-7491f09xmpl79d18acf3d050"
},
"multiValueHeaders": {
"accept": [
"text/html,application/xhtml+xml,application/xml;q=0.9,image/webp,image/apng,*/*;q=0.8,application/signed-exchange;v=b3;q=0.9"
],
"accept-encoding": ["gzip, deflate, br"]
},
"queryStringParameters": null,
"multiValueQueryStringParameters": null,
"pathParameters": null,
"stageVariables": null,
"body": null,
"isBase64Encoded": false
}
Special behaviors depending on AWS input
When Lambda is invoked via AWS API GW
The http header 'X-Idempotency-From' is added with the timestamp of the first call that actually run the function whe returning a cache response
If "validResponseJmespath" config is not defined, it will default to 'statusCode >= `200` && statusCode < `300`', which means responses not in range 2xx won't be saved in idempotency.