go
74 lines · 10 steps
Idempotent requests in Gin with Redis
A Gin middleware that replays cached responses for repeated Idempotency-Key headers and locks concurrent duplicates.
Explained by
highlit
1package middleware
2
3import (
4 "bytes"
5 "encoding/json"
6 "net/http"
7 "time"
8
9 "github.com/gin-gonic/gin"
10 "github.com/redis/go-redis/v9"
11)
12
13type cachedResponse struct {
14 Status int `json:"status"`
15 Headers map[string][]string `json:"headers"`
16 Body []byte `json:"body"`
17}
18
19type bodyWriter struct {
20 gin.ResponseWriter
21 buf *bytes.Buffer
22}
23
24func (w *bodyWriter) Write(b []byte) (int, error) {
25 w.buf.Write(b)
26 return w.ResponseWriter.Write(b)
27}
28
29func Idempotency(rdb *redis.Client, ttl time.Duration) gin.HandlerFunc {
30 return func(c *gin.Context) {
31 key := c.GetHeader("Idempotency-Key")
32 if key == "" {
33 c.AbortWithStatusJSON(http.StatusBadRequest, gin.H{"error": "Idempotency-Key header required"})
34 return
35 }
36
37 cacheKey := "idem:" + c.FullPath() + ":" + key
38 ctx := c.Request.Context()
39
40 if raw, err := rdb.Get(ctx, cacheKey).Bytes(); err == nil {
41 var cached cachedResponse
42 if json.Unmarshal(raw, &cached) == nil {
43 for h, vals := range cached.Headers {
44 for _, v := range vals {
45 c.Writer.Header().Add(h, v)
46 }
47 }
48 c.Writer.Header().Set("Idempotent-Replay", "true")
49 c.Data(cached.Status, "application/json", cached.Body)
50 c.Abort()
51 return
52 }
53 }
54
55 if ok, _ := rdb.SetNX(ctx, cacheKey+":lock", "1", 30*time.Second).Result(); !ok {
56 c.AbortWithStatusJSON(http.StatusConflict, gin.H{"error": "request with this Idempotency-Key is in progress"})
57 return
58 }
59
60 bw := &bodyWriter{ResponseWriter: c.Writer, buf: &bytes.Buffer{}}
61 c.Writer = bw
62 c.Next()
63
64 if c.Writer.Status() >= 200 && c.Writer.Status() < 300 {
65 payload, _ := json.Marshal(cachedResponse{
66 Status: c.Writer.Status(),
67 Headers: c.Writer.Header(),
68 Body: bw.buf.Bytes(),
69 })
70 rdb.Set(ctx, cacheKey, payload, ttl)
71 }
72 rdb.Del(ctx, cacheKey+":lock")
73 }
74}
01 / 01
STEP 01
‹ swipe to step through ›
Walkthrough
Space play
←→ step
click any line
Three takeaways
- 1Storing the full response — status, headers, and body — lets you replay a prior result byte-for-byte on retries.
- 2A short-lived SetNX lock turns two in-flight duplicates into a clean 409 instead of a double side effect.
- 3Wrapping the ResponseWriter is how you capture a handler's output without changing the handler itself.
Related explainers
javascript
const ROLE_PERMISSIONS = { admin: ['users:read', 'users:write', 'billing:read', 'billing:write'], manager: ['users:read', 'billing:read'], member: ['users:read'],
Role-based permissions middleware in Express
authorization
middleware
rbac
Intermediate
9 steps
rust
use axum::{extract::{Path, State}, http::StatusCode, Json}; use dashmap::DashMap; use serde::Serialize; use std::sync::Arc;
Request coalescing in an Axum handler
caching
concurrency
request-coalescing
Advanced
8 steps
go
package handlers import ( "net/http"
Serving embedded static meta files in Gin
embedding
static assets
http caching
Intermediate
6 steps
go
package config import ( "fmt"
A thread-safe config singleton in Go
singleton
concurrency
environment-variables
Intermediate
7 steps
javascript
const express = require('express'); const app = express(); app.get('/health', (req, res) => res.json({ status: 'ok' }));
Graceful shutdown in an Express server
graceful-shutdown
signal-handling
connection-tracking
Advanced
9 steps
php
<?php namespace App\Http\Middleware;
Resolving the current team in Laravel middleware
middleware
multi-tenancy
cookies
Intermediate
8 steps
Share this explainer
Here's the card — post it anywhere.
Made with highlit — turn any snippet into a walkthrough like this in about a minute.
Explain your code
Embed this explainer
Drop the interactive walkthrough into a blog or docs. Views never cost a credit.
<iframe src="https://highlit.co/explainers/idempotent-requests-in-gin-with-redis-explained-go-e7df/embed?autoplay=1" width="100%" height="520" loading="lazy" style="border:0"></iframe>
Autoplay is on by default — add ?autoplay=0 to start paused.