| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680681682683684685686687688689690691692693694695696697698699700701702703704705706707708709710711712713714715716717718719720721722723724725726727728729730731732733734735736737738739740741742743744745746747748749750751752753754755756757758759760761762763764765766767768769770771772773774775776777778779780781782783784785786787788789790791792793794795796797798799800801802803804805806807808809810811812813814815816817818819820821822823824825826827828829830831832833834835836837838839840841842843844845846847848849850851852 |
- package service
- import (
- "context"
- "errors"
- "strings"
- "sync"
- "sync/atomic"
- "testing"
- "time"
- "unicode/utf8"
- infraerrors "github.com/Wei-Shaw/sub2api/internal/pkg/errors"
- "github.com/stretchr/testify/require"
- )
- type inMemoryIdempotencyRepo struct {
- mu sync.Mutex
- nextID int64
- data map[string]*IdempotencyRecord
- }
- func newInMemoryIdempotencyRepo() *inMemoryIdempotencyRepo {
- return &inMemoryIdempotencyRepo{
- nextID: 1,
- data: make(map[string]*IdempotencyRecord),
- }
- }
- func (r *inMemoryIdempotencyRepo) key(scope, hash string) string {
- return scope + "|" + hash
- }
- func cloneRecord(in *IdempotencyRecord) *IdempotencyRecord {
- if in == nil {
- return nil
- }
- out := *in
- if in.ResponseStatus != nil {
- v := *in.ResponseStatus
- out.ResponseStatus = &v
- }
- if in.ResponseBody != nil {
- v := *in.ResponseBody
- out.ResponseBody = &v
- }
- if in.ErrorReason != nil {
- v := *in.ErrorReason
- out.ErrorReason = &v
- }
- if in.LockedUntil != nil {
- v := *in.LockedUntil
- out.LockedUntil = &v
- }
- return &out
- }
- func (r *inMemoryIdempotencyRepo) CreateProcessing(_ context.Context, record *IdempotencyRecord) (bool, error) {
- r.mu.Lock()
- defer r.mu.Unlock()
- k := r.key(record.Scope, record.IdempotencyKeyHash)
- if _, ok := r.data[k]; ok {
- return false, nil
- }
- rec := cloneRecord(record)
- rec.ID = r.nextID
- rec.CreatedAt = time.Now()
- rec.UpdatedAt = rec.CreatedAt
- r.nextID++
- r.data[k] = rec
- record.ID = rec.ID
- record.CreatedAt = rec.CreatedAt
- record.UpdatedAt = rec.UpdatedAt
- return true, nil
- }
- func (r *inMemoryIdempotencyRepo) GetByScopeAndKeyHash(_ context.Context, scope, keyHash string) (*IdempotencyRecord, error) {
- r.mu.Lock()
- defer r.mu.Unlock()
- return cloneRecord(r.data[r.key(scope, keyHash)]), nil
- }
- func (r *inMemoryIdempotencyRepo) TryReclaim(_ context.Context, id int64, fromStatus string, now, newLockedUntil, newExpiresAt time.Time) (bool, error) {
- r.mu.Lock()
- defer r.mu.Unlock()
- for _, rec := range r.data {
- if rec.ID != id {
- continue
- }
- if rec.Status != fromStatus {
- return false, nil
- }
- if rec.LockedUntil != nil && rec.LockedUntil.After(now) {
- return false, nil
- }
- rec.Status = IdempotencyStatusProcessing
- rec.LockedUntil = &newLockedUntil
- rec.ExpiresAt = newExpiresAt
- rec.ErrorReason = nil
- rec.UpdatedAt = time.Now()
- return true, nil
- }
- return false, nil
- }
- func (r *inMemoryIdempotencyRepo) ExtendProcessingLock(_ context.Context, id int64, requestFingerprint string, newLockedUntil, newExpiresAt time.Time) (bool, error) {
- r.mu.Lock()
- defer r.mu.Unlock()
- for _, rec := range r.data {
- if rec.ID != id {
- continue
- }
- if rec.Status != IdempotencyStatusProcessing || rec.RequestFingerprint != requestFingerprint {
- return false, nil
- }
- rec.LockedUntil = &newLockedUntil
- rec.ExpiresAt = newExpiresAt
- rec.UpdatedAt = time.Now()
- return true, nil
- }
- return false, nil
- }
- func (r *inMemoryIdempotencyRepo) MarkSucceeded(_ context.Context, id int64, responseStatus int, responseBody string, expiresAt time.Time) error {
- r.mu.Lock()
- defer r.mu.Unlock()
- for _, rec := range r.data {
- if rec.ID != id {
- continue
- }
- rec.Status = IdempotencyStatusSucceeded
- rec.LockedUntil = nil
- rec.ExpiresAt = expiresAt
- rec.UpdatedAt = time.Now()
- rec.ErrorReason = nil
- rec.ResponseStatus = &responseStatus
- rec.ResponseBody = &responseBody
- return nil
- }
- return errors.New("record not found")
- }
- func (r *inMemoryIdempotencyRepo) MarkFailedRetryable(_ context.Context, id int64, errorReason string, lockedUntil, expiresAt time.Time) error {
- r.mu.Lock()
- defer r.mu.Unlock()
- for _, rec := range r.data {
- if rec.ID != id {
- continue
- }
- rec.Status = IdempotencyStatusFailedRetryable
- rec.LockedUntil = &lockedUntil
- rec.ExpiresAt = expiresAt
- rec.UpdatedAt = time.Now()
- rec.ErrorReason = &errorReason
- return nil
- }
- return errors.New("record not found")
- }
- func (r *inMemoryIdempotencyRepo) DeleteExpired(_ context.Context, now time.Time, _ int) (int64, error) {
- r.mu.Lock()
- defer r.mu.Unlock()
- var deleted int64
- for k, rec := range r.data {
- if !rec.ExpiresAt.After(now) {
- delete(r.data, k)
- deleted++
- }
- }
- return deleted, nil
- }
- func TestIdempotencyCoordinator_RequireKey(t *testing.T) {
- resetIdempotencyMetricsForTest()
- repo := newInMemoryIdempotencyRepo()
- cfg := DefaultIdempotencyConfig()
- cfg.ObserveOnly = false
- coordinator := NewIdempotencyCoordinator(repo, cfg)
- _, err := coordinator.Execute(context.Background(), IdempotencyExecuteOptions{
- Scope: "test.scope",
- Method: "POST",
- Route: "/test",
- ActorScope: "admin:1",
- RequireKey: true,
- Payload: map[string]any{"a": 1},
- }, func(ctx context.Context) (any, error) {
- return map[string]any{"ok": true}, nil
- })
- require.Error(t, err)
- require.Equal(t, infraerrors.Code(err), infraerrors.Code(ErrIdempotencyKeyRequired))
- }
- func TestIdempotencyCoordinator_ReplaySucceededResult(t *testing.T) {
- resetIdempotencyMetricsForTest()
- repo := newInMemoryIdempotencyRepo()
- cfg := DefaultIdempotencyConfig()
- coordinator := NewIdempotencyCoordinator(repo, cfg)
- execCount := 0
- exec := func(ctx context.Context) (any, error) {
- execCount++
- return map[string]any{"count": execCount}, nil
- }
- opts := IdempotencyExecuteOptions{
- Scope: "test.scope",
- Method: "POST",
- Route: "/test",
- ActorScope: "user:1",
- RequireKey: true,
- IdempotencyKey: "case-1",
- Payload: map[string]any{"a": 1},
- }
- first, err := coordinator.Execute(context.Background(), opts, exec)
- require.NoError(t, err)
- require.False(t, first.Replayed)
- second, err := coordinator.Execute(context.Background(), opts, exec)
- require.NoError(t, err)
- require.True(t, second.Replayed)
- require.Equal(t, 1, execCount, "second request should replay without executing business logic")
- metrics := GetIdempotencyMetricsSnapshot()
- require.Equal(t, uint64(1), metrics.ClaimTotal)
- require.Equal(t, uint64(1), metrics.ReplayTotal)
- }
- func TestIdempotencyCoordinator_ReclaimExpiredSucceededRecord(t *testing.T) {
- resetIdempotencyMetricsForTest()
- repo := newInMemoryIdempotencyRepo()
- coordinator := NewIdempotencyCoordinator(repo, DefaultIdempotencyConfig())
- opts := IdempotencyExecuteOptions{
- Scope: "test.scope.expired",
- Method: "POST",
- Route: "/test/expired",
- ActorScope: "user:99",
- RequireKey: true,
- IdempotencyKey: "expired-case",
- Payload: map[string]any{"k": "v"},
- }
- execCount := 0
- exec := func(ctx context.Context) (any, error) {
- execCount++
- return map[string]any{"count": execCount}, nil
- }
- first, err := coordinator.Execute(context.Background(), opts, exec)
- require.NoError(t, err)
- require.NotNil(t, first)
- require.False(t, first.Replayed)
- require.Equal(t, 1, execCount)
- keyHash := HashIdempotencyKey(opts.IdempotencyKey)
- repo.mu.Lock()
- existing := repo.data[repo.key(opts.Scope, keyHash)]
- require.NotNil(t, existing)
- existing.ExpiresAt = time.Now().Add(-time.Second)
- repo.mu.Unlock()
- second, err := coordinator.Execute(context.Background(), opts, exec)
- require.NoError(t, err)
- require.NotNil(t, second)
- require.False(t, second.Replayed, "expired record should be reclaimed and execute business logic again")
- require.Equal(t, 2, execCount)
- third, err := coordinator.Execute(context.Background(), opts, exec)
- require.NoError(t, err)
- require.NotNil(t, third)
- require.True(t, third.Replayed)
- payload, ok := third.Data.(map[string]any)
- require.True(t, ok)
- require.Equal(t, float64(2), payload["count"])
- metrics := GetIdempotencyMetricsSnapshot()
- require.GreaterOrEqual(t, metrics.ClaimTotal, uint64(2))
- require.GreaterOrEqual(t, metrics.ReplayTotal, uint64(1))
- }
- func TestIdempotencyCoordinator_SameKeyDifferentPayloadConflict(t *testing.T) {
- resetIdempotencyMetricsForTest()
- repo := newInMemoryIdempotencyRepo()
- cfg := DefaultIdempotencyConfig()
- coordinator := NewIdempotencyCoordinator(repo, cfg)
- _, err := coordinator.Execute(context.Background(), IdempotencyExecuteOptions{
- Scope: "test.scope",
- Method: "POST",
- Route: "/test",
- ActorScope: "user:1",
- RequireKey: true,
- IdempotencyKey: "case-2",
- Payload: map[string]any{"a": 1},
- }, func(ctx context.Context) (any, error) {
- return map[string]any{"ok": true}, nil
- })
- require.NoError(t, err)
- _, err = coordinator.Execute(context.Background(), IdempotencyExecuteOptions{
- Scope: "test.scope",
- Method: "POST",
- Route: "/test",
- ActorScope: "user:1",
- RequireKey: true,
- IdempotencyKey: "case-2",
- Payload: map[string]any{"a": 2},
- }, func(ctx context.Context) (any, error) {
- return map[string]any{"ok": true}, nil
- })
- require.Error(t, err)
- require.Equal(t, infraerrors.Code(err), infraerrors.Code(ErrIdempotencyKeyConflict))
- metrics := GetIdempotencyMetricsSnapshot()
- require.Equal(t, uint64(1), metrics.ConflictTotal)
- }
- func TestIdempotencyCoordinator_BackoffAfterRetryableFailure(t *testing.T) {
- resetIdempotencyMetricsForTest()
- repo := newInMemoryIdempotencyRepo()
- cfg := DefaultIdempotencyConfig()
- cfg.FailedRetryBackoff = 2 * time.Second
- coordinator := NewIdempotencyCoordinator(repo, cfg)
- opts := IdempotencyExecuteOptions{
- Scope: "test.scope",
- Method: "POST",
- Route: "/test",
- ActorScope: "user:1",
- RequireKey: true,
- IdempotencyKey: "case-3",
- Payload: map[string]any{"a": 1},
- }
- _, err := coordinator.Execute(context.Background(), opts, func(ctx context.Context) (any, error) {
- return nil, infraerrors.InternalServer("UPSTREAM_ERROR", "upstream error")
- })
- require.Error(t, err)
- _, err = coordinator.Execute(context.Background(), opts, func(ctx context.Context) (any, error) {
- return map[string]any{"ok": true}, nil
- })
- require.Error(t, err)
- require.Equal(t, infraerrors.Code(err), infraerrors.Code(ErrIdempotencyRetryBackoff))
- require.Greater(t, RetryAfterSecondsFromError(err), 0)
- metrics := GetIdempotencyMetricsSnapshot()
- require.GreaterOrEqual(t, metrics.RetryBackoffTotal, uint64(2))
- require.GreaterOrEqual(t, metrics.ConflictTotal, uint64(1))
- require.GreaterOrEqual(t, metrics.ProcessingDurationCount, uint64(1))
- }
- func TestIdempotencyCoordinator_ConcurrentSameKeySingleSideEffect(t *testing.T) {
- resetIdempotencyMetricsForTest()
- repo := newInMemoryIdempotencyRepo()
- cfg := DefaultIdempotencyConfig()
- cfg.ProcessingTimeout = 2 * time.Second
- coordinator := NewIdempotencyCoordinator(repo, cfg)
- opts := IdempotencyExecuteOptions{
- Scope: "test.scope.concurrent",
- Method: "POST",
- Route: "/test/concurrent",
- ActorScope: "user:7",
- RequireKey: true,
- IdempotencyKey: "concurrent-case",
- Payload: map[string]any{"v": 1},
- }
- var execCount int32
- var wg sync.WaitGroup
- for i := 0; i < 8; i++ {
- wg.Add(1)
- go func() {
- defer wg.Done()
- _, _ = coordinator.Execute(context.Background(), opts, func(ctx context.Context) (any, error) {
- atomic.AddInt32(&execCount, 1)
- time.Sleep(80 * time.Millisecond)
- return map[string]any{"ok": true}, nil
- })
- }()
- }
- wg.Wait()
- replayed, err := coordinator.Execute(context.Background(), opts, func(ctx context.Context) (any, error) {
- atomic.AddInt32(&execCount, 1)
- return map[string]any{"ok": true}, nil
- })
- require.NoError(t, err)
- require.True(t, replayed.Replayed)
- require.Equal(t, int32(1), atomic.LoadInt32(&execCount), "concurrent same-key requests should execute business side-effect once")
- metrics := GetIdempotencyMetricsSnapshot()
- require.Equal(t, uint64(1), metrics.ClaimTotal)
- require.Equal(t, uint64(1), metrics.ReplayTotal)
- require.GreaterOrEqual(t, metrics.ConflictTotal, uint64(1))
- }
- type failingIdempotencyRepo struct{}
- func (failingIdempotencyRepo) CreateProcessing(context.Context, *IdempotencyRecord) (bool, error) {
- return false, errors.New("store unavailable")
- }
- func (failingIdempotencyRepo) GetByScopeAndKeyHash(context.Context, string, string) (*IdempotencyRecord, error) {
- return nil, errors.New("store unavailable")
- }
- func (failingIdempotencyRepo) TryReclaim(context.Context, int64, string, time.Time, time.Time, time.Time) (bool, error) {
- return false, errors.New("store unavailable")
- }
- func (failingIdempotencyRepo) ExtendProcessingLock(context.Context, int64, string, time.Time, time.Time) (bool, error) {
- return false, errors.New("store unavailable")
- }
- func (failingIdempotencyRepo) MarkSucceeded(context.Context, int64, int, string, time.Time) error {
- return errors.New("store unavailable")
- }
- func (failingIdempotencyRepo) MarkFailedRetryable(context.Context, int64, string, time.Time, time.Time) error {
- return errors.New("store unavailable")
- }
- func (failingIdempotencyRepo) DeleteExpired(context.Context, time.Time, int) (int64, error) {
- return 0, errors.New("store unavailable")
- }
- func TestIdempotencyCoordinator_StoreUnavailableMetrics(t *testing.T) {
- resetIdempotencyMetricsForTest()
- coordinator := NewIdempotencyCoordinator(failingIdempotencyRepo{}, DefaultIdempotencyConfig())
- _, err := coordinator.Execute(context.Background(), IdempotencyExecuteOptions{
- Scope: "test.scope.unavailable",
- Method: "POST",
- Route: "/test/unavailable",
- ActorScope: "admin:1",
- RequireKey: true,
- IdempotencyKey: "case-unavailable",
- Payload: map[string]any{"v": 1},
- }, func(ctx context.Context) (any, error) {
- return map[string]any{"ok": true}, nil
- })
- require.Error(t, err)
- require.Equal(t, infraerrors.Code(ErrIdempotencyStoreUnavail), infraerrors.Code(err))
- require.GreaterOrEqual(t, GetIdempotencyMetricsSnapshot().StoreUnavailableTotal, uint64(1))
- }
- type utf8RejectingIdempotencyRepo struct {
- inMemoryIdempotencyRepo
- }
- func newUTF8RejectingIdempotencyRepo() *utf8RejectingIdempotencyRepo {
- return &utf8RejectingIdempotencyRepo{inMemoryIdempotencyRepo: *newInMemoryIdempotencyRepo()}
- }
- func (r *utf8RejectingIdempotencyRepo) MarkSucceeded(ctx context.Context, id int64, responseStatus int, responseBody string, expiresAt time.Time) error {
- if !utf8.ValidString(responseBody) {
- return errors.New(`pq: invalid byte sequence for encoding "UTF8": 0xe8 0xb4 0x2e`)
- }
- return r.inMemoryIdempotencyRepo.MarkSucceeded(ctx, id, responseStatus, responseBody, expiresAt)
- }
- func TestIdempotencyCoordinator_TruncatedStoredResponseRemainsUTF8(t *testing.T) {
- repo := newUTF8RejectingIdempotencyRepo()
- cfg := DefaultIdempotencyConfig()
- cfg.MaxStoredResponseLen = len(`{"message":"`) + 2
- coordinator := NewIdempotencyCoordinator(repo, cfg)
- opts := IdempotencyExecuteOptions{
- Scope: "test.scope.truncate_utf8",
- Method: "POST",
- Route: "/api/v1/accounts/import/codex-session",
- ActorScope: "admin:1",
- RequireKey: true,
- IdempotencyKey: "truncate-utf8",
- Payload: map[string]any{"content": "codex-session"},
- }
- result, err := coordinator.Execute(context.Background(), opts, func(ctx context.Context) (any, error) {
- return map[string]any{"message": strings.Repeat("\u8d26", 8)}, nil
- })
- require.NoError(t, err)
- require.NotNil(t, result)
- stored, err := repo.GetByScopeAndKeyHash(context.Background(), opts.Scope, HashIdempotencyKey(opts.IdempotencyKey))
- require.NoError(t, err)
- require.NotNil(t, stored)
- require.NotNil(t, stored.ResponseBody)
- require.True(t, utf8.ValidString(*stored.ResponseBody))
- require.Contains(t, *stored.ResponseBody, "...(truncated)")
- }
- func TestDefaultIdempotencyCoordinatorAndTTLs(t *testing.T) {
- SetDefaultIdempotencyCoordinator(nil)
- require.Nil(t, DefaultIdempotencyCoordinator())
- require.Equal(t, DefaultIdempotencyConfig().DefaultTTL, DefaultWriteIdempotencyTTL())
- require.Equal(t, DefaultIdempotencyConfig().SystemOperationTTL, DefaultSystemOperationIdempotencyTTL())
- coordinator := NewIdempotencyCoordinator(newInMemoryIdempotencyRepo(), IdempotencyConfig{
- DefaultTTL: 2 * time.Hour,
- SystemOperationTTL: 15 * time.Minute,
- ProcessingTimeout: 10 * time.Second,
- FailedRetryBackoff: 3 * time.Second,
- ObserveOnly: false,
- })
- SetDefaultIdempotencyCoordinator(coordinator)
- t.Cleanup(func() {
- SetDefaultIdempotencyCoordinator(nil)
- })
- require.Same(t, coordinator, DefaultIdempotencyCoordinator())
- require.Equal(t, 2*time.Hour, DefaultWriteIdempotencyTTL())
- require.Equal(t, 15*time.Minute, DefaultSystemOperationIdempotencyTTL())
- }
- func TestNormalizeIdempotencyKeyAndFingerprint(t *testing.T) {
- key, err := NormalizeIdempotencyKey(" abc-123 ")
- require.NoError(t, err)
- require.Equal(t, "abc-123", key)
- key, err = NormalizeIdempotencyKey("")
- require.NoError(t, err)
- require.Equal(t, "", key)
- _, err = NormalizeIdempotencyKey(string(make([]byte, 129)))
- require.Error(t, err)
- _, err = NormalizeIdempotencyKey("bad\nkey")
- require.Error(t, err)
- fp1, err := BuildIdempotencyFingerprint("", "", "", map[string]any{"a": 1})
- require.NoError(t, err)
- require.NotEmpty(t, fp1)
- fp2, err := BuildIdempotencyFingerprint("POST", "/", "anonymous", map[string]any{"a": 1})
- require.NoError(t, err)
- require.Equal(t, fp1, fp2)
- _, err = BuildIdempotencyFingerprint("POST", "/x", "u:1", map[string]any{"bad": make(chan int)})
- require.Error(t, err)
- require.Equal(t, infraerrors.Code(ErrIdempotencyInvalidPayload), infraerrors.Code(err))
- }
- func TestRetryAfterSecondsFromErrorBranches(t *testing.T) {
- require.Equal(t, 0, RetryAfterSecondsFromError(nil))
- require.Equal(t, 0, RetryAfterSecondsFromError(errors.New("plain")))
- err := ErrIdempotencyInProgress.WithMetadata(map[string]string{"retry_after": "12"})
- require.Equal(t, 12, RetryAfterSecondsFromError(err))
- err = ErrIdempotencyInProgress.WithMetadata(map[string]string{"retry_after": "bad"})
- require.Equal(t, 0, RetryAfterSecondsFromError(err))
- }
- func TestIdempotencyCoordinator_ExecuteNilExecutorAndNoKeyPassThrough(t *testing.T) {
- repo := newInMemoryIdempotencyRepo()
- coordinator := NewIdempotencyCoordinator(repo, DefaultIdempotencyConfig())
- _, err := coordinator.Execute(context.Background(), IdempotencyExecuteOptions{
- Scope: "scope",
- IdempotencyKey: "k",
- Payload: map[string]any{"a": 1},
- }, nil)
- require.Error(t, err)
- require.Equal(t, "IDEMPOTENCY_EXECUTOR_NIL", infraerrors.Reason(err))
- called := 0
- result, err := coordinator.Execute(context.Background(), IdempotencyExecuteOptions{
- Scope: "scope",
- RequireKey: true,
- Payload: map[string]any{"a": 1},
- }, func(ctx context.Context) (any, error) {
- called++
- return map[string]any{"ok": true}, nil
- })
- require.NoError(t, err)
- require.Equal(t, 1, called)
- require.NotNil(t, result)
- require.False(t, result.Replayed)
- }
- type noIDOwnerRepo struct{}
- func (noIDOwnerRepo) CreateProcessing(context.Context, *IdempotencyRecord) (bool, error) {
- return true, nil
- }
- func (noIDOwnerRepo) GetByScopeAndKeyHash(context.Context, string, string) (*IdempotencyRecord, error) {
- return nil, nil
- }
- func (noIDOwnerRepo) TryReclaim(context.Context, int64, string, time.Time, time.Time, time.Time) (bool, error) {
- return false, nil
- }
- func (noIDOwnerRepo) ExtendProcessingLock(context.Context, int64, string, time.Time, time.Time) (bool, error) {
- return false, nil
- }
- func (noIDOwnerRepo) MarkSucceeded(context.Context, int64, int, string, time.Time) error { return nil }
- func (noIDOwnerRepo) MarkFailedRetryable(context.Context, int64, string, time.Time, time.Time) error {
- return nil
- }
- func (noIDOwnerRepo) DeleteExpired(context.Context, time.Time, int) (int64, error) { return 0, nil }
- func TestIdempotencyCoordinator_RepoNilScopeRequiredAndRecordIDMissing(t *testing.T) {
- cfg := DefaultIdempotencyConfig()
- coordinator := NewIdempotencyCoordinator(nil, cfg)
- _, err := coordinator.Execute(context.Background(), IdempotencyExecuteOptions{
- Scope: "scope",
- IdempotencyKey: "k",
- Payload: map[string]any{"a": 1},
- }, func(ctx context.Context) (any, error) {
- return map[string]any{"ok": true}, nil
- })
- require.Error(t, err)
- require.Equal(t, infraerrors.Code(ErrIdempotencyStoreUnavail), infraerrors.Code(err))
- coordinator = NewIdempotencyCoordinator(newInMemoryIdempotencyRepo(), cfg)
- _, err = coordinator.Execute(context.Background(), IdempotencyExecuteOptions{
- IdempotencyKey: "k2",
- Payload: map[string]any{"a": 1},
- }, func(ctx context.Context) (any, error) {
- return map[string]any{"ok": true}, nil
- })
- require.Error(t, err)
- require.Equal(t, "IDEMPOTENCY_SCOPE_REQUIRED", infraerrors.Reason(err))
- coordinator = NewIdempotencyCoordinator(noIDOwnerRepo{}, cfg)
- _, err = coordinator.Execute(context.Background(), IdempotencyExecuteOptions{
- Scope: "scope-no-id",
- IdempotencyKey: "k3",
- Payload: map[string]any{"a": 1},
- }, func(ctx context.Context) (any, error) {
- return map[string]any{"ok": true}, nil
- })
- require.Error(t, err)
- require.Equal(t, infraerrors.Code(ErrIdempotencyStoreUnavail), infraerrors.Code(err))
- }
- type conflictBranchRepo struct {
- existing *IdempotencyRecord
- tryReclaimErr error
- tryReclaimOK bool
- }
- func (r *conflictBranchRepo) CreateProcessing(context.Context, *IdempotencyRecord) (bool, error) {
- return false, nil
- }
- func (r *conflictBranchRepo) GetByScopeAndKeyHash(context.Context, string, string) (*IdempotencyRecord, error) {
- return cloneRecord(r.existing), nil
- }
- func (r *conflictBranchRepo) TryReclaim(context.Context, int64, string, time.Time, time.Time, time.Time) (bool, error) {
- if r.tryReclaimErr != nil {
- return false, r.tryReclaimErr
- }
- return r.tryReclaimOK, nil
- }
- func (r *conflictBranchRepo) ExtendProcessingLock(context.Context, int64, string, time.Time, time.Time) (bool, error) {
- return false, nil
- }
- func (r *conflictBranchRepo) MarkSucceeded(context.Context, int64, int, string, time.Time) error {
- return nil
- }
- func (r *conflictBranchRepo) MarkFailedRetryable(context.Context, int64, string, time.Time, time.Time) error {
- return nil
- }
- func (r *conflictBranchRepo) DeleteExpired(context.Context, time.Time, int) (int64, error) {
- return 0, nil
- }
- func TestIdempotencyCoordinator_ConflictBranchesAndDecodeError(t *testing.T) {
- now := time.Now()
- fp, err := BuildIdempotencyFingerprint("POST", "/x", "u:1", map[string]any{"a": 1})
- require.NoError(t, err)
- badBody := "{bad-json"
- repo := &conflictBranchRepo{
- existing: &IdempotencyRecord{
- ID: 1,
- Scope: "scope",
- IdempotencyKeyHash: HashIdempotencyKey("k"),
- RequestFingerprint: fp,
- Status: IdempotencyStatusSucceeded,
- ResponseBody: &badBody,
- ExpiresAt: now.Add(time.Hour),
- },
- }
- coordinator := NewIdempotencyCoordinator(repo, DefaultIdempotencyConfig())
- _, err = coordinator.Execute(context.Background(), IdempotencyExecuteOptions{
- Scope: "scope",
- IdempotencyKey: "k",
- Method: "POST",
- Route: "/x",
- ActorScope: "u:1",
- Payload: map[string]any{"a": 1},
- }, func(ctx context.Context) (any, error) {
- return map[string]any{"ok": true}, nil
- })
- require.Error(t, err)
- require.Equal(t, infraerrors.Code(ErrIdempotencyStoreUnavail), infraerrors.Code(err))
- repo.existing = &IdempotencyRecord{
- ID: 2,
- Scope: "scope",
- IdempotencyKeyHash: HashIdempotencyKey("k"),
- RequestFingerprint: fp,
- Status: "unknown",
- ExpiresAt: now.Add(time.Hour),
- }
- _, err = coordinator.Execute(context.Background(), IdempotencyExecuteOptions{
- Scope: "scope",
- IdempotencyKey: "k",
- Method: "POST",
- Route: "/x",
- ActorScope: "u:1",
- Payload: map[string]any{"a": 1},
- }, func(ctx context.Context) (any, error) {
- return map[string]any{"ok": true}, nil
- })
- require.Error(t, err)
- require.Equal(t, infraerrors.Code(ErrIdempotencyKeyConflict), infraerrors.Code(err))
- repo.existing = &IdempotencyRecord{
- ID: 3,
- Scope: "scope",
- IdempotencyKeyHash: HashIdempotencyKey("k"),
- RequestFingerprint: fp,
- Status: IdempotencyStatusFailedRetryable,
- LockedUntil: ptrTime(now.Add(-time.Second)),
- ExpiresAt: now.Add(time.Hour),
- }
- repo.tryReclaimErr = errors.New("reclaim down")
- _, err = coordinator.Execute(context.Background(), IdempotencyExecuteOptions{
- Scope: "scope",
- IdempotencyKey: "k",
- Method: "POST",
- Route: "/x",
- ActorScope: "u:1",
- Payload: map[string]any{"a": 1},
- }, func(ctx context.Context) (any, error) {
- return map[string]any{"ok": true}, nil
- })
- require.Error(t, err)
- require.Equal(t, infraerrors.Code(ErrIdempotencyStoreUnavail), infraerrors.Code(err))
- repo.tryReclaimErr = nil
- repo.tryReclaimOK = false
- _, err = coordinator.Execute(context.Background(), IdempotencyExecuteOptions{
- Scope: "scope",
- IdempotencyKey: "k",
- Method: "POST",
- Route: "/x",
- ActorScope: "u:1",
- Payload: map[string]any{"a": 1},
- }, func(ctx context.Context) (any, error) {
- return map[string]any{"ok": true}, nil
- })
- require.Error(t, err)
- require.Equal(t, infraerrors.Code(ErrIdempotencyInProgress), infraerrors.Code(err))
- }
- type markBehaviorRepo struct {
- inMemoryIdempotencyRepo
- failMarkSucceeded bool
- failMarkFailed bool
- }
- func (r *markBehaviorRepo) MarkSucceeded(ctx context.Context, id int64, responseStatus int, responseBody string, expiresAt time.Time) error {
- if r.failMarkSucceeded {
- return errors.New("mark succeeded failed")
- }
- return r.inMemoryIdempotencyRepo.MarkSucceeded(ctx, id, responseStatus, responseBody, expiresAt)
- }
- func (r *markBehaviorRepo) MarkFailedRetryable(ctx context.Context, id int64, errorReason string, lockedUntil, expiresAt time.Time) error {
- if r.failMarkFailed {
- return errors.New("mark failed retryable failed")
- }
- return r.inMemoryIdempotencyRepo.MarkFailedRetryable(ctx, id, errorReason, lockedUntil, expiresAt)
- }
- func TestIdempotencyCoordinator_MarkAndMarshalBranches(t *testing.T) {
- repo := &markBehaviorRepo{inMemoryIdempotencyRepo: *newInMemoryIdempotencyRepo()}
- coordinator := NewIdempotencyCoordinator(repo, DefaultIdempotencyConfig())
- repo.failMarkSucceeded = true
- _, err := coordinator.Execute(context.Background(), IdempotencyExecuteOptions{
- Scope: "scope-success",
- IdempotencyKey: "k1",
- Method: "POST",
- Route: "/ok",
- ActorScope: "u:1",
- Payload: map[string]any{"a": 1},
- }, func(ctx context.Context) (any, error) {
- return map[string]any{"ok": true}, nil
- })
- require.Error(t, err)
- require.Equal(t, infraerrors.Code(ErrIdempotencyStoreUnavail), infraerrors.Code(err))
- repo.failMarkSucceeded = false
- _, err = coordinator.Execute(context.Background(), IdempotencyExecuteOptions{
- Scope: "scope-marshal",
- IdempotencyKey: "k2",
- Method: "POST",
- Route: "/bad",
- ActorScope: "u:1",
- Payload: map[string]any{"a": 1},
- }, func(ctx context.Context) (any, error) {
- return map[string]any{"bad": make(chan int)}, nil
- })
- require.Error(t, err)
- require.Equal(t, infraerrors.Code(ErrIdempotencyStoreUnavail), infraerrors.Code(err))
- repo.failMarkFailed = true
- _, err = coordinator.Execute(context.Background(), IdempotencyExecuteOptions{
- Scope: "scope-fail",
- IdempotencyKey: "k3",
- Method: "POST",
- Route: "/fail",
- ActorScope: "u:1",
- Payload: map[string]any{"a": 1},
- }, func(ctx context.Context) (any, error) {
- return nil, errors.New("plain failure")
- })
- require.Error(t, err)
- require.Equal(t, "plain failure", err.Error())
- }
- func TestIdempotencyCoordinator_HelperBranches(t *testing.T) {
- c := NewIdempotencyCoordinator(newInMemoryIdempotencyRepo(), IdempotencyConfig{
- DefaultTTL: time.Hour,
- SystemOperationTTL: time.Hour,
- ProcessingTimeout: time.Second,
- FailedRetryBackoff: time.Second,
- MaxStoredResponseLen: 12,
- ObserveOnly: false,
- })
- // conflictWithRetryAfter without locked_until should return base error.
- base := ErrIdempotencyInProgress
- err := c.conflictWithRetryAfter(base, nil, time.Now())
- require.Equal(t, infraerrors.Code(base), infraerrors.Code(err))
- // marshalStoredResponse should truncate.
- body, err := c.marshalStoredResponse(map[string]any{"long": "abcdefghijklmnopqrstuvwxyz"})
- require.NoError(t, err)
- require.Contains(t, body, "...(truncated)")
- // decodeStoredResponse empty and invalid json.
- out, err := c.decodeStoredResponse(nil)
- require.NoError(t, err)
- _, ok := out.(map[string]any)
- require.True(t, ok)
- invalid := "{invalid"
- _, err = c.decodeStoredResponse(&invalid)
- require.Error(t, err)
- }
|