Designing Idempotent REST APIs

Photo by Luca Bravo on Unsplash

Designing Idempotent REST APIs

Designing idempotent APIs using Conditional Requests and Post-Once-Exactly with Spring Boot and CockroachDB

·

13 min read

Overview

This article outlines common techniques for implementing REST API idempotency using the Spring Boot stack, or to be specific: idempotent POST methods.

The POST method is not safe or idempotent by specification, yet it's frequently used when creating new resources. How do you guarantee that double posting doesn't result in duplicate side-effects, as in exactly/effectively once semantics?

One way of turning a POST method idempotent is by using a generated ID or token as a pre-condition control element to check whether the request has been previously processed or not. If that is the case, any additional request will just be de-deduplicated as no-ops. This way, the client can safely re-submit a POST request any number of times without concerning itself about causing multiple side-effects in the output of the operation.

Before jumping into the weeds on how, lets first do a brief primer on REST, HTTP and why idempotency is an important and useful API design property.

What is a REST API?

Representational State Transfer (REST) coined by Roy Fielding two decades ago is an architectural style for distributed hypermedia systems described by a set of constraints named the uniform interface, client-server, stateless, cacheable, layered system and code-on-demand.

“REST provides a set of architectural constraints that, when applied as a whole, emphasizes scalability of component interactions, generality of interfaces, independent deployment of components, and intermediary components to reduce interaction latency, enforce security, and encapsulate legacy systems.“

In this style, every piece of information is a resource, and resources are addressed using an URI, typically links on the Web. Each unique URI refers to a representation of some object or resource that only exist on the server. The resources are acted upon by using a set of simple, uniform and well-defined operations or methods.

Operations on a REST resource follows the HTTP verbs used on the web. You can get the contents of a resource using GET, update it with PUT or PATCH, create a new resource with POST or delete it with a DELETE.

Implementing REST is like everything else not immune against bad practises and anti-patterns. Such as breaking the safety and idempotence properties of HTTP methods like tunneling updates via GET or passing all operations via POST.

Method safety and idempotence are key for a successful REST implementation, besides adopting one of the most overlooked sub-constraint of them all: hypermedia as the engine of application state, part of the uniform interface. The demo service used in this article is hypermedia driven, much because Spring HATEOAS makes it quite straightforward. We'll come back to hypermedia driven APIs at a later time since this article is about idempotency.

Method Safety and Idempotence

Below is a table of the most common HTTP methods or verbs and their semantics:

MethodDescriptionSafeIdempotent
GETReturns a representation of a resourceYesYes
PUTCreate or replace a resource with the given representationNoYes
DELETEDelete an identified resourceNoYes
POSTCreate a resource with the given representation as a subordinate to an identified resourceNoNo
HEADSame as GET but only retrieves headers and not the bodyYesYes
OPTIONSReturns the methods/verbs supported by an identified resourceYesYes
PATCHApply partial modifications to an identified resourceYesNo

Safe means that a method call will have no side effects that the client is accountable for. A safe method is typically a read operation without any significance other than retrieval.

Idempotent means that the side effects of numerous identical requests is the same as for a single request. A unary operation (function) is idempotent if, whenever it is applied twice or more to any value, gives the same result as if it were applied once, for example: abs(abs(a))=abs(a).

Why is idempotency important?

Idempotency is an important and useful design property in distributed systems because it helps to maintain consistency and integrity across integration boundaries. It also defers that responsibilities to the server rather than burdening the clients. A client should be allowed to be rather ignorant and able to send a sequence of requests multiple times over an unreliable network without worrying about multiple side effects or consistency.

Consider a scenario where a client submits a request to move funds between accounts. The request is accepted by the server and it's completed by writing to the database with a commit, which in turn emits a change event that cause other downstream systems to also act on the business event. These are the visible side effects of POSTing this particular request, the so called post-conditions.

idempotent_api.jpg

There's a problem here however, where the acknowledgement to the client gets delayed and lost due to an I/O error. The client is left hanging trying to figure out what actually happened. Did the request succeed or not?

