Dismiss
  • Scroll up
  • Toggle Theme
  • View as Mobile

Building Idempotent APIs with AWS Lambda Powertools

In today's distributed systems, ensuring reliable and consistent API behavior is crucial. One of the key challenges developers face is handling duplicate requests gracefully. This is where idempotency comes into play.

This post will guide serverless developers and AWS users through implementing idempotent APIs using AWS Lambda Powertools. By the end, you'll understand how to build resilient APIs that can handle network issues and client retries without producing duplicate operations.

The code for this is inside the spike-idempotent-requests repo. It uses the serverless framework to deploy the DDB table and the backend lambda function and then the frontend is using react/vite.

See the demo https://demo-idempotent-requests.netlify.app

What is Idempotency?

Idempotency is a property of an API endpoint that ensures multiple identical requests produce the same result as a single request. This is particularly important in scenarios where network issues or client retries might cause the same request to be sent multiple times.

TLDR; Only do things once.

Why do we need idempotency?

Implementing idempotent APIs is crucial for:

  • Preventing duplicate transactions
  • Handling network issues gracefully
  • Improving system reliability
  • Reducing the complexity of client-side retry logic
  • Saving on costs for expensive long running backend operations

Real-world Examples

Understanding when to use idempotency is vital. Here are common scenarios where idempotent APIs make a difference:

  1. Payment Processing: Prevent charging a customer twice if they submit a payment and the connection drops before receiving confirmation
  2. Order Submission: Ensure an order is only placed once, even if the customer clicks "Submit" multiple times
  3. Resource Creation: Avoid creating duplicate resources (users, accounts, etc.) when requests are retried
  4. Data Updates: Make sure updates are applied exactly once, even with network issues

Implementation Architecture

Here's how our idempotent API system works:

  1. Client adds Idempotency-Key header that is the hashed contents of the payload
  2. The server stores this hashed payload if the request succeed
  3. On the next request, if its a payload we have already seen, we can returned the already processed results
┌─────────────┐        ┌─────────────┐        ┌───────────────────┐        ┌──────────────┐
│   Client    │───────▶│  API        │───────▶│ Lambda Function   │───────▶│  Business    │
│ (with Key   │        │  Gateway    │        │ with Idempotency  │        │  Logic       │
│  Key)       │        │             │        │ Wrapper           │        │              │
└─────────────┘        └─────────────┘        └───────────────────┘        └──────────────┘
                                                      │
                                                      │
                                                      ▼
                                              ┌──────────────────┐
                                              │    DynamoDB      │
                                              │ (Idempotency     │
                                              │  Records)        │
                                              └──────────────────┘

Implementation Steps

Let's break down how to implement idempotent APIs with AWS Lambda Powertools:

  1. Set up DynamoDB table for storing idempotency records
  2. Configure Lambda Powertools with idempotency support
  3. Create a handler function wrapped with idempotency logic
  4. Implement proper error handling for missing keys
  5. Add cache control headers for optimized client behavior

How it works

In our backend code we are wrapping our business logic with makeIdempotent from @aws-lambda-powertools/idempotency

This will check in memory (if possible), then also check in a DynamoDB table if our request has already been made.

// https://github.com/DavidWells/spike-idempotent-requests/blob/master/src/backend/src/idempotent.js
import { createHash } from 'crypto'
import { makeIdempotent, IdempotencyConfig } from '@aws-lambda-powertools/idempotency';
import { DynamoDBPersistenceLayer } from '@aws-lambda-powertools/idempotency/dynamodb'
import { Logger } from '@aws-lambda-powertools/logger'

const logger = new Logger()

if (!process.env.IDEMPOTENCY_TABLE) {
  throw new Error('IDEMPOTENCY_TABLE is not set in environment variables')
}

const persistenceStore = new DynamoDBPersistenceLayer({
  tableName: process.env.IDEMPOTENCY_TABLE,
  keyAttr: 'id',
  expiryAttr: 'expiration',
})

// Helper function to generate ETag from response body
function generateETag(responseBody) {
  return `"${createHash('md5').update(responseBody).digest('hex')}"`
}

// Add cache headers to the response
function addCacheHeaders(response, etag) {
  // Get cache TTL from env or use default (3600 seconds = 1 hour)
  const cacheTtl = process.env.CACHE_TIME_IN_SECONDS || 3600
  
  // Create new headers object - don't mutate corsHeaders directly
  response.headers = {
    ...response.headers,
    'ETag': etag,
    'Cache-Control': `max-age=${cacheTtl}`,
    'Vary': 'Accept-Encoding, Accept'
  }
  
  return response
}

function responseHook(response, record) {
  console.log('responseHook response')
  console.log(response)
  console.log('responseHook record')
  console.log(record)
  const currentBody = JSON.parse(response.body)
  
  // Add serverCacheHit flag to indicate this is from the idempotency cache
  const newBody = JSON.stringify(Object.assign({}, currentBody, {
    serverCacheHit: true
  }))
  response.body = newBody
  
  
  // Return modified response
  return response
}

const idempotencyConfig = new IdempotencyConfig({
  eventKeyJmesPath: 'headers."Idempotency-Key" || headers."idempotency-key"',
  expiresAfterSeconds: parseInt(process.env.CACHE_TIME_IN_SECONDS), // 24 * 60 * 60, // 24 hours
  throwOnNoIdempotencyKey: true,
  useLocalCache: true,
  maxLocalCacheSize: 512,
  responseHook,
})

const corsHeaders = {
  'Access-Control-Allow-Origin': '*',
  'Access-Control-Allow-Headers': '*',
  'Access-Control-Allow-Methods': 'OPTIONS,POST,GET'
}

/**
 * Lambda handler for idempotent requests
 * @param {import('aws-lambda').APIGatewayProxyEvent} event - The request event
 * @returns {Promise<Response>} The response
 */
export const handler = makeIdempotent(
  async (event) => {
    try {
      logger.info('Processing request', { event })
      
      // Check for idempotency key
      const idempotencyKey = event.headers?.['Idempotency-Key'] || event.headers?.['idempotency-key']
      if (!idempotencyKey) {
        return {
          statusCode: 400,
          headers: corsHeaders,
          body: JSON.stringify({ error: 'Idempotency key is required' })
        }
      }
      
      // Check if the request includes an If-None-Match header
      const ifNoneMatch = event.headers?.['If-None-Match'] || event.headers?.['if-none-match']
      
      // Create the response body
      const responseBody = JSON.stringify({ 
        message: 'Processed', 
        requestId: idempotencyKey
      })
      
      // Generate an ETag for this response
      const etag = generateETag(responseBody)
      console.log('etag', etag)
      
      // If client sent If-None-Match header that matches our ETag, return 304
      if (ifNoneMatch && ifNoneMatch === etag) {
        logger.info('ETag match - returning 304', { etag, ifNoneMatch })
        return {
          statusCode: 304, // Not Modified
          headers: addCacheHeaders({ headers: corsHeaders }, etag)
        }
      }
      
      // Otherwise, return full response with ETag
      logger.info('Returning full response with ETag', { etag })
      return addCacheHeaders({
        statusCode: 200,
        headers: corsHeaders,
        body: responseBody
      }, etag)
    } catch (err) {
      logger.error('Handler error', { error: err })
      
      // Handle missing idempotency key error
      if (err.message?.includes('No idempotency key found')) {
        return {
          statusCode: 400,
          headers: corsHeaders,
          body: JSON.stringify({ error: 'Idempotency key is required' })
        }
      }

      return {
        statusCode: 500,
        headers: corsHeaders,
        body: JSON.stringify({ error: err.message })
      }
    }
  },
  {
    persistenceStore,
    config: idempotencyConfig
  }
)

Testing Idempotent Endpoints

When implementing idempotent APIs, thorough testing is essential. Here's how to test your endpoints:

  1. Send duplicate requests with the same idempotency key and verify only one operation occurs
  2. Test with simulated network failures by cutting requests halfway and retrying
  3. Verify cache behavior by checking the serverCacheHit flag in responses
  4. Test expiration by sending requests with the same key after the expiration period
  5. Validate error handling by sending requests without idempotency keys

Here's a simple test script example:

async function testIdempotency() {
  const idempotencyKey = `test-${Date.now()}`
  
  // First request should succeed normally
  const response1 = await fetch('https://your-api.com/endpoint', {
    method: 'POST',
    headers: {
      'Idempotency-Key': idempotencyKey,
      'Content-Type': 'application/json'
    },
    body: JSON.stringify({ test: 'data' })
  })
  
  // Second request with same key should return cached result
  const response2 = await fetch('https://your-api.com/endpoint', {
    method: 'POST',
    headers: {
      'Idempotency-Key': idempotencyKey,
      'Content-Type': 'application/json'
    },
    body: JSON.stringify({ test: 'data' })
  })
  
  const data1 = await response1.json()
  const data2 = await response2.json()
  
  console.log('First response:', data1)
  console.log('Second response:', data2)
  console.log('Server cache hit:', data2.serverCacheHit)
}

Project Details

A demonstration project showcasing how to implement idempotent requests in AWS Lambda functions using the AWS Lambda Powertools library. This project includes both backend and frontend components to demonstrate the complete flow of idempotent request handling.

Table of Contents

Project Structure

.
├── src/              # Source code for both frontend and backend
│   ├── backend/      # AWS Lambda functions and infrastructure
│   └── frontend/     # React frontend application
├── scripts/          # Utility scripts
└── docs/             # Project documentation

Prerequisites

  • Node.js (v18 or later)
  • pnpm (v10.11.0 or later)
  • AWS CLI configured with appropriate credentials

Setup

  1. Install dependencies:

In the root dir run:

## Install dependencies
npm run setup
  1. Configure AWS credentials:

    • Ensure you have AWS credentials configured in ~/.aws/credentials
    • The credentials should have permissions to create and manage DynamoDB tables and Lambda functions
  2. Deploy the backend:

cd src/backend
npm run deploy

Development

Backend

The backend consists of AWS Lambda functions that demonstrate idempotent request handling using AWS Lambda Powertools. Key features:

  • Idempotent request handling using DynamoDB
  • Structured logging
  • Unit and integration tests

To start the development server:

npm start

To run tests:

cd src/backend
pnpm run test

Frontend

The frontend is a React application that demonstrates how to interact with the idempotent API endpoints.

To start the development server:

cd src/frontend
npm start

Make sure to deploy the backend first to setup the API endpoint.

Pull in the backend api endpoint automatically with

cd src/frontend
npm run sync

Key Features

  • Idempotent request handling using AWS Lambda Powertools
  • DynamoDB integration for idempotency state management
  • Structured logging with AWS Lambda Powertools Logger
  • React frontend for demonstration
  • Comprehensive test suite

Architecture

The project uses:

  • AWS Lambda for serverless functions
  • DynamoDB for idempotency state management
  • @aws-lambda-powertools/idempotency for idempotency
  • React + vite for the frontend interface

License

ISC

Conclusion

This project serves as a practical example of implementing idempotent APIs in a serverless environment. By using AWS Lambda Powertools, we can implement these patterns with minimal boilerplate code while maintaining best practices for observability and reliability.

The complete source code is available in the repository, along with detailed documentation on setup and usage. Whether you're building a new API or improving an existing one, this project provides valuable insights into implementing idempotent request handling in a production environment.

See the code in https://github.com/DavidWells/spike-idempotent-requests/

Enjoy ✌️