Hit Me Again: Building Idempotent Endpoints for Reliable APIs
Imagine you’re at a restaurant and you order a hamburger and fries. After some time passes, you ask the waiter to check on your order because you’re hungry! The waiter heads back to the kitchen to inquire. Now let’s explore two scenarios:
In one restaurant, telling the kitchen your order again means they’ll make you a whole new hamburger and fries, even if it’s already being cooked! So you could end up with multiple unwanted burgers if you check on your order more than once. This is like a non-idempotent API that duplicates effects if called multiple times.
But in an idempotent restaurant, the kitchen keeps track of orders already in progress. So no matter how many times the waiter asks, the chef will still only make one hamburger and fries for your table. Checking on your order doesn’t change the end result. This is similar to an idempotent API that avoids side effects from duplicate requests.
So idempotency is like the reliable restaurant where you can ask about your food as many times as you want, but you’ll only get one order! The key is that idempotent systems remember previous calls and remain consistent no matter how often they are queried.
Idempotence is an important design consideration for resilient REST APIs. It accounts for the fact that API consumers may inadvertently send duplicate requests, such as due to network errors or retries.
HTTP methods (GET | HEAD | PUT | DELETE) are idempotent by nature because they don’t change the application’s state if called multiple times.
- GET / HEAD retrieving data
- PUT replaces the whole object, the first request will update the resource, and the rest n-1 requests will have no effect.
- DELETE delete object, the first request will delete the resource, and rest n-1 requests have no effect.
This leaves us with the non-idempotent methods (POST | PATCH)
- POST it is used for creating resource, multiple requests means create multiple resources
- PATCH can also be non-idempotent if it performs partial modifications.
In summary, POST and PATCH requests require special handling to implement idempotency, since they are not natively idempotent methods.
Moving forward, we will explore recommended strategies for achieving idempotency with non-idempotent operations like POST and PATCH. This includes techniques such as unique request IDs, idempotency keys, response caching, and business logic checks.
Implementing idempotency typically involves some form of storage or caching layer to track request details for validation.
- Unique Request IDs: Generate a unique ID for every request and pass it with the request. The API can check if a request with that ID already exists before processing, avoiding duplicates.
POST /users
Content-Type: application/json
X-Request-Id: 123e4567-e89b-12d3-a456-426614174000
{
"name": "John",
"email": "john@email.com"
}
- Idempotency Keys: Allow the client to generate a key that uniquely identifies a request. The server stores the request details mapped to this key. Subsequent requests with the same key are skipped as duplicates.
POST /charge
Content-Type: application/json
Idempotency-Key: 1234
{
"amount": 100,
"card_number": "4242-4242-4242-4242"
}
Handling Idempotency is interesting:
1- You can return error code starting from request 2 to request n.
2- You can cache the response and return it to the caller.
It is a design decision
Here is a typical request for the first time using a Unique Request ID
For the second time, you just retrieve the response from the cache without more additional computation and side effects.
I’ll write a golang example mocking the caching layer, for simplicity, we will remove the key after 5 minutes and we should get the same response every time.
package main
import (
"sync"
"fmt"
"time"
)
// Cache struct with mutex lock and response storage
type Cache struct {
m map[string]string // store response
mutex sync.Mutex
}
// factory initializes cache
func NewCache() *Cache {
return &Cache{
m: make(map[string]string),
}
}
// Put key and response in cache
func (c *Cache) Put(key, response string) {
c.mutex.Lock()
defer c.mutex.Unlock()
c.m[key] = response
go c.clearCache(key)
}
// Get cached response
func (c *Cache) Get(key string) (string, bool) {
c.mutex.Lock()
defer c.mutex.Unlock()
response, exists := c.m[key]
return response, exists
}
func (c *Cache) clearCache( key string) {
// Sleep for expiry duration
time.Sleep(5 * time.Minute)
// Delete key
c.mutex.Lock()
delete(c.m, key)
c.mutex.Unlock()
fmt.Println("Key expired and deleted")
}
Here is the handler function mocking the HTTP handler
// Handle request
func handleRequest(c *Cache, key string) string {
// Check cache
if response, exists := c.Get(key); exists {
fmt.Println("Cache hit")
return response
}
// Cache miss, process request
fmt.Println("Cache miss, processing request")
// Add response to cache
response := "Response content"
c.Put(key, response)
return response
}
Finally here is the main mocking the HTTP server
func runForEver(){
time.Sleep(10 * time.Minute)
}
func main() {
cache := NewCache()
var wg sync.WaitGroup
wg.Add(1)
go func() {
runForEver()
wg.Done()
}()
handleRequest(cache, "xyz")
handleRequest(cache, "xyz")
wg.Wait()
fmt.Println("Done")
}
the output of the program
Cache miss, processing request
Cache hit
In summary, idempotent APIs produce consistent, reliable behavior even when clients make mistakes and unintentional duplicates occur. This prevents downstream issues and makes integration much simpler.
If you found this information useful, I sincerely appreciate you taking a moment to hit the clap button and follow me to stay updated on future posts. Reader feedback is invaluable for helping guide and improve my technical writing. Until next time, happy engineering!