Network errors does not always mean failure but rather absence of information. This points back to the two-generals paradox, that it's impossible to tell the difference between a failed and slow response over an unreliable channel. The client may decide to re-submit the request after a timeout, which in this case will cause multiple side effects and that is not the desired outcome or a valid post-condition.

Idempotency in APIs is ultimately a quality of service provided towards clients, where the burden of ensuring a correct outcome from double processsing is contained by the server.

Hypermedia also serves the purpose of complexity containment, yet in a different way. Hypermedia controls are used to guide a client throughout a series of workflow steps and thereby protecting it from business rules, domain knowledge and binding entirely to out-of-band information.

There’s always a cost involved to provide idempotency unless an operation is naturally idempotent or immutable. Service idempotency ensures that if an operation is called multiple times with the same deterministic input (parameters), the post-conditions are unaffected. A read operation is naturally idempotent in most cases but not always without side-effects. A read-only GET request may result in audit log entries on the server which, when put into a different definition context not be considered idempotent.

For service idempotency however, only the post-conditions must hold true. It is not relevant for a service consumer whether a log entry is created or not on the server for auditing. The choice of the correct HTTP method signals what a service supports in terms of idempotency and safety, which is why GET is always a really bad idea for tunneling writes.

Even though the POST and PATCH methods are not idempotent by specification, they can be made idempotent by the service implementation. POST methods are pretty much "allowed" to do anything, even deleting resources.

That's it for the primer, lets now see this implemented in practice.

Idempotency in Action

Lets go through a tutorial of using idempotent POSTs from the clients point of view using only cURL and jq for formatting.

Example Code

The code examples are available in Github.

Use Case

The use case is to move funds between accounts. Each account holds a current balance which must stay positive (invariant). A transfer is a single synchronous operation where funds are moved between different accounts expressed as legs. Each leg represents a single account balance update for which the total sum of all legs must equal zero. Once a transfer is completed, each leg will have a corresponding transaction describing the balance update.

The transfer operation is what we intend to make idempotent. Because the entire business operation can be expressed in a single TransferRequest its just a matter of making that HTTP POST controller endpoint idempotent, meaning that POSTing the same request once will have the same side-effect as posting it multiple times.

Implementation Options

To implement this, we are going to use two slightly different approaches which mainly differs in workflow steps and operations involved. The client input and service outcome is the same in both approaches.

The options are:

  1. Conditional POST Requests - Conditional POSTs request using generated One-Time URIs
  2. Post-once-exactly Method - Storing idempotency keys for de-duplication

Conditional POST Request with One-Time URIs

In this method we will use weak entity tags (ETag) of the accounts to generate a token which will be encoded into the URI. The generated token is only valid for the current state of the accounts targeted for the transfer, which will be used as a pre-condition for POST requests to succeed.

ETags are typically used for two purposes; caching and conditional requests. Conditional requests can be applied in an optimistic locking strategy and for idempotency. Strong ETags are commonly a cryptographic hash of the resource representation, where even the smallest change will result in a new ETag. Weak ETags are a softer version where the semantic equivalence is compared. Caching can also be combined with a Last-Modified header which is the last modified date of the resource.

The client supplies the account IDs involved in the transfer in an initial GET request, and the server returns a link with a hash representing the state of the accounts to initiate the transfer.

conditional.jpg

  1. Request to get a URI to make a conditional request. Responds with a one-time URI and current state of resources.
  2. Request to complete the transfer with the hash token as pre-condition. Responds with a 201 and the resources created.
  3. Attempt to re-post the same transfer, using the now expired URI.
  4. Responds with a pre-condition failed response since the URI is expired.

In this demo we are using a SHA-256 checksum of the current account balances and IDs. To prevent URI tempering, we could also include a digital signature in the URI. The token does not need to be stored since it can be recomputed from the current entity tags of the resources. If the hashed values does not match, it means the URI is already used.

Let's go through each of these steps using the demo service.

First get a transfer form template:

curl http://localhost:8090/transfer/form | jq

