mirror of
https://github.com/Dvorinka/Containr.git
synced 2026-10-28 07:41:27 +00:00
- Stable machine-readable error codes alongside the error message (UNAUTHENTICATED, INVALID_TOKEN, ADMIN_REQUIRED, SCOPE_INSUFFICIENT, IDEMPOTENCY_IN_PROGRESS); CLI renders "CODE: message". - Idempotency middleware on authed/admin groups: POST requests with an Idempotency-Key header replay the first response for 24h (Redis-backed, caller-scoped); concurrent duplicates get 409. X-Idempotent-Replay header marks replays. - Shared pagination helpers: ?cursor= (opaque, preferred) alongside legacy ?page= on projects and deployments lists; pagination meta includes next_cursor; invalid cursors fail soft to page 1. - CLI --idempotency-key flag for safe scripted retries. - OpenAPI updated: error envelope, cursor params, idempotency contract. Generated with [Devin](https://devin.ai) Co-Authored-By: Devin <158243242+devin-ai-integration[bot]@users.noreply.github.com>
133 lines
3.4 KiB
Go
133 lines
3.4 KiB
Go
package middleware
|
|
|
|
import (
|
|
"bytes"
|
|
"context"
|
|
"crypto/sha256"
|
|
"encoding/hex"
|
|
"encoding/json"
|
|
"net/http"
|
|
"time"
|
|
|
|
"containr/internal/database"
|
|
|
|
"github.com/gin-gonic/gin"
|
|
"github.com/go-redis/redis/v8"
|
|
)
|
|
|
|
// Idempotency replays POST responses for a repeated Idempotency-Key header
|
|
// so agents can safely retry mutations. The first request wins; concurrent
|
|
// duplicates get 409 IDEMPOTENCY_IN_PROGRESS; completed responses replay
|
|
// verbatim for 24h. Keys are scoped to the authenticated identity.
|
|
func Idempotency() gin.HandlerFunc {
|
|
return func(c *gin.Context) {
|
|
if c.Request.Method != http.MethodPost {
|
|
c.Next()
|
|
return
|
|
}
|
|
key := c.GetHeader("Idempotency-Key")
|
|
if key == "" || len(key) > 128 {
|
|
c.Next()
|
|
return
|
|
}
|
|
dbValue, exists := c.Get("redis")
|
|
if !exists {
|
|
c.Next()
|
|
return
|
|
}
|
|
rdb, ok := dbValue.(*database.Redis)
|
|
if !ok || rdb == nil || rdb.Client == nil {
|
|
c.Next()
|
|
return
|
|
}
|
|
|
|
// Scope by caller identity so a stolen key can't replay across users.
|
|
actor := c.GetString("user_id")
|
|
if actor == "" {
|
|
actor = c.GetHeader("Authorization")
|
|
}
|
|
sum := sha256.Sum256([]byte(actor + ":" + c.Request.URL.Path + ":" + key))
|
|
redisKey := "idem:" + hex.EncodeToString(sum[:16])
|
|
|
|
ctx, cancel := context.WithTimeout(c.Request.Context(), 3*time.Second)
|
|
defer cancel()
|
|
|
|
stored, err := rdb.Client.Get(ctx, redisKey).Result()
|
|
switch {
|
|
case err == nil:
|
|
if stored == "processing" {
|
|
c.JSON(http.StatusConflict, gin.H{
|
|
"error": "a request with this idempotency key is still in progress",
|
|
"code": "IDEMPOTENCY_IN_PROGRESS",
|
|
})
|
|
c.Abort()
|
|
return
|
|
}
|
|
var rec idemRecord
|
|
if json.Unmarshal([]byte(stored), &rec) == nil {
|
|
c.Header("X-Idempotent-Replay", "true")
|
|
c.Data(rec.Status, rec.ContentType, rec.Body)
|
|
c.Abort()
|
|
return
|
|
}
|
|
case err != redis.Nil:
|
|
// Redis hiccup — degrade to pass-through rather than failing the request.
|
|
c.Next()
|
|
return
|
|
}
|
|
|
|
// Claim the key for the duration of the request.
|
|
if set, serr := rdb.Client.SetNX(ctx, redisKey, "processing", 2*time.Minute).Result(); serr != nil || !set {
|
|
c.JSON(http.StatusConflict, gin.H{
|
|
"error": "a request with this idempotency key is still in progress",
|
|
"code": "IDEMPOTENCY_IN_PROGRESS",
|
|
})
|
|
c.Abort()
|
|
return
|
|
}
|
|
|
|
recorder := &responseRecorder{ResponseWriter: c.Writer, status: http.StatusOK, body: &bytes.Buffer{}}
|
|
c.Writer = recorder
|
|
c.Next()
|
|
|
|
// Cache terminal responses only — 5xx is retryable and must not be replayed.
|
|
if recorder.status < 500 && recorder.body.Len() <= 1<<20 {
|
|
rec := idemRecord{
|
|
Status: recorder.status,
|
|
ContentType: recorder.Header().Get("Content-Type"),
|
|
Body: recorder.body.Bytes(),
|
|
}
|
|
if rec.ContentType == "" {
|
|
rec.ContentType = "application/json; charset=utf-8"
|
|
}
|
|
if raw, merr := json.Marshal(rec); merr == nil {
|
|
_ = rdb.Client.Set(context.Background(), redisKey, raw, 24*time.Hour).Err()
|
|
}
|
|
} else {
|
|
_ = rdb.Client.Del(context.Background(), redisKey).Err()
|
|
}
|
|
}
|
|
}
|
|
|
|
type idemRecord struct {
|
|
Status int `json:"s"`
|
|
ContentType string `json:"ct"`
|
|
Body []byte `json:"b"`
|
|
}
|
|
|
|
type responseRecorder struct {
|
|
gin.ResponseWriter
|
|
status int
|
|
body *bytes.Buffer
|
|
}
|
|
|
|
func (r *responseRecorder) WriteHeader(code int) {
|
|
r.status = code
|
|
r.ResponseWriter.WriteHeader(code)
|
|
}
|
|
|
|
func (r *responseRecorder) Write(b []byte) (int, error) {
|
|
r.body.Write(b)
|
|
return r.ResponseWriter.Write(b)
|
|
}
|