idempotency.go 17 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482
  1. package service
  2. import (
  3. "context"
  4. "crypto/sha256"
  5. "encoding/hex"
  6. "encoding/json"
  7. "errors"
  8. "fmt"
  9. "strconv"
  10. "strings"
  11. "sync"
  12. "time"
  13. "unicode/utf8"
  14. infraerrors "github.com/Wei-Shaw/sub2api/internal/pkg/errors"
  15. "github.com/Wei-Shaw/sub2api/internal/util/logredact"
  16. )
  17. const (
  18. IdempotencyStatusProcessing = "processing"
  19. IdempotencyStatusSucceeded = "succeeded"
  20. IdempotencyStatusFailedRetryable = "failed_retryable"
  21. )
  22. var (
  23. ErrIdempotencyKeyRequired = infraerrors.BadRequest("IDEMPOTENCY_KEY_REQUIRED", "idempotency key is required")
  24. ErrIdempotencyKeyInvalid = infraerrors.BadRequest("IDEMPOTENCY_KEY_INVALID", "idempotency key is invalid")
  25. ErrIdempotencyKeyConflict = infraerrors.Conflict("IDEMPOTENCY_KEY_CONFLICT", "idempotency key reused with different payload")
  26. ErrIdempotencyInProgress = infraerrors.Conflict("IDEMPOTENCY_IN_PROGRESS", "idempotent request is still processing")
  27. ErrIdempotencyRetryBackoff = infraerrors.Conflict("IDEMPOTENCY_RETRY_BACKOFF", "idempotent request is in retry backoff window")
  28. ErrIdempotencyStoreUnavail = infraerrors.ServiceUnavailable("IDEMPOTENCY_STORE_UNAVAILABLE", "idempotency store unavailable")
  29. ErrIdempotencyInvalidPayload = infraerrors.BadRequest("IDEMPOTENCY_PAYLOAD_INVALID", "failed to normalize request payload")
  30. )
  31. type IdempotencyRecord struct {
  32. ID int64
  33. Scope string
  34. IdempotencyKeyHash string
  35. RequestFingerprint string
  36. Status string
  37. ResponseStatus *int
  38. ResponseBody *string
  39. ErrorReason *string
  40. LockedUntil *time.Time
  41. ExpiresAt time.Time
  42. CreatedAt time.Time
  43. UpdatedAt time.Time
  44. }
  45. type IdempotencyRepository interface {
  46. CreateProcessing(ctx context.Context, record *IdempotencyRecord) (bool, error)
  47. GetByScopeAndKeyHash(ctx context.Context, scope, keyHash string) (*IdempotencyRecord, error)
  48. TryReclaim(ctx context.Context, id int64, fromStatus string, now, newLockedUntil, newExpiresAt time.Time) (bool, error)
  49. ExtendProcessingLock(ctx context.Context, id int64, requestFingerprint string, newLockedUntil, newExpiresAt time.Time) (bool, error)
  50. MarkSucceeded(ctx context.Context, id int64, responseStatus int, responseBody string, expiresAt time.Time) error
  51. MarkFailedRetryable(ctx context.Context, id int64, errorReason string, lockedUntil, expiresAt time.Time) error
  52. DeleteExpired(ctx context.Context, now time.Time, limit int) (int64, error)
  53. }
  54. type IdempotencyConfig struct {
  55. DefaultTTL time.Duration
  56. SystemOperationTTL time.Duration
  57. ProcessingTimeout time.Duration
  58. FailedRetryBackoff time.Duration
  59. MaxStoredResponseLen int
  60. ObserveOnly bool
  61. }
  62. func DefaultIdempotencyConfig() IdempotencyConfig {
  63. return IdempotencyConfig{
  64. DefaultTTL: 24 * time.Hour,
  65. SystemOperationTTL: 1 * time.Hour,
  66. ProcessingTimeout: 30 * time.Second,
  67. FailedRetryBackoff: 5 * time.Second,
  68. MaxStoredResponseLen: 64 * 1024,
  69. ObserveOnly: true, // 默认先观察再强制,避免老客户端立刻中断
  70. }
  71. }
  72. type IdempotencyExecuteOptions struct {
  73. Scope string
  74. ActorScope string
  75. Method string
  76. Route string
  77. IdempotencyKey string
  78. Payload any
  79. TTL time.Duration
  80. RequireKey bool
  81. }
  82. type IdempotencyExecuteResult struct {
  83. Data any
  84. Replayed bool
  85. }
  86. type IdempotencyCoordinator struct {
  87. repo IdempotencyRepository
  88. cfg IdempotencyConfig
  89. }
  90. var (
  91. defaultIdempotencyMu sync.RWMutex
  92. defaultIdempotencySvc *IdempotencyCoordinator
  93. )
  94. func SetDefaultIdempotencyCoordinator(svc *IdempotencyCoordinator) {
  95. defaultIdempotencyMu.Lock()
  96. defaultIdempotencySvc = svc
  97. defaultIdempotencyMu.Unlock()
  98. }
  99. func DefaultIdempotencyCoordinator() *IdempotencyCoordinator {
  100. defaultIdempotencyMu.RLock()
  101. defer defaultIdempotencyMu.RUnlock()
  102. return defaultIdempotencySvc
  103. }
  104. func DefaultWriteIdempotencyTTL() time.Duration {
  105. defaultTTL := DefaultIdempotencyConfig().DefaultTTL
  106. if coordinator := DefaultIdempotencyCoordinator(); coordinator != nil && coordinator.cfg.DefaultTTL > 0 {
  107. return coordinator.cfg.DefaultTTL
  108. }
  109. return defaultTTL
  110. }
  111. func DefaultSystemOperationIdempotencyTTL() time.Duration {
  112. defaultTTL := DefaultIdempotencyConfig().SystemOperationTTL
  113. if coordinator := DefaultIdempotencyCoordinator(); coordinator != nil && coordinator.cfg.SystemOperationTTL > 0 {
  114. return coordinator.cfg.SystemOperationTTL
  115. }
  116. return defaultTTL
  117. }
  118. func NewIdempotencyCoordinator(repo IdempotencyRepository, cfg IdempotencyConfig) *IdempotencyCoordinator {
  119. return &IdempotencyCoordinator{
  120. repo: repo,
  121. cfg: cfg,
  122. }
  123. }
  124. func NormalizeIdempotencyKey(raw string) (string, error) {
  125. key := strings.TrimSpace(raw)
  126. if key == "" {
  127. return "", nil
  128. }
  129. if len(key) > 128 {
  130. return "", ErrIdempotencyKeyInvalid
  131. }
  132. for _, r := range key {
  133. if r < 33 || r > 126 {
  134. return "", ErrIdempotencyKeyInvalid
  135. }
  136. }
  137. return key, nil
  138. }
  139. func HashIdempotencyKey(key string) string {
  140. sum := sha256.Sum256([]byte(key))
  141. return hex.EncodeToString(sum[:])
  142. }
  143. func BuildIdempotencyFingerprint(method, route, actorScope string, payload any) (string, error) {
  144. if method == "" {
  145. method = "POST"
  146. }
  147. if route == "" {
  148. route = "/"
  149. }
  150. if actorScope == "" {
  151. actorScope = "anonymous"
  152. }
  153. raw, err := json.Marshal(payload)
  154. if err != nil {
  155. return "", ErrIdempotencyInvalidPayload.WithCause(err)
  156. }
  157. sum := sha256.Sum256([]byte(
  158. strings.ToUpper(method) + "\n" + route + "\n" + actorScope + "\n" + string(raw),
  159. ))
  160. return hex.EncodeToString(sum[:]), nil
  161. }
  162. func RetryAfterSecondsFromError(err error) int {
  163. appErr := new(infraerrors.ApplicationError)
  164. if !errors.As(err, &appErr) || appErr == nil || appErr.Metadata == nil {
  165. return 0
  166. }
  167. v := strings.TrimSpace(appErr.Metadata["retry_after"])
  168. if v == "" {
  169. return 0
  170. }
  171. seconds, convErr := strconv.Atoi(v)
  172. if convErr != nil || seconds <= 0 {
  173. return 0
  174. }
  175. return seconds
  176. }
  177. func (c *IdempotencyCoordinator) Execute(
  178. ctx context.Context,
  179. opts IdempotencyExecuteOptions,
  180. execute func(context.Context) (any, error),
  181. ) (*IdempotencyExecuteResult, error) {
  182. if execute == nil {
  183. return nil, infraerrors.InternalServer("IDEMPOTENCY_EXECUTOR_NIL", "idempotency executor is nil")
  184. }
  185. key, err := NormalizeIdempotencyKey(opts.IdempotencyKey)
  186. if err != nil {
  187. return nil, err
  188. }
  189. if key == "" {
  190. if opts.RequireKey && !c.cfg.ObserveOnly {
  191. return nil, ErrIdempotencyKeyRequired
  192. }
  193. data, execErr := execute(ctx)
  194. if execErr != nil {
  195. return nil, execErr
  196. }
  197. return &IdempotencyExecuteResult{Data: data}, nil
  198. }
  199. if c.repo == nil {
  200. RecordIdempotencyStoreUnavailable(opts.Route, opts.Scope, "repo_nil")
  201. return nil, ErrIdempotencyStoreUnavail
  202. }
  203. if opts.Scope == "" {
  204. return nil, infraerrors.BadRequest("IDEMPOTENCY_SCOPE_REQUIRED", "idempotency scope is required")
  205. }
  206. fingerprint, err := BuildIdempotencyFingerprint(opts.Method, opts.Route, opts.ActorScope, opts.Payload)
  207. if err != nil {
  208. return nil, err
  209. }
  210. ttl := opts.TTL
  211. if ttl <= 0 {
  212. ttl = c.cfg.DefaultTTL
  213. }
  214. now := time.Now()
  215. expiresAt := now.Add(ttl)
  216. lockedUntil := now.Add(c.cfg.ProcessingTimeout)
  217. keyHash := HashIdempotencyKey(key)
  218. record := &IdempotencyRecord{
  219. Scope: opts.Scope,
  220. IdempotencyKeyHash: keyHash,
  221. RequestFingerprint: fingerprint,
  222. Status: IdempotencyStatusProcessing,
  223. LockedUntil: &lockedUntil,
  224. ExpiresAt: expiresAt,
  225. }
  226. owner, err := c.repo.CreateProcessing(ctx, record)
  227. if err != nil {
  228. RecordIdempotencyStoreUnavailable(opts.Route, opts.Scope, "create_processing_error")
  229. logIdempotencyAudit(opts.Route, opts.Scope, keyHash, "unknown->store_unavailable", false, map[string]string{
  230. "operation": "create_processing",
  231. })
  232. return nil, ErrIdempotencyStoreUnavail.WithCause(err)
  233. }
  234. if owner {
  235. recordIdempotencyClaim(opts.Route, opts.Scope, map[string]string{"mode": "new_claim"})
  236. logIdempotencyAudit(opts.Route, opts.Scope, keyHash, "none->processing", false, map[string]string{
  237. "claim_mode": "new",
  238. })
  239. }
  240. if !owner {
  241. existing, getErr := c.repo.GetByScopeAndKeyHash(ctx, opts.Scope, keyHash)
  242. if getErr != nil {
  243. RecordIdempotencyStoreUnavailable(opts.Route, opts.Scope, "get_existing_error")
  244. logIdempotencyAudit(opts.Route, opts.Scope, keyHash, "unknown->store_unavailable", false, map[string]string{
  245. "operation": "get_existing",
  246. })
  247. return nil, ErrIdempotencyStoreUnavail.WithCause(getErr)
  248. }
  249. if existing == nil {
  250. RecordIdempotencyStoreUnavailable(opts.Route, opts.Scope, "missing_existing")
  251. logIdempotencyAudit(opts.Route, opts.Scope, keyHash, "unknown->store_unavailable", false, map[string]string{
  252. "operation": "missing_existing",
  253. })
  254. return nil, ErrIdempotencyStoreUnavail
  255. }
  256. if existing.RequestFingerprint != fingerprint {
  257. recordIdempotencyConflict(opts.Route, opts.Scope, map[string]string{"reason": "fingerprint_mismatch"})
  258. logIdempotencyAudit(opts.Route, opts.Scope, keyHash, "existing->fingerprint_mismatch", false, nil)
  259. return nil, ErrIdempotencyKeyConflict
  260. }
  261. reclaimedByExpired := false
  262. if !existing.ExpiresAt.After(now) {
  263. taken, reclaimErr := c.repo.TryReclaim(ctx, existing.ID, existing.Status, now, lockedUntil, expiresAt)
  264. if reclaimErr != nil {
  265. RecordIdempotencyStoreUnavailable(opts.Route, opts.Scope, "try_reclaim_expired_error")
  266. logIdempotencyAudit(opts.Route, opts.Scope, keyHash, existing.Status+"->store_unavailable", false, map[string]string{
  267. "operation": "try_reclaim_expired",
  268. })
  269. return nil, ErrIdempotencyStoreUnavail.WithCause(reclaimErr)
  270. }
  271. if taken {
  272. reclaimedByExpired = true
  273. recordIdempotencyClaim(opts.Route, opts.Scope, map[string]string{"mode": "expired_reclaim"})
  274. logIdempotencyAudit(opts.Route, opts.Scope, keyHash, existing.Status+"->processing", false, map[string]string{
  275. "claim_mode": "expired_reclaim",
  276. })
  277. record.ID = existing.ID
  278. } else {
  279. latest, latestErr := c.repo.GetByScopeAndKeyHash(ctx, opts.Scope, keyHash)
  280. if latestErr != nil {
  281. RecordIdempotencyStoreUnavailable(opts.Route, opts.Scope, "get_existing_after_expired_reclaim_error")
  282. logIdempotencyAudit(opts.Route, opts.Scope, keyHash, "unknown->store_unavailable", false, map[string]string{
  283. "operation": "get_existing_after_expired_reclaim",
  284. })
  285. return nil, ErrIdempotencyStoreUnavail.WithCause(latestErr)
  286. }
  287. if latest == nil {
  288. RecordIdempotencyStoreUnavailable(opts.Route, opts.Scope, "missing_existing_after_expired_reclaim")
  289. logIdempotencyAudit(opts.Route, opts.Scope, keyHash, "unknown->store_unavailable", false, map[string]string{
  290. "operation": "missing_existing_after_expired_reclaim",
  291. })
  292. return nil, ErrIdempotencyStoreUnavail
  293. }
  294. if latest.RequestFingerprint != fingerprint {
  295. recordIdempotencyConflict(opts.Route, opts.Scope, map[string]string{"reason": "fingerprint_mismatch"})
  296. logIdempotencyAudit(opts.Route, opts.Scope, keyHash, "existing->fingerprint_mismatch", false, nil)
  297. return nil, ErrIdempotencyKeyConflict
  298. }
  299. existing = latest
  300. }
  301. }
  302. if !reclaimedByExpired {
  303. switch existing.Status {
  304. case IdempotencyStatusSucceeded:
  305. data, parseErr := c.decodeStoredResponse(existing.ResponseBody)
  306. if parseErr != nil {
  307. RecordIdempotencyStoreUnavailable(opts.Route, opts.Scope, "decode_stored_response_error")
  308. logIdempotencyAudit(opts.Route, opts.Scope, keyHash, "succeeded->store_unavailable", false, map[string]string{
  309. "operation": "decode_stored_response",
  310. })
  311. return nil, ErrIdempotencyStoreUnavail.WithCause(parseErr)
  312. }
  313. recordIdempotencyReplay(opts.Route, opts.Scope, nil)
  314. logIdempotencyAudit(opts.Route, opts.Scope, keyHash, "succeeded->replayed", true, nil)
  315. return &IdempotencyExecuteResult{Data: data, Replayed: true}, nil
  316. case IdempotencyStatusProcessing:
  317. recordIdempotencyConflict(opts.Route, opts.Scope, map[string]string{"reason": "in_progress"})
  318. logIdempotencyAudit(opts.Route, opts.Scope, keyHash, "processing->conflict", false, nil)
  319. return nil, c.conflictWithRetryAfter(ErrIdempotencyInProgress, existing.LockedUntil, now)
  320. case IdempotencyStatusFailedRetryable:
  321. if existing.LockedUntil != nil && existing.LockedUntil.After(now) {
  322. recordIdempotencyConflict(opts.Route, opts.Scope, map[string]string{"reason": "retry_backoff"})
  323. recordIdempotencyRetryBackoff(opts.Route, opts.Scope, nil)
  324. logIdempotencyAudit(opts.Route, opts.Scope, keyHash, "failed_retryable->retry_backoff_conflict", false, nil)
  325. return nil, c.conflictWithRetryAfter(ErrIdempotencyRetryBackoff, existing.LockedUntil, now)
  326. }
  327. taken, reclaimErr := c.repo.TryReclaim(ctx, existing.ID, IdempotencyStatusFailedRetryable, now, lockedUntil, expiresAt)
  328. if reclaimErr != nil {
  329. RecordIdempotencyStoreUnavailable(opts.Route, opts.Scope, "try_reclaim_error")
  330. logIdempotencyAudit(opts.Route, opts.Scope, keyHash, "failed_retryable->store_unavailable", false, map[string]string{
  331. "operation": "try_reclaim",
  332. })
  333. return nil, ErrIdempotencyStoreUnavail.WithCause(reclaimErr)
  334. }
  335. if !taken {
  336. recordIdempotencyConflict(opts.Route, opts.Scope, map[string]string{"reason": "reclaim_race"})
  337. logIdempotencyAudit(opts.Route, opts.Scope, keyHash, "failed_retryable->conflict", false, map[string]string{
  338. "conflict": "reclaim_race",
  339. })
  340. return nil, c.conflictWithRetryAfter(ErrIdempotencyInProgress, existing.LockedUntil, now)
  341. }
  342. recordIdempotencyClaim(opts.Route, opts.Scope, map[string]string{"mode": "reclaim"})
  343. logIdempotencyAudit(opts.Route, opts.Scope, keyHash, "failed_retryable->processing", false, map[string]string{
  344. "claim_mode": "reclaim",
  345. })
  346. record.ID = existing.ID
  347. default:
  348. recordIdempotencyConflict(opts.Route, opts.Scope, map[string]string{"reason": "unexpected_status"})
  349. logIdempotencyAudit(opts.Route, opts.Scope, keyHash, "existing->conflict", false, map[string]string{
  350. "status": existing.Status,
  351. })
  352. return nil, ErrIdempotencyKeyConflict
  353. }
  354. }
  355. }
  356. if record.ID == 0 {
  357. RecordIdempotencyStoreUnavailable(opts.Route, opts.Scope, "record_id_missing")
  358. logIdempotencyAudit(opts.Route, opts.Scope, keyHash, "processing->store_unavailable", false, map[string]string{
  359. "operation": "record_id_missing",
  360. })
  361. return nil, ErrIdempotencyStoreUnavail
  362. }
  363. execStart := time.Now()
  364. defer func() {
  365. recordIdempotencyProcessingDuration(opts.Route, opts.Scope, time.Since(execStart), nil)
  366. }()
  367. data, execErr := execute(ctx)
  368. if execErr != nil {
  369. backoffUntil := time.Now().Add(c.cfg.FailedRetryBackoff)
  370. reason := infraerrors.Reason(execErr)
  371. if reason == "" {
  372. reason = "EXECUTION_FAILED"
  373. }
  374. recordIdempotencyRetryBackoff(opts.Route, opts.Scope, nil)
  375. logIdempotencyAudit(opts.Route, opts.Scope, keyHash, "processing->failed_retryable", false, map[string]string{
  376. "reason": reason,
  377. })
  378. if markErr := c.repo.MarkFailedRetryable(ctx, record.ID, reason, backoffUntil, expiresAt); markErr != nil {
  379. RecordIdempotencyStoreUnavailable(opts.Route, opts.Scope, "mark_failed_retryable_error")
  380. logIdempotencyAudit(opts.Route, opts.Scope, keyHash, "processing->store_unavailable", false, map[string]string{
  381. "operation": "mark_failed_retryable",
  382. })
  383. }
  384. return nil, execErr
  385. }
  386. storedBody, marshalErr := c.marshalStoredResponse(data)
  387. if marshalErr != nil {
  388. RecordIdempotencyStoreUnavailable(opts.Route, opts.Scope, "marshal_response_error")
  389. logIdempotencyAudit(opts.Route, opts.Scope, keyHash, "processing->store_unavailable", false, map[string]string{
  390. "operation": "marshal_response",
  391. })
  392. return nil, ErrIdempotencyStoreUnavail.WithCause(marshalErr)
  393. }
  394. if markErr := c.repo.MarkSucceeded(ctx, record.ID, 200, storedBody, expiresAt); markErr != nil {
  395. RecordIdempotencyStoreUnavailable(opts.Route, opts.Scope, "mark_succeeded_error")
  396. logIdempotencyAudit(opts.Route, opts.Scope, keyHash, "processing->store_unavailable", false, map[string]string{
  397. "operation": "mark_succeeded",
  398. })
  399. return nil, ErrIdempotencyStoreUnavail.WithCause(markErr)
  400. }
  401. logIdempotencyAudit(opts.Route, opts.Scope, keyHash, "processing->succeeded", false, nil)
  402. return &IdempotencyExecuteResult{Data: data}, nil
  403. }
  404. func (c *IdempotencyCoordinator) conflictWithRetryAfter(base *infraerrors.ApplicationError, lockedUntil *time.Time, now time.Time) error {
  405. if lockedUntil == nil {
  406. return base
  407. }
  408. sec := int(lockedUntil.Sub(now).Seconds())
  409. if sec <= 0 {
  410. sec = 1
  411. }
  412. return base.WithMetadata(map[string]string{"retry_after": strconv.Itoa(sec)})
  413. }
  414. func (c *IdempotencyCoordinator) marshalStoredResponse(data any) (string, error) {
  415. raw, err := json.Marshal(data)
  416. if err != nil {
  417. return "", err
  418. }
  419. redacted := logredact.RedactText(string(raw))
  420. if c.cfg.MaxStoredResponseLen > 0 && len(redacted) > c.cfg.MaxStoredResponseLen {
  421. redacted = truncateUTF8(redacted, c.cfg.MaxStoredResponseLen) + "...(truncated)"
  422. }
  423. return redacted, nil
  424. }
  425. func truncateUTF8(s string, maxBytes int) string {
  426. if maxBytes <= 0 || len(s) <= maxBytes {
  427. return s
  428. }
  429. for maxBytes > 0 && !utf8.ValidString(s[:maxBytes]) {
  430. maxBytes--
  431. }
  432. return s[:maxBytes]
  433. }
  434. func (c *IdempotencyCoordinator) decodeStoredResponse(stored *string) (any, error) {
  435. if stored == nil || strings.TrimSpace(*stored) == "" {
  436. return map[string]any{}, nil
  437. }
  438. var out any
  439. if err := json.Unmarshal([]byte(*stored), &out); err != nil {
  440. return nil, fmt.Errorf("decode stored response: %w", err)
  441. }
  442. return out, nil
  443. }