Form templates are used in REST APIs to pre-populate forms at the clients convenience. In this demo service we use the HAL+forms media type.

In the response there are four account legs which we are going to use (formatted below). The underscore-prefixed elements in the response are hypmermedia controls that can be ignored for now.

{
  "legs": [
    {
      "id": 1,
      "amount": 10.0
    },
    {
      "id": 2,
      "amount": -10.0
    },
    {
      "id": 3,
      "amount": -15.0
    },
    {
      "id": 4,
      "amount": 15.0
    }
  ]
}

Next, sign the form by following the roach-spring:transfer-signature link rel in the previous response:

curl -v -d '{"legs":[{"id":1,"amount":10.0},{"id":2,"amount":-10.0},{"id":3,"amount":15.0},{"id":4,"amount":-15.0}]}' -H "Content-Type:application/json" -X GET http://localhost:8090/transfer/signature | jq

In the response you will find a X-transfer header which represents a hash of the current state of the accounts 1, 2, 3 and 4:

X-transfer: 0ed104255363925a54790b0e11eac725a5f66caf4d8d244421c4c53485bb1c85

Next, post the transfer form by following the roach-spring:transfer link rel:

curl -v -d '{"legs":[{"id":1,"amount":10.0},{"id":2,"amount":-10.0},{"id":3,"amount":15.0},{"id":4,"amount":-15.0}]}' -H "Content-Type:application/json" -X POST http://localhost:8090/transfer/signature/0ed104255363925a54790b0e11eac725a5f66caf4d8d244421c4c53485bb1c85 | jq

If all goes well, expect a 201 in return:

HTTP/1.1 201 Created
Date: Sun, 16 Oct 2022 07:41:09 GMT
Content-Type: application/prs.hal-forms+json
Transfer-Encoding: chunked

The rest of the response contains a resource representation of the transactions created as a result of the fund transfer (the side-effect).

The generated hash 0ed104255363925a54790b0e11eac725a5f66caf4d8d244421c4c53485bb1c85 is now considered consumed and no longer valid, so attempting to re-post the same request will fail with a 412:

HTTP/1.1 412 Precondition Failed
Date: Sun, 16 Oct 2022 07:43:16 GMT
Content-Type: application/problem+json
Transfer-Encoding: chunked

And there we have it, idempotent POSTs using one time generated URIs without storing any keys or tokens. For a production-grade service there a few more security considerations like using digital signatures to prevent URI tampering, but the concept is the same.

The main drawback with this approach is that its taxing on validating the pre-condition for the request. The accounts must be read from the database and a hash created in a separate GET request before a POST request is possible. The other drawback is that its not straightforward to return the same response as the original request when the precondition fails since neither the token or the response is stored. Lastly, this method depends on the fact that there is some entity tags to use as a base for the hash function, like pre-existing accounts in this example.

Post-Once-Exactly

Another solution very similar to the conditional requests method (its also conditional) is referred to as POST once exactly or POE, for which there is an expired internet [draft] (datatracker.ietf.org/doc/html/draft-notting..).

The principle is to generate a token based on a timestamp, random number or by using an UUID. This token is then stored and used for de-duplication by the server. It mean the server must store the token for a period of time to be able to tell if an URI has been used or not. This however leans well into tagging the original response with the token, so that when de-duplication happens, the server can return the same response but with a 200 OK code rather than 201 Created.

poe.jpg

  1. Request to get a URI to make a conditional request. Response with a POE-Link header containting idempotency token/key. This step is optional as the client can use a generated token or UUID just as well.
  2. Request with a pre-condition to complete the transfer.
  3. Response with a 201 code and created resources.
  4. Attempt to re-post using expired token.
  5. Responds either with a 200 OK and the original response payload, or a 405 to signal pre-condition failed.

Let's walk through this example as well (note: this deviates a bit from the expired POE spec).

First get a transfer form template:

curl http://localhost:8090/transfer/form | jq

If you look in the _links section, you will find a rel named transfer-once with a UUID token encoded into the URI:

{
  "legs": [
    {
      "id": 1,
      "amount": 0
    },
    {
      "id": 2,
      "amount": 0
    },
    {
      "id": 3,
      "amount": 0
    },
    {
      "id": 4,
      "amount": 0
    }
  ],
  "_links": {
    "roach-spring:transfer-signature": {
      "href": "http://localhost:8090/transfer/signature",
      "title": "Sign request with current account states"
    },
    "roach-spring:transfer-once": {
      "href": "http://localhost:8090/transfer/07f18b6d-9bfd-4a38-af0e-781f21963fcf",
      "title": "Submit transfer request using POE tag"
    },
    "curies": [
      {
        "href": "http://localhost:8090/rels/{rel}",
        "name": "roach-spring",
        "templated": true
      }
    ]
  },
  "_templates": {
    "default": {
      "method": "POST",
      "properties": [
        {
          "name": "legs",
          "readOnly": true
        }
      ],
      "target": "http://localhost:8090/transfer/07f18b6d-9bfd-4a38-af0e-781f21963fcf"
    }
  }
}

Next, we will use the following transfer amounts with a zero sum:

{
  "legs": [
    {
      "id": 1,
      "amount": 10.0
    },
    {
      "id": 2,
      "amount": -10.0
    },
    {
      "id": 3,
      "amount": -15.0
    },
    {
      "id": 4,
      "amount": 15.0
    }
  ]
}

Let's follow the roach-spring:transfer-once link rel in the previous response:

curl -v -d '{"legs":[{"id":1,"amount":10.0},{"id":2,"amount":-10.0},{"id":3,"amount":15.0},{"id":4,"amount":-15.0}]}' -H "Content-Type:application/json" -X POST http://localhost:8090/transfer/07f18b6d-9bfd-4a38-af0e-781f21963fcf | jq

If all goes well, expect a 201 in return:

HTTP/1.1 201 Created
Date: Sun, 16 Oct 2022 14:58:38 GMT
POE-Link: 07f18b6d-9bfd-4a38-af0e-781f21963fcf
Content-Type: application/prs.hal-forms+json
Transfer-Encoding: chunked

In the response, you will find a POE-Link header which represents the UUID token used as idempotency key:

POE-Link: 07f18b6d-9bfd-4a38-af0e-781f21963fcf

The rest of the response is a resource representation of the transactions created as a result of the transfer (the side-effect). The generated token 07f18b6d-9bfd-4a38-af0e-781f21963fcf is considered consumed, so attempting to re-post the same request will return a 200 OK to signal deduplication:

HTTP/1.1 200 OK
Date: Sun, 16 Oct 2022 15:01:07 GMT
POE-Link: 07f18b6d-9bfd-4a38-af0e-781f21963fcf
Content-Type: application/prs.hal-forms+json
Transfer-Encoding: chunked

In addition, the same response used for the original request will be returned in the body.

The main drawback with this approach is that the tokens must be stored along with the response payloads, either with a retention period or indefinitely.

Implementation Notes

The demo application is a pretty typical Spring Boot application with a hypermedia/REST API.

It uses the following stack:

  • Spring Boot with Jetty
  • Spring Data JPA and Hibernate with:
    • Custom JSONB user type
  • Spring Hateoas
  • Flyway
  • CockroachDB with:
    • JSONB for storing response bodies
    • TTLs to expire POE tags

The schema used:

The key features of CockroachDB to support our idempotency implementation is when storing POE tags and responses in JSONB format. It's also leveraging the TTL feature to clean out tags after 5 minutes. Effectively this means the idempotency guarantee lasts for 5 minutes.

Conclusions

Idempotency is an important design property for REST APIs. We explored two implementation options for idempotent POST methods and demonstrated the pros and cons of each:

  • Conditional Requests
    • Pros: no token storage
    • Cons: read before write + hashing + signing
  • Post-once-exactly
    • Pros: generated idempotency key w/o client involvement, retention of response bodies
    • Cons: token and response storage

Did you find this article valuable?

Support Kai Niemi by becoming a sponsor. Any amount is appreciated!