Indempotent APIs

What is Idempotency?

Idempotency is a characteristic that guarantees a particular operation can be performed multiple times without changing the result beyond the initial operation. In simpler terms, an idempotent API returns the same result for the same request. This means multiple identical requests have the effect of a single request.

Importance in Distributed Systems

In distributed systems, network failures and other issues can and will happen. As a result, requests might need to be retried, which could lead to the same request being sent multiple times. Without idempotency, this can cause unintended side effects, such as duplicate transactions or resource creation.

Non-Idempotent API Example

Consider the following non-idempotent API:

POST /restaurants/<restaurantId>/toggle-favorite

Assume the restaurant is not a favorite yet. Calling this endpoint for the first time marks it as a favorite. A second request toggles it back to non-favorite. This behavior is not idempotent because multiple identical requests do not produce the same result.

Making It Idempotent

We can design this API idempotent by including the desired favorite state in the request:

POST /restaurants/:restaurantId/toggle-favorite
{
  isFavorite: true
}

In this case, any unexpected or accidental retry ensures the restaurant remains marked as a favorite, providing consistent results.

Challenge with Non-Idempotent Operations

Not all APIs can be idempotent by design. Consider the following payment API:

POST /api/payments
{
  receiver: "account-2",
  amount: "150",
  currency: "USD"
}

If our payment service sees this request for the second time, how can it know whether the request is a retry or if the user actually wants to send this amount of money a second time? We can build heuristics to flag the payment request as "likely unintended," but we don't know for sure.

Solution: Idempotency Key

Introducing an "Idempotency Key" can solve this problem. Idempotency keys are unique identifiers generated by the client of a service and attached to a request. Typically, the key is included in an HTTP header, but it can also be part of the request body or any other part of the request.

POST /api/payments
Idempotency-Key: 6ba3-f122-42e1-8b31-...
{
  receiver: "account-2",
  amount: "150",
  currency: "USD"
}

The targeted service can then keep a list of idempotency keys and check whether a request has been seen before. If a request with the same key is received again, the service knows it is a duplicate and can return the same result without processing the request again.

HTTP Methods and Idempotency

Certain HTTP methods are intendent to be idempotent by nature:

  • GET: Retrieving data does not change the state of the server.
  • PUT: Updating a resource with the same data multiple times has the same effect as doing it once.
  • DELETE: Deleting a resource that does not exist is still a successful operation.

POST requests are not idempotent because they typically create new resources or trigger operations that change the state on the server.

Use Cases and Importance

Real-world scenarios where idempotency is crucial include:

  • Payment Processing: Ensuring that duplicate payment requests do not result in multiple charges.
  • Resource Creation: Avoiding the creation of duplicate resources in response to repeated requests.
  • Booking Systems: Preventing double bookings by handling retry requests correctly.

Implementation Details

Generating Idempotency Keys

Clients can generate unique idempotency keys using methods like UUIDs to ensure they are unique for each request.

Storage and Retrieval

Servers need to store and manage idempotency keys, including strategies for key expiration to avoid excessive storage use. A common approach is to store the key along with the request payload and response in a database or cache.

Idempotency Key Scope

The scope of an idempotency key should be limited to the specific operation it was intended for, ensuring that keys are not reused across different types of requests.

Best Practices

Client-Side Practices

  • Retry Logic: Implement robust retry logic that includes generating and attaching unique idempotency keys to each request.
  • Handling Server Responses: Properly handle server responses, including recognizing when a duplicate request has been detected and processed.

Server-Side Practices

  • Handling Idempotency Keys: Ensure that idempotency keys are checked, stored, and managed correctly.
  • Error Codes and Responses: Return appropriate error codes (e.g., HTTP 409 Conflict) for duplicate requests and ensure the client receives a consistent response.

Examples and Code Snippets

Client-Side Implementation

const axios = require('axios');
const { v4: uuidv4 } = require('uuid');

async function makePayment(paymentData) {
  const idempotencyKey = uuidv4();
  let retries = 3;

  while (retries > 0) {
    try {
      const response = await axios.post('/api/payments', paymentData, {
        headers: {
          'Idempotency-Key': idempotencyKey
        }
      });
      return response.data;
    } catch (error) {
      retries -= 1;
      if (retries === 0 || !isRetryableError(error)) {
        throw error;
      }
      console.error('Payment failed, retrying...', error);
    }
  }
}

function isRetryableError(error) {
  if (!error.response) {
    return true; // Network or server errors are retryable
  }
  const status = error.response.status;
  return status >= 500 || status === 429; // Retry on server errors or rate limits
}

makePayment({
  receiver: "account-2",
  amount: "150",
  currency: "USD"
});

Server-Side Implementation

package main

import (
    "crypto/sha256"
    "encoding/hex"
    "encoding/json"
    "net/http"
    "sync"
)

var idempotencyStore = struct {
    sync.RWMutex
    store map[string]map[string]interface{}
}{store: make(map[string]map[string]interface{})}

func processPayment(w http.ResponseWriter, r *http.Request) {
    idempotencyKey := r.Header.Get("Idempotency-Key")
    if idempotencyKey == "" {
        http.Error(w, "Idempotency Key required", http.StatusBadRequest)
        return
    }

    hasher := sha256.New()
    hasher.Write([]byte(idempotencyKey))
    requestHash := hex.EncodeToString(hasher.Sum(nil))

    idempotencyStore.RLock()
    if response, exists := idempotencyStore.store[requestHash]; exists {
        idempotencyStore.RUnlock()
        w.Header().Set("Content-Type", "application/json")
        w.WriteHeader(http.StatusConflict) // Return HTTP 409 Conflict
        json.NewEncoder(w).Encode(response)
        return
    }
    idempotencyStore.RUnlock()

    var paymentData map[string]interface{}
    if err := json.NewDecoder(r.Body).Decode(&paymentData); err != nil {
        http.Error(w, "Invalid request payload", http.StatusBadRequest)
        return
    }

    response := map[string]interface{}{
        "status":          "success",
        "transaction_id":  "txn_12345",
    }

    idempotencyStore.Lock()
    idempotencyStore.store[requestHash] = response
    idempotencyStore.Unlock()

    w.Header().Set("Content-Type", "application/json")
    w.WriteHeader(http.StatusOK)
    json.NewEncoder(w).Encode(response)
}

func main() {
    http.HandleFunc("/api/payments", processPayment)
    http.ListenAndServe(":8080", nil)
}