idempotency_test.go 28 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680681682683684685686687688689690691692693694695696697698699700701702703704705706707708709710711712713714715716717718719720721722723724725726727728729730731732733734735736737738739740741742743744745746747748749750751752753754755756757758759760761762763764765766767768769770771772773774775776777778779780781782783784785786787788789790791792793794795796797798799800801802803804805806807808809810811812813814815816817818819820821822823824825826827828829830831832833834835836837838839840841842843844845846847848849850851852
  1. package service
  2. import (
  3. "context"
  4. "errors"
  5. "strings"
  6. "sync"
  7. "sync/atomic"
  8. "testing"
  9. "time"
  10. "unicode/utf8"
  11. infraerrors "github.com/Wei-Shaw/sub2api/internal/pkg/errors"
  12. "github.com/stretchr/testify/require"
  13. )
  14. type inMemoryIdempotencyRepo struct {
  15. mu sync.Mutex
  16. nextID int64
  17. data map[string]*IdempotencyRecord
  18. }
  19. func newInMemoryIdempotencyRepo() *inMemoryIdempotencyRepo {
  20. return &inMemoryIdempotencyRepo{
  21. nextID: 1,
  22. data: make(map[string]*IdempotencyRecord),
  23. }
  24. }
  25. func (r *inMemoryIdempotencyRepo) key(scope, hash string) string {
  26. return scope + "|" + hash
  27. }
  28. func cloneRecord(in *IdempotencyRecord) *IdempotencyRecord {
  29. if in == nil {
  30. return nil
  31. }
  32. out := *in
  33. if in.ResponseStatus != nil {
  34. v := *in.ResponseStatus
  35. out.ResponseStatus = &v
  36. }
  37. if in.ResponseBody != nil {
  38. v := *in.ResponseBody
  39. out.ResponseBody = &v
  40. }
  41. if in.ErrorReason != nil {
  42. v := *in.ErrorReason
  43. out.ErrorReason = &v
  44. }
  45. if in.LockedUntil != nil {
  46. v := *in.LockedUntil
  47. out.LockedUntil = &v
  48. }
  49. return &out
  50. }
  51. func (r *inMemoryIdempotencyRepo) CreateProcessing(_ context.Context, record *IdempotencyRecord) (bool, error) {
  52. r.mu.Lock()
  53. defer r.mu.Unlock()
  54. k := r.key(record.Scope, record.IdempotencyKeyHash)
  55. if _, ok := r.data[k]; ok {
  56. return false, nil
  57. }
  58. rec := cloneRecord(record)
  59. rec.ID = r.nextID
  60. rec.CreatedAt = time.Now()
  61. rec.UpdatedAt = rec.CreatedAt
  62. r.nextID++
  63. r.data[k] = rec
  64. record.ID = rec.ID
  65. record.CreatedAt = rec.CreatedAt
  66. record.UpdatedAt = rec.UpdatedAt
  67. return true, nil
  68. }
  69. func (r *inMemoryIdempotencyRepo) GetByScopeAndKeyHash(_ context.Context, scope, keyHash string) (*IdempotencyRecord, error) {
  70. r.mu.Lock()
  71. defer r.mu.Unlock()
  72. return cloneRecord(r.data[r.key(scope, keyHash)]), nil
  73. }
  74. func (r *inMemoryIdempotencyRepo) TryReclaim(_ context.Context, id int64, fromStatus string, now, newLockedUntil, newExpiresAt time.Time) (bool, error) {
  75. r.mu.Lock()
  76. defer r.mu.Unlock()
  77. for _, rec := range r.data {
  78. if rec.ID != id {
  79. continue
  80. }
  81. if rec.Status != fromStatus {
  82. return false, nil
  83. }
  84. if rec.LockedUntil != nil && rec.LockedUntil.After(now) {
  85. return false, nil
  86. }
  87. rec.Status = IdempotencyStatusProcessing
  88. rec.LockedUntil = &newLockedUntil
  89. rec.ExpiresAt = newExpiresAt
  90. rec.ErrorReason = nil
  91. rec.UpdatedAt = time.Now()
  92. return true, nil
  93. }
  94. return false, nil
  95. }
  96. func (r *inMemoryIdempotencyRepo) ExtendProcessingLock(_ context.Context, id int64, requestFingerprint string, newLockedUntil, newExpiresAt time.Time) (bool, error) {
  97. r.mu.Lock()
  98. defer r.mu.Unlock()
  99. for _, rec := range r.data {
  100. if rec.ID != id {
  101. continue
  102. }
  103. if rec.Status != IdempotencyStatusProcessing || rec.RequestFingerprint != requestFingerprint {
  104. return false, nil
  105. }
  106. rec.LockedUntil = &newLockedUntil
  107. rec.ExpiresAt = newExpiresAt
  108. rec.UpdatedAt = time.Now()
  109. return true, nil
  110. }
  111. return false, nil
  112. }
  113. func (r *inMemoryIdempotencyRepo) MarkSucceeded(_ context.Context, id int64, responseStatus int, responseBody string, expiresAt time.Time) error {
  114. r.mu.Lock()
  115. defer r.mu.Unlock()
  116. for _, rec := range r.data {
  117. if rec.ID != id {
  118. continue
  119. }
  120. rec.Status = IdempotencyStatusSucceeded
  121. rec.LockedUntil = nil
  122. rec.ExpiresAt = expiresAt
  123. rec.UpdatedAt = time.Now()
  124. rec.ErrorReason = nil
  125. rec.ResponseStatus = &responseStatus
  126. rec.ResponseBody = &responseBody
  127. return nil
  128. }
  129. return errors.New("record not found")
  130. }
  131. func (r *inMemoryIdempotencyRepo) MarkFailedRetryable(_ context.Context, id int64, errorReason string, lockedUntil, expiresAt time.Time) error {
  132. r.mu.Lock()
  133. defer r.mu.Unlock()
  134. for _, rec := range r.data {
  135. if rec.ID != id {
  136. continue
  137. }
  138. rec.Status = IdempotencyStatusFailedRetryable
  139. rec.LockedUntil = &lockedUntil
  140. rec.ExpiresAt = expiresAt
  141. rec.UpdatedAt = time.Now()
  142. rec.ErrorReason = &errorReason
  143. return nil
  144. }
  145. return errors.New("record not found")
  146. }
  147. func (r *inMemoryIdempotencyRepo) DeleteExpired(_ context.Context, now time.Time, _ int) (int64, error) {
  148. r.mu.Lock()
  149. defer r.mu.Unlock()
  150. var deleted int64
  151. for k, rec := range r.data {
  152. if !rec.ExpiresAt.After(now) {
  153. delete(r.data, k)
  154. deleted++
  155. }
  156. }
  157. return deleted, nil
  158. }
  159. func TestIdempotencyCoordinator_RequireKey(t *testing.T) {
  160. resetIdempotencyMetricsForTest()
  161. repo := newInMemoryIdempotencyRepo()
  162. cfg := DefaultIdempotencyConfig()
  163. cfg.ObserveOnly = false
  164. coordinator := NewIdempotencyCoordinator(repo, cfg)
  165. _, err := coordinator.Execute(context.Background(), IdempotencyExecuteOptions{
  166. Scope: "test.scope",
  167. Method: "POST",
  168. Route: "/test",
  169. ActorScope: "admin:1",
  170. RequireKey: true,
  171. Payload: map[string]any{"a": 1},
  172. }, func(ctx context.Context) (any, error) {
  173. return map[string]any{"ok": true}, nil
  174. })
  175. require.Error(t, err)
  176. require.Equal(t, infraerrors.Code(err), infraerrors.Code(ErrIdempotencyKeyRequired))
  177. }
  178. func TestIdempotencyCoordinator_ReplaySucceededResult(t *testing.T) {
  179. resetIdempotencyMetricsForTest()
  180. repo := newInMemoryIdempotencyRepo()
  181. cfg := DefaultIdempotencyConfig()
  182. coordinator := NewIdempotencyCoordinator(repo, cfg)
  183. execCount := 0
  184. exec := func(ctx context.Context) (any, error) {
  185. execCount++
  186. return map[string]any{"count": execCount}, nil
  187. }
  188. opts := IdempotencyExecuteOptions{
  189. Scope: "test.scope",
  190. Method: "POST",
  191. Route: "/test",
  192. ActorScope: "user:1",
  193. RequireKey: true,
  194. IdempotencyKey: "case-1",
  195. Payload: map[string]any{"a": 1},
  196. }
  197. first, err := coordinator.Execute(context.Background(), opts, exec)
  198. require.NoError(t, err)
  199. require.False(t, first.Replayed)
  200. second, err := coordinator.Execute(context.Background(), opts, exec)
  201. require.NoError(t, err)
  202. require.True(t, second.Replayed)
  203. require.Equal(t, 1, execCount, "second request should replay without executing business logic")
  204. metrics := GetIdempotencyMetricsSnapshot()
  205. require.Equal(t, uint64(1), metrics.ClaimTotal)
  206. require.Equal(t, uint64(1), metrics.ReplayTotal)
  207. }
  208. func TestIdempotencyCoordinator_ReclaimExpiredSucceededRecord(t *testing.T) {
  209. resetIdempotencyMetricsForTest()
  210. repo := newInMemoryIdempotencyRepo()
  211. coordinator := NewIdempotencyCoordinator(repo, DefaultIdempotencyConfig())
  212. opts := IdempotencyExecuteOptions{
  213. Scope: "test.scope.expired",
  214. Method: "POST",
  215. Route: "/test/expired",
  216. ActorScope: "user:99",
  217. RequireKey: true,
  218. IdempotencyKey: "expired-case",
  219. Payload: map[string]any{"k": "v"},
  220. }
  221. execCount := 0
  222. exec := func(ctx context.Context) (any, error) {
  223. execCount++
  224. return map[string]any{"count": execCount}, nil
  225. }
  226. first, err := coordinator.Execute(context.Background(), opts, exec)
  227. require.NoError(t, err)
  228. require.NotNil(t, first)
  229. require.False(t, first.Replayed)
  230. require.Equal(t, 1, execCount)
  231. keyHash := HashIdempotencyKey(opts.IdempotencyKey)
  232. repo.mu.Lock()
  233. existing := repo.data[repo.key(opts.Scope, keyHash)]
  234. require.NotNil(t, existing)
  235. existing.ExpiresAt = time.Now().Add(-time.Second)
  236. repo.mu.Unlock()
  237. second, err := coordinator.Execute(context.Background(), opts, exec)
  238. require.NoError(t, err)
  239. require.NotNil(t, second)
  240. require.False(t, second.Replayed, "expired record should be reclaimed and execute business logic again")
  241. require.Equal(t, 2, execCount)
  242. third, err := coordinator.Execute(context.Background(), opts, exec)
  243. require.NoError(t, err)
  244. require.NotNil(t, third)
  245. require.True(t, third.Replayed)
  246. payload, ok := third.Data.(map[string]any)
  247. require.True(t, ok)
  248. require.Equal(t, float64(2), payload["count"])
  249. metrics := GetIdempotencyMetricsSnapshot()
  250. require.GreaterOrEqual(t, metrics.ClaimTotal, uint64(2))
  251. require.GreaterOrEqual(t, metrics.ReplayTotal, uint64(1))
  252. }
  253. func TestIdempotencyCoordinator_SameKeyDifferentPayloadConflict(t *testing.T) {
  254. resetIdempotencyMetricsForTest()
  255. repo := newInMemoryIdempotencyRepo()
  256. cfg := DefaultIdempotencyConfig()
  257. coordinator := NewIdempotencyCoordinator(repo, cfg)
  258. _, err := coordinator.Execute(context.Background(), IdempotencyExecuteOptions{
  259. Scope: "test.scope",
  260. Method: "POST",
  261. Route: "/test",
  262. ActorScope: "user:1",
  263. RequireKey: true,
  264. IdempotencyKey: "case-2",
  265. Payload: map[string]any{"a": 1},
  266. }, func(ctx context.Context) (any, error) {
  267. return map[string]any{"ok": true}, nil
  268. })
  269. require.NoError(t, err)
  270. _, err = coordinator.Execute(context.Background(), IdempotencyExecuteOptions{
  271. Scope: "test.scope",
  272. Method: "POST",
  273. Route: "/test",
  274. ActorScope: "user:1",
  275. RequireKey: true,
  276. IdempotencyKey: "case-2",
  277. Payload: map[string]any{"a": 2},
  278. }, func(ctx context.Context) (any, error) {
  279. return map[string]any{"ok": true}, nil
  280. })
  281. require.Error(t, err)
  282. require.Equal(t, infraerrors.Code(err), infraerrors.Code(ErrIdempotencyKeyConflict))
  283. metrics := GetIdempotencyMetricsSnapshot()
  284. require.Equal(t, uint64(1), metrics.ConflictTotal)
  285. }
  286. func TestIdempotencyCoordinator_BackoffAfterRetryableFailure(t *testing.T) {
  287. resetIdempotencyMetricsForTest()
  288. repo := newInMemoryIdempotencyRepo()
  289. cfg := DefaultIdempotencyConfig()
  290. cfg.FailedRetryBackoff = 2 * time.Second
  291. coordinator := NewIdempotencyCoordinator(repo, cfg)
  292. opts := IdempotencyExecuteOptions{
  293. Scope: "test.scope",
  294. Method: "POST",
  295. Route: "/test",
  296. ActorScope: "user:1",
  297. RequireKey: true,
  298. IdempotencyKey: "case-3",
  299. Payload: map[string]any{"a": 1},
  300. }
  301. _, err := coordinator.Execute(context.Background(), opts, func(ctx context.Context) (any, error) {
  302. return nil, infraerrors.InternalServer("UPSTREAM_ERROR", "upstream error")
  303. })
  304. require.Error(t, err)
  305. _, err = coordinator.Execute(context.Background(), opts, func(ctx context.Context) (any, error) {
  306. return map[string]any{"ok": true}, nil
  307. })
  308. require.Error(t, err)
  309. require.Equal(t, infraerrors.Code(err), infraerrors.Code(ErrIdempotencyRetryBackoff))
  310. require.Greater(t, RetryAfterSecondsFromError(err), 0)
  311. metrics := GetIdempotencyMetricsSnapshot()
  312. require.GreaterOrEqual(t, metrics.RetryBackoffTotal, uint64(2))
  313. require.GreaterOrEqual(t, metrics.ConflictTotal, uint64(1))
  314. require.GreaterOrEqual(t, metrics.ProcessingDurationCount, uint64(1))
  315. }
  316. func TestIdempotencyCoordinator_ConcurrentSameKeySingleSideEffect(t *testing.T) {
  317. resetIdempotencyMetricsForTest()
  318. repo := newInMemoryIdempotencyRepo()
  319. cfg := DefaultIdempotencyConfig()
  320. cfg.ProcessingTimeout = 2 * time.Second
  321. coordinator := NewIdempotencyCoordinator(repo, cfg)
  322. opts := IdempotencyExecuteOptions{
  323. Scope: "test.scope.concurrent",
  324. Method: "POST",
  325. Route: "/test/concurrent",
  326. ActorScope: "user:7",
  327. RequireKey: true,
  328. IdempotencyKey: "concurrent-case",
  329. Payload: map[string]any{"v": 1},
  330. }
  331. var execCount int32
  332. var wg sync.WaitGroup
  333. for i := 0; i < 8; i++ {
  334. wg.Add(1)
  335. go func() {
  336. defer wg.Done()
  337. _, _ = coordinator.Execute(context.Background(), opts, func(ctx context.Context) (any, error) {
  338. atomic.AddInt32(&execCount, 1)
  339. time.Sleep(80 * time.Millisecond)
  340. return map[string]any{"ok": true}, nil
  341. })
  342. }()
  343. }
  344. wg.Wait()
  345. replayed, err := coordinator.Execute(context.Background(), opts, func(ctx context.Context) (any, error) {
  346. atomic.AddInt32(&execCount, 1)
  347. return map[string]any{"ok": true}, nil
  348. })
  349. require.NoError(t, err)
  350. require.True(t, replayed.Replayed)
  351. require.Equal(t, int32(1), atomic.LoadInt32(&execCount), "concurrent same-key requests should execute business side-effect once")
  352. metrics := GetIdempotencyMetricsSnapshot()
  353. require.Equal(t, uint64(1), metrics.ClaimTotal)
  354. require.Equal(t, uint64(1), metrics.ReplayTotal)
  355. require.GreaterOrEqual(t, metrics.ConflictTotal, uint64(1))
  356. }
  357. type failingIdempotencyRepo struct{}
  358. func (failingIdempotencyRepo) CreateProcessing(context.Context, *IdempotencyRecord) (bool, error) {
  359. return false, errors.New("store unavailable")
  360. }
  361. func (failingIdempotencyRepo) GetByScopeAndKeyHash(context.Context, string, string) (*IdempotencyRecord, error) {
  362. return nil, errors.New("store unavailable")
  363. }
  364. func (failingIdempotencyRepo) TryReclaim(context.Context, int64, string, time.Time, time.Time, time.Time) (bool, error) {
  365. return false, errors.New("store unavailable")
  366. }
  367. func (failingIdempotencyRepo) ExtendProcessingLock(context.Context, int64, string, time.Time, time.Time) (bool, error) {
  368. return false, errors.New("store unavailable")
  369. }
  370. func (failingIdempotencyRepo) MarkSucceeded(context.Context, int64, int, string, time.Time) error {
  371. return errors.New("store unavailable")
  372. }
  373. func (failingIdempotencyRepo) MarkFailedRetryable(context.Context, int64, string, time.Time, time.Time) error {
  374. return errors.New("store unavailable")
  375. }
  376. func (failingIdempotencyRepo) DeleteExpired(context.Context, time.Time, int) (int64, error) {
  377. return 0, errors.New("store unavailable")
  378. }
  379. func TestIdempotencyCoordinator_StoreUnavailableMetrics(t *testing.T) {
  380. resetIdempotencyMetricsForTest()
  381. coordinator := NewIdempotencyCoordinator(failingIdempotencyRepo{}, DefaultIdempotencyConfig())
  382. _, err := coordinator.Execute(context.Background(), IdempotencyExecuteOptions{
  383. Scope: "test.scope.unavailable",
  384. Method: "POST",
  385. Route: "/test/unavailable",
  386. ActorScope: "admin:1",
  387. RequireKey: true,
  388. IdempotencyKey: "case-unavailable",
  389. Payload: map[string]any{"v": 1},
  390. }, func(ctx context.Context) (any, error) {
  391. return map[string]any{"ok": true}, nil
  392. })
  393. require.Error(t, err)
  394. require.Equal(t, infraerrors.Code(ErrIdempotencyStoreUnavail), infraerrors.Code(err))
  395. require.GreaterOrEqual(t, GetIdempotencyMetricsSnapshot().StoreUnavailableTotal, uint64(1))
  396. }
  397. type utf8RejectingIdempotencyRepo struct {
  398. inMemoryIdempotencyRepo
  399. }
  400. func newUTF8RejectingIdempotencyRepo() *utf8RejectingIdempotencyRepo {
  401. return &utf8RejectingIdempotencyRepo{inMemoryIdempotencyRepo: *newInMemoryIdempotencyRepo()}
  402. }
  403. func (r *utf8RejectingIdempotencyRepo) MarkSucceeded(ctx context.Context, id int64, responseStatus int, responseBody string, expiresAt time.Time) error {
  404. if !utf8.ValidString(responseBody) {
  405. return errors.New(`pq: invalid byte sequence for encoding "UTF8": 0xe8 0xb4 0x2e`)
  406. }
  407. return r.inMemoryIdempotencyRepo.MarkSucceeded(ctx, id, responseStatus, responseBody, expiresAt)
  408. }
  409. func TestIdempotencyCoordinator_TruncatedStoredResponseRemainsUTF8(t *testing.T) {
  410. repo := newUTF8RejectingIdempotencyRepo()
  411. cfg := DefaultIdempotencyConfig()
  412. cfg.MaxStoredResponseLen = len(`{"message":"`) + 2
  413. coordinator := NewIdempotencyCoordinator(repo, cfg)
  414. opts := IdempotencyExecuteOptions{
  415. Scope: "test.scope.truncate_utf8",
  416. Method: "POST",
  417. Route: "/api/v1/accounts/import/codex-session",
  418. ActorScope: "admin:1",
  419. RequireKey: true,
  420. IdempotencyKey: "truncate-utf8",
  421. Payload: map[string]any{"content": "codex-session"},
  422. }
  423. result, err := coordinator.Execute(context.Background(), opts, func(ctx context.Context) (any, error) {
  424. return map[string]any{"message": strings.Repeat("\u8d26", 8)}, nil
  425. })
  426. require.NoError(t, err)
  427. require.NotNil(t, result)
  428. stored, err := repo.GetByScopeAndKeyHash(context.Background(), opts.Scope, HashIdempotencyKey(opts.IdempotencyKey))
  429. require.NoError(t, err)
  430. require.NotNil(t, stored)
  431. require.NotNil(t, stored.ResponseBody)
  432. require.True(t, utf8.ValidString(*stored.ResponseBody))
  433. require.Contains(t, *stored.ResponseBody, "...(truncated)")
  434. }
  435. func TestDefaultIdempotencyCoordinatorAndTTLs(t *testing.T) {
  436. SetDefaultIdempotencyCoordinator(nil)
  437. require.Nil(t, DefaultIdempotencyCoordinator())
  438. require.Equal(t, DefaultIdempotencyConfig().DefaultTTL, DefaultWriteIdempotencyTTL())
  439. require.Equal(t, DefaultIdempotencyConfig().SystemOperationTTL, DefaultSystemOperationIdempotencyTTL())
  440. coordinator := NewIdempotencyCoordinator(newInMemoryIdempotencyRepo(), IdempotencyConfig{
  441. DefaultTTL: 2 * time.Hour,
  442. SystemOperationTTL: 15 * time.Minute,
  443. ProcessingTimeout: 10 * time.Second,
  444. FailedRetryBackoff: 3 * time.Second,
  445. ObserveOnly: false,
  446. })
  447. SetDefaultIdempotencyCoordinator(coordinator)
  448. t.Cleanup(func() {
  449. SetDefaultIdempotencyCoordinator(nil)
  450. })
  451. require.Same(t, coordinator, DefaultIdempotencyCoordinator())
  452. require.Equal(t, 2*time.Hour, DefaultWriteIdempotencyTTL())
  453. require.Equal(t, 15*time.Minute, DefaultSystemOperationIdempotencyTTL())
  454. }
  455. func TestNormalizeIdempotencyKeyAndFingerprint(t *testing.T) {
  456. key, err := NormalizeIdempotencyKey(" abc-123 ")
  457. require.NoError(t, err)
  458. require.Equal(t, "abc-123", key)
  459. key, err = NormalizeIdempotencyKey("")
  460. require.NoError(t, err)
  461. require.Equal(t, "", key)
  462. _, err = NormalizeIdempotencyKey(string(make([]byte, 129)))
  463. require.Error(t, err)
  464. _, err = NormalizeIdempotencyKey("bad\nkey")
  465. require.Error(t, err)
  466. fp1, err := BuildIdempotencyFingerprint("", "", "", map[string]any{"a": 1})
  467. require.NoError(t, err)
  468. require.NotEmpty(t, fp1)
  469. fp2, err := BuildIdempotencyFingerprint("POST", "/", "anonymous", map[string]any{"a": 1})
  470. require.NoError(t, err)
  471. require.Equal(t, fp1, fp2)
  472. _, err = BuildIdempotencyFingerprint("POST", "/x", "u:1", map[string]any{"bad": make(chan int)})
  473. require.Error(t, err)
  474. require.Equal(t, infraerrors.Code(ErrIdempotencyInvalidPayload), infraerrors.Code(err))
  475. }
  476. func TestRetryAfterSecondsFromErrorBranches(t *testing.T) {
  477. require.Equal(t, 0, RetryAfterSecondsFromError(nil))
  478. require.Equal(t, 0, RetryAfterSecondsFromError(errors.New("plain")))
  479. err := ErrIdempotencyInProgress.WithMetadata(map[string]string{"retry_after": "12"})
  480. require.Equal(t, 12, RetryAfterSecondsFromError(err))
  481. err = ErrIdempotencyInProgress.WithMetadata(map[string]string{"retry_after": "bad"})
  482. require.Equal(t, 0, RetryAfterSecondsFromError(err))
  483. }
  484. func TestIdempotencyCoordinator_ExecuteNilExecutorAndNoKeyPassThrough(t *testing.T) {
  485. repo := newInMemoryIdempotencyRepo()
  486. coordinator := NewIdempotencyCoordinator(repo, DefaultIdempotencyConfig())
  487. _, err := coordinator.Execute(context.Background(), IdempotencyExecuteOptions{
  488. Scope: "scope",
  489. IdempotencyKey: "k",
  490. Payload: map[string]any{"a": 1},
  491. }, nil)
  492. require.Error(t, err)
  493. require.Equal(t, "IDEMPOTENCY_EXECUTOR_NIL", infraerrors.Reason(err))
  494. called := 0
  495. result, err := coordinator.Execute(context.Background(), IdempotencyExecuteOptions{
  496. Scope: "scope",
  497. RequireKey: true,
  498. Payload: map[string]any{"a": 1},
  499. }, func(ctx context.Context) (any, error) {
  500. called++
  501. return map[string]any{"ok": true}, nil
  502. })
  503. require.NoError(t, err)
  504. require.Equal(t, 1, called)
  505. require.NotNil(t, result)
  506. require.False(t, result.Replayed)
  507. }
  508. type noIDOwnerRepo struct{}
  509. func (noIDOwnerRepo) CreateProcessing(context.Context, *IdempotencyRecord) (bool, error) {
  510. return true, nil
  511. }
  512. func (noIDOwnerRepo) GetByScopeAndKeyHash(context.Context, string, string) (*IdempotencyRecord, error) {
  513. return nil, nil
  514. }
  515. func (noIDOwnerRepo) TryReclaim(context.Context, int64, string, time.Time, time.Time, time.Time) (bool, error) {
  516. return false, nil
  517. }
  518. func (noIDOwnerRepo) ExtendProcessingLock(context.Context, int64, string, time.Time, time.Time) (bool, error) {
  519. return false, nil
  520. }
  521. func (noIDOwnerRepo) MarkSucceeded(context.Context, int64, int, string, time.Time) error { return nil }
  522. func (noIDOwnerRepo) MarkFailedRetryable(context.Context, int64, string, time.Time, time.Time) error {
  523. return nil
  524. }
  525. func (noIDOwnerRepo) DeleteExpired(context.Context, time.Time, int) (int64, error) { return 0, nil }
  526. func TestIdempotencyCoordinator_RepoNilScopeRequiredAndRecordIDMissing(t *testing.T) {
  527. cfg := DefaultIdempotencyConfig()
  528. coordinator := NewIdempotencyCoordinator(nil, cfg)
  529. _, err := coordinator.Execute(context.Background(), IdempotencyExecuteOptions{
  530. Scope: "scope",
  531. IdempotencyKey: "k",
  532. Payload: map[string]any{"a": 1},
  533. }, func(ctx context.Context) (any, error) {
  534. return map[string]any{"ok": true}, nil
  535. })
  536. require.Error(t, err)
  537. require.Equal(t, infraerrors.Code(ErrIdempotencyStoreUnavail), infraerrors.Code(err))
  538. coordinator = NewIdempotencyCoordinator(newInMemoryIdempotencyRepo(), cfg)
  539. _, err = coordinator.Execute(context.Background(), IdempotencyExecuteOptions{
  540. IdempotencyKey: "k2",
  541. Payload: map[string]any{"a": 1},
  542. }, func(ctx context.Context) (any, error) {
  543. return map[string]any{"ok": true}, nil
  544. })
  545. require.Error(t, err)
  546. require.Equal(t, "IDEMPOTENCY_SCOPE_REQUIRED", infraerrors.Reason(err))
  547. coordinator = NewIdempotencyCoordinator(noIDOwnerRepo{}, cfg)
  548. _, err = coordinator.Execute(context.Background(), IdempotencyExecuteOptions{
  549. Scope: "scope-no-id",
  550. IdempotencyKey: "k3",
  551. Payload: map[string]any{"a": 1},
  552. }, func(ctx context.Context) (any, error) {
  553. return map[string]any{"ok": true}, nil
  554. })
  555. require.Error(t, err)
  556. require.Equal(t, infraerrors.Code(ErrIdempotencyStoreUnavail), infraerrors.Code(err))
  557. }
  558. type conflictBranchRepo struct {
  559. existing *IdempotencyRecord
  560. tryReclaimErr error
  561. tryReclaimOK bool
  562. }
  563. func (r *conflictBranchRepo) CreateProcessing(context.Context, *IdempotencyRecord) (bool, error) {
  564. return false, nil
  565. }
  566. func (r *conflictBranchRepo) GetByScopeAndKeyHash(context.Context, string, string) (*IdempotencyRecord, error) {
  567. return cloneRecord(r.existing), nil
  568. }
  569. func (r *conflictBranchRepo) TryReclaim(context.Context, int64, string, time.Time, time.Time, time.Time) (bool, error) {
  570. if r.tryReclaimErr != nil {
  571. return false, r.tryReclaimErr
  572. }
  573. return r.tryReclaimOK, nil
  574. }
  575. func (r *conflictBranchRepo) ExtendProcessingLock(context.Context, int64, string, time.Time, time.Time) (bool, error) {
  576. return false, nil
  577. }
  578. func (r *conflictBranchRepo) MarkSucceeded(context.Context, int64, int, string, time.Time) error {
  579. return nil
  580. }
  581. func (r *conflictBranchRepo) MarkFailedRetryable(context.Context, int64, string, time.Time, time.Time) error {
  582. return nil
  583. }
  584. func (r *conflictBranchRepo) DeleteExpired(context.Context, time.Time, int) (int64, error) {
  585. return 0, nil
  586. }
  587. func TestIdempotencyCoordinator_ConflictBranchesAndDecodeError(t *testing.T) {
  588. now := time.Now()
  589. fp, err := BuildIdempotencyFingerprint("POST", "/x", "u:1", map[string]any{"a": 1})
  590. require.NoError(t, err)
  591. badBody := "{bad-json"
  592. repo := &conflictBranchRepo{
  593. existing: &IdempotencyRecord{
  594. ID: 1,
  595. Scope: "scope",
  596. IdempotencyKeyHash: HashIdempotencyKey("k"),
  597. RequestFingerprint: fp,
  598. Status: IdempotencyStatusSucceeded,
  599. ResponseBody: &badBody,
  600. ExpiresAt: now.Add(time.Hour),
  601. },
  602. }
  603. coordinator := NewIdempotencyCoordinator(repo, DefaultIdempotencyConfig())
  604. _, err = coordinator.Execute(context.Background(), IdempotencyExecuteOptions{
  605. Scope: "scope",
  606. IdempotencyKey: "k",
  607. Method: "POST",
  608. Route: "/x",
  609. ActorScope: "u:1",
  610. Payload: map[string]any{"a": 1},
  611. }, func(ctx context.Context) (any, error) {
  612. return map[string]any{"ok": true}, nil
  613. })
  614. require.Error(t, err)
  615. require.Equal(t, infraerrors.Code(ErrIdempotencyStoreUnavail), infraerrors.Code(err))
  616. repo.existing = &IdempotencyRecord{
  617. ID: 2,
  618. Scope: "scope",
  619. IdempotencyKeyHash: HashIdempotencyKey("k"),
  620. RequestFingerprint: fp,
  621. Status: "unknown",
  622. ExpiresAt: now.Add(time.Hour),
  623. }
  624. _, err = coordinator.Execute(context.Background(), IdempotencyExecuteOptions{
  625. Scope: "scope",
  626. IdempotencyKey: "k",
  627. Method: "POST",
  628. Route: "/x",
  629. ActorScope: "u:1",
  630. Payload: map[string]any{"a": 1},
  631. }, func(ctx context.Context) (any, error) {
  632. return map[string]any{"ok": true}, nil
  633. })
  634. require.Error(t, err)
  635. require.Equal(t, infraerrors.Code(ErrIdempotencyKeyConflict), infraerrors.Code(err))
  636. repo.existing = &IdempotencyRecord{
  637. ID: 3,
  638. Scope: "scope",
  639. IdempotencyKeyHash: HashIdempotencyKey("k"),
  640. RequestFingerprint: fp,
  641. Status: IdempotencyStatusFailedRetryable,
  642. LockedUntil: ptrTime(now.Add(-time.Second)),
  643. ExpiresAt: now.Add(time.Hour),
  644. }
  645. repo.tryReclaimErr = errors.New("reclaim down")
  646. _, err = coordinator.Execute(context.Background(), IdempotencyExecuteOptions{
  647. Scope: "scope",
  648. IdempotencyKey: "k",
  649. Method: "POST",
  650. Route: "/x",
  651. ActorScope: "u:1",
  652. Payload: map[string]any{"a": 1},
  653. }, func(ctx context.Context) (any, error) {
  654. return map[string]any{"ok": true}, nil
  655. })
  656. require.Error(t, err)
  657. require.Equal(t, infraerrors.Code(ErrIdempotencyStoreUnavail), infraerrors.Code(err))
  658. repo.tryReclaimErr = nil
  659. repo.tryReclaimOK = false
  660. _, err = coordinator.Execute(context.Background(), IdempotencyExecuteOptions{
  661. Scope: "scope",
  662. IdempotencyKey: "k",
  663. Method: "POST",
  664. Route: "/x",
  665. ActorScope: "u:1",
  666. Payload: map[string]any{"a": 1},
  667. }, func(ctx context.Context) (any, error) {
  668. return map[string]any{"ok": true}, nil
  669. })
  670. require.Error(t, err)
  671. require.Equal(t, infraerrors.Code(ErrIdempotencyInProgress), infraerrors.Code(err))
  672. }
  673. type markBehaviorRepo struct {
  674. inMemoryIdempotencyRepo
  675. failMarkSucceeded bool
  676. failMarkFailed bool
  677. }
  678. func (r *markBehaviorRepo) MarkSucceeded(ctx context.Context, id int64, responseStatus int, responseBody string, expiresAt time.Time) error {
  679. if r.failMarkSucceeded {
  680. return errors.New("mark succeeded failed")
  681. }
  682. return r.inMemoryIdempotencyRepo.MarkSucceeded(ctx, id, responseStatus, responseBody, expiresAt)
  683. }
  684. func (r *markBehaviorRepo) MarkFailedRetryable(ctx context.Context, id int64, errorReason string, lockedUntil, expiresAt time.Time) error {
  685. if r.failMarkFailed {
  686. return errors.New("mark failed retryable failed")
  687. }
  688. return r.inMemoryIdempotencyRepo.MarkFailedRetryable(ctx, id, errorReason, lockedUntil, expiresAt)
  689. }
  690. func TestIdempotencyCoordinator_MarkAndMarshalBranches(t *testing.T) {
  691. repo := &markBehaviorRepo{inMemoryIdempotencyRepo: *newInMemoryIdempotencyRepo()}
  692. coordinator := NewIdempotencyCoordinator(repo, DefaultIdempotencyConfig())
  693. repo.failMarkSucceeded = true
  694. _, err := coordinator.Execute(context.Background(), IdempotencyExecuteOptions{
  695. Scope: "scope-success",
  696. IdempotencyKey: "k1",
  697. Method: "POST",
  698. Route: "/ok",
  699. ActorScope: "u:1",
  700. Payload: map[string]any{"a": 1},
  701. }, func(ctx context.Context) (any, error) {
  702. return map[string]any{"ok": true}, nil
  703. })
  704. require.Error(t, err)
  705. require.Equal(t, infraerrors.Code(ErrIdempotencyStoreUnavail), infraerrors.Code(err))
  706. repo.failMarkSucceeded = false
  707. _, err = coordinator.Execute(context.Background(), IdempotencyExecuteOptions{
  708. Scope: "scope-marshal",
  709. IdempotencyKey: "k2",
  710. Method: "POST",
  711. Route: "/bad",
  712. ActorScope: "u:1",
  713. Payload: map[string]any{"a": 1},
  714. }, func(ctx context.Context) (any, error) {
  715. return map[string]any{"bad": make(chan int)}, nil
  716. })
  717. require.Error(t, err)
  718. require.Equal(t, infraerrors.Code(ErrIdempotencyStoreUnavail), infraerrors.Code(err))
  719. repo.failMarkFailed = true
  720. _, err = coordinator.Execute(context.Background(), IdempotencyExecuteOptions{
  721. Scope: "scope-fail",
  722. IdempotencyKey: "k3",
  723. Method: "POST",
  724. Route: "/fail",
  725. ActorScope: "u:1",
  726. Payload: map[string]any{"a": 1},
  727. }, func(ctx context.Context) (any, error) {
  728. return nil, errors.New("plain failure")
  729. })
  730. require.Error(t, err)
  731. require.Equal(t, "plain failure", err.Error())
  732. }
  733. func TestIdempotencyCoordinator_HelperBranches(t *testing.T) {
  734. c := NewIdempotencyCoordinator(newInMemoryIdempotencyRepo(), IdempotencyConfig{
  735. DefaultTTL: time.Hour,
  736. SystemOperationTTL: time.Hour,
  737. ProcessingTimeout: time.Second,
  738. FailedRetryBackoff: time.Second,
  739. MaxStoredResponseLen: 12,
  740. ObserveOnly: false,
  741. })
  742. // conflictWithRetryAfter without locked_until should return base error.
  743. base := ErrIdempotencyInProgress
  744. err := c.conflictWithRetryAfter(base, nil, time.Now())
  745. require.Equal(t, infraerrors.Code(base), infraerrors.Code(err))
  746. // marshalStoredResponse should truncate.
  747. body, err := c.marshalStoredResponse(map[string]any{"long": "abcdefghijklmnopqrstuvwxyz"})
  748. require.NoError(t, err)
  749. require.Contains(t, body, "...(truncated)")
  750. // decodeStoredResponse empty and invalid json.
  751. out, err := c.decodeStoredResponse(nil)
  752. require.NoError(t, err)
  753. _, ok := out.(map[string]any)
  754. require.True(t, ok)
  755. invalid := "{invalid"
  756. _, err = c.decodeStoredResponse(&invalid)
  757. require.Error(t, err)
  758. }