diff --git a/providers/aws/internal/purchasecfg/purchasetest/purchasetest.go b/providers/aws/internal/purchasecfg/purchasetest/purchasetest.go new file mode 100644 index 00000000..6bb92c67 --- /dev/null +++ b/providers/aws/internal/purchasecfg/purchasetest/purchasetest.go @@ -0,0 +1,79 @@ +// Package purchasetest is imported only by tests. It holds the shared empty-token PurchaseCommitment cases +// for the services whose purchase API has no idempotency token of its own +// (see go#299). It is test support only. +package purchasetest + +import ( + "errors" + "net" + "net/http" + "testing" + + "github.com/LeanerCloud/cloud-commitments-go/pkg/common" + awshttp "github.com/aws/aws-sdk-go-v2/aws/transport/http" + "github.com/aws/smithy-go" + smithyhttp "github.com/aws/smithy-go/transport/http" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +// Call runs one PurchaseCommitment against a fake whose Purchase API returns +// apiErr (or an empty success when emptyResp is set, or a normal success when +// both are zero). It returns the RetryMaxAttempts the call asked for (0 when +// no per-operation option was passed). +type Call func(t *testing.T, token string, apiErr error, emptyResp bool) (attempts int, result common.PurchaseResult, err error) + +// RespErr wraps inner with an HTTP status; a bare typed fault classifies as unknown. +func RespErr(status int, inner error) error { + return &awshttp.ResponseError{ResponseError: &smithyhttp.ResponseError{ + Response: &smithyhttp.Response{Response: &http.Response{StatusCode: status}}, + Err: inner, + }} +} + +// Pick returns the fake output: nil on apiErr, empty if emptyResp, else ok. +func Pick[T any](ok, empty *T, apiErr error, emptyResp bool) *T { + switch { + case apiErr != nil: + return nil + case emptyResp: + return empty + } + return ok +} + +// RunEmptyToken checks the go#299 behavior. alreadyExists is the service's +// typed AlreadyExists fault. +func RunEmptyToken(t *testing.T, call Call, alreadyExists error) { + t.Helper() + attempts, result, err := call(t, "", nil, false) + require.NoError(t, err) + assert.True(t, result.Success) + assert.Equal(t, 1, attempts, "empty token must send once") + attempts, _, err = call(t, "exec-token", nil, false) + require.NoError(t, err) + assert.Equal(t, 0, attempts, "token path must keep the client default retries") + + lost := &net.OpError{Op: "read", Err: errors.New("connection reset")} + for _, tc := range []struct { + name, token string + apiErr error + emptyResp bool + unknown bool + }{ + {"transport error", "", lost, false, true}, + {"5xx", "", RespErr(http.StatusInternalServerError, &smithy.GenericAPIError{Code: "InternalFailure"}), false, true}, + {"empty response", "", nil, true, true}, + {"400 AlreadyExists", "", RespErr(http.StatusBadRequest, alreadyExists), false, false}, + {"throttle", "", RespErr(http.StatusServiceUnavailable, &smithy.GenericAPIError{Code: "Throttling"}), false, false}, + {"token: transport error", "exec-token", lost, false, false}, + {"token: empty response", "exec-token", nil, true, false}, + } { + t.Run(tc.name, func(t *testing.T) { + _, result, err := call(t, tc.token, tc.apiErr, tc.emptyResp) + require.Error(t, err) + assert.False(t, result.Success) + assert.Equal(t, tc.unknown, errors.Is(err, common.ErrOutcomeUnknown)) + }) + } +} diff --git a/providers/aws/internal/purchasecfg/tokenless.go b/providers/aws/internal/purchasecfg/tokenless.go new file mode 100644 index 00000000..006349b7 --- /dev/null +++ b/providers/aws/internal/purchasecfg/tokenless.go @@ -0,0 +1,34 @@ +package purchasecfg + +import ( + "fmt" + + "github.com/LeanerCloud/cloud-commitments-go/pkg/common" +) + +// These apply only without an idempotency token (go#299; precedent: ec2). + +// SingleAttemptIf returns the per-operation option that disables SDK retries +// when token is empty, and no option otherwise. +func SingleAttemptIf[O any](token string, set func(*O)) []func(*O) { + if token != "" { + return nil + } + return []func(*O){set} +} + +// ClassifyUnlessToken returns err unchanged when token is set, else ClassifyPurchaseError(err). +func ClassifyUnlessToken(token string, err error) error { + if token != "" { + return err + } + return ClassifyPurchaseError(err) +} + +// UnknownUnlessToken wraps err with common.ErrOutcomeUnknown when token is empty. +func UnknownUnlessToken(token string, err error) error { + if token != "" { + return err + } + return fmt.Errorf("%w: %w", common.ErrOutcomeUnknown, err) +} diff --git a/providers/aws/internal/purchasecfg/tokenless_test.go b/providers/aws/internal/purchasecfg/tokenless_test.go new file mode 100644 index 00000000..c19fe7d6 --- /dev/null +++ b/providers/aws/internal/purchasecfg/tokenless_test.go @@ -0,0 +1,23 @@ +package purchasecfg + +import ( + "errors" + "testing" + + "github.com/LeanerCloud/cloud-commitments-go/pkg/common" + "github.com/stretchr/testify/assert" +) + +type opts struct{ n int } + +func TestTokenlessHelpers(t *testing.T) { + set := func(o *opts) { o.n = 1 } + assert.Len(t, SingleAttemptIf("", set), 1) + assert.Nil(t, SingleAttemptIf("tok", set)) + + plain := errors.New("boom") + assert.ErrorIs(t, ClassifyUnlessToken("", plain), common.ErrOutcomeUnknown) + assert.Equal(t, plain, ClassifyUnlessToken("tok", plain)) + assert.ErrorIs(t, UnknownUnlessToken("", plain), common.ErrOutcomeUnknown) + assert.Equal(t, plain, UnknownUnlessToken("tok", plain)) +} diff --git a/providers/aws/services/elasticache/client.go b/providers/aws/services/elasticache/client.go index ff58c35e..b9b31403 100644 --- a/providers/aws/services/elasticache/client.go +++ b/providers/aws/services/elasticache/client.go @@ -171,7 +171,10 @@ func (c *Client) PurchaseCommitment(ctx context.Context, rec common.Recommendati Tags: c.createPurchaseTags(rec, opts.Source), } - response, err := c.client.PurchaseReservedCacheNodesOffering(ctx, input) + // With no token the API cannot dedupe, so an SDK retry after a lost response + // could re-send the purchase: send once and report a lost response as + // unknown (precedent: ec2/client.go). + response, err := c.client.PurchaseReservedCacheNodesOffering(ctx, input, purchasecfg.SingleAttemptIf(opts.IdempotencyToken, func(o *elasticache.Options) { o.RetryMaxAttempts = 1 })...) if err != nil { if existingID, recovered := c.recoverAlreadyExists(ctx, opts.IdempotencyToken, reservationID, err); recovered { result.Success = true @@ -179,6 +182,7 @@ func (c *Client) PurchaseCommitment(ctx context.Context, rec common.Recommendati result.CommitmentID = existingID return result, nil } + err = purchasecfg.ClassifyUnlessToken(opts.IdempotencyToken, err) result.Error = fmt.Errorf("failed to purchase Reserved Cache Node: %w", err) return result, result.Error } @@ -192,7 +196,7 @@ func (c *Client) PurchaseCommitment(ctx context.Context, rec common.Recommendati result.Cost = &total } } else { - result.Error = fmt.Errorf("purchase response was empty") + result.Error = purchasecfg.UnknownUnlessToken(opts.IdempotencyToken, fmt.Errorf("purchase response was empty")) return result, result.Error } diff --git a/providers/aws/services/elasticache/purchase_empty_token_test.go b/providers/aws/services/elasticache/purchase_empty_token_test.go new file mode 100644 index 00000000..bc51c8ca --- /dev/null +++ b/providers/aws/services/elasticache/purchase_empty_token_test.go @@ -0,0 +1,58 @@ +package elasticache + +import ( + "context" + "testing" + + "github.com/LeanerCloud/cloud-commitments-go/pkg/common" + "github.com/LeanerCloud/cloud-commitments-go/providers/aws/internal/purchasecfg/purchasetest" + "github.com/aws/aws-sdk-go-v2/aws" + "github.com/aws/aws-sdk-go-v2/service/elasticache" + "github.com/aws/aws-sdk-go-v2/service/elasticache/types" + "github.com/stretchr/testify/mock" +) + +// optRecordingElastiCache records the RetryMaxAttempts the per-operation options set. +type optRecordingElastiCache struct { + *MockElastiCacheClient + attempts int +} + +func (r *optRecordingElastiCache) PurchaseReservedCacheNodesOffering(ctx context.Context, p *elasticache.PurchaseReservedCacheNodesOfferingInput, optFns ...func(*elasticache.Options)) (*elasticache.PurchaseReservedCacheNodesOfferingOutput, error) { + var o elasticache.Options + for _, f := range optFns { + f(&o) + } + r.attempts = o.RetryMaxAttempts + return r.MockElastiCacheClient.PurchaseReservedCacheNodesOffering(ctx, p, optFns...) +} + +func TestPurchaseCommitment_EmptyToken(t *testing.T) { + call := func(t *testing.T, token string, apiErr error, emptyResp bool) (int, common.PurchaseResult, error) { + m := &MockElastiCacheClient{} + rec := common.Recommendation{ + Service: common.ServiceCache, ResourceType: "cache.m5.large", Count: 1, + PaymentOption: "all-upfront", Term: "1yr", + Details: &common.CacheDetails{Engine: "redis", NodeType: "cache.m5.large"}, + } + m.On("DescribeReservedCacheNodesOfferings", mock.Anything, mock.Anything). + Return(&elasticache.DescribeReservedCacheNodesOfferingsOutput{ + ReservedCacheNodesOfferings: []types.ReservedCacheNodesOffering{{ + ReservedCacheNodesOfferingId: aws.String("offering-123"), + CacheNodeType: aws.String("cache.m5.large"), + ProductDescription: aws.String("redis"), + OfferingType: aws.String("All Upfront"), + Duration: aws.Int32(31536000), + }}, + }, nil) + m.On("DescribeReservedCacheNodes", mock.Anything, mock.Anything). + Return(&elasticache.DescribeReservedCacheNodesOutput{}, nil) + out := purchasetest.Pick(&elasticache.PurchaseReservedCacheNodesOfferingOutput{ReservedCacheNode: &types.ReservedCacheNode{ReservedCacheNodeId: aws.String("rn-1")}}, + &elasticache.PurchaseReservedCacheNodesOfferingOutput{}, apiErr, emptyResp) + m.On("PurchaseReservedCacheNodesOffering", mock.Anything, mock.Anything).Return(out, apiErr) + rec2 := &optRecordingElastiCache{MockElastiCacheClient: m} + res, err := (&Client{client: rec2, region: "us-east-1"}).PurchaseCommitment(context.Background(), rec, common.PurchaseOptions{IdempotencyToken: token}) + return rec2.attempts, res, err + } + purchasetest.RunEmptyToken(t, call, &types.ReservedCacheNodeAlreadyExistsFault{}) +} diff --git a/providers/aws/services/memorydb/client.go b/providers/aws/services/memorydb/client.go index c4342ec4..03cee910 100644 --- a/providers/aws/services/memorydb/client.go +++ b/providers/aws/services/memorydb/client.go @@ -166,7 +166,10 @@ func (c *Client) PurchaseCommitment(ctx context.Context, rec common.Recommendati Tags: c.createPurchaseTags(rec, opts.Source), } - response, err := c.client.PurchaseReservedNodesOffering(ctx, input) + // With no token the API cannot dedupe, so an SDK retry after a lost response + // could re-send the purchase: send once and report a lost response as + // unknown (precedent: ec2/client.go). + response, err := c.client.PurchaseReservedNodesOffering(ctx, input, purchasecfg.SingleAttemptIf(opts.IdempotencyToken, func(o *memorydb.Options) { o.RetryMaxAttempts = 1 })...) if err != nil { if existingID, recovered := c.recoverAlreadyExists(ctx, opts.IdempotencyToken, reservationID, err); recovered { result.Success = true @@ -174,6 +177,7 @@ func (c *Client) PurchaseCommitment(ctx context.Context, rec common.Recommendati result.CommitmentID = existingID return result, nil } + err = purchasecfg.ClassifyUnlessToken(opts.IdempotencyToken, err) result.Error = fmt.Errorf("failed to purchase MemoryDB Reserved Nodes: %w", err) return result, result.Error } @@ -188,7 +192,7 @@ func (c *Client) PurchaseCommitment(ctx context.Context, rec common.Recommendati result.Cost = &total } } else { - result.Error = fmt.Errorf("purchase response was empty") + result.Error = purchasecfg.UnknownUnlessToken(opts.IdempotencyToken, fmt.Errorf("purchase response was empty")) return result, result.Error } diff --git a/providers/aws/services/memorydb/purchase_empty_token_test.go b/providers/aws/services/memorydb/purchase_empty_token_test.go new file mode 100644 index 00000000..512dc4d7 --- /dev/null +++ b/providers/aws/services/memorydb/purchase_empty_token_test.go @@ -0,0 +1,57 @@ +package memorydb + +import ( + "context" + "testing" + + "github.com/LeanerCloud/cloud-commitments-go/pkg/common" + "github.com/LeanerCloud/cloud-commitments-go/providers/aws/internal/purchasecfg/purchasetest" + "github.com/aws/aws-sdk-go-v2/aws" + "github.com/aws/aws-sdk-go-v2/service/memorydb" + "github.com/aws/aws-sdk-go-v2/service/memorydb/types" + "github.com/stretchr/testify/mock" +) + +// optRecordingMemoryDB records the RetryMaxAttempts the per-operation options set. +type optRecordingMemoryDB struct { + *MockMemoryDBClient + attempts int +} + +func (r *optRecordingMemoryDB) PurchaseReservedNodesOffering(ctx context.Context, p *memorydb.PurchaseReservedNodesOfferingInput, optFns ...func(*memorydb.Options)) (*memorydb.PurchaseReservedNodesOfferingOutput, error) { + var o memorydb.Options + for _, f := range optFns { + f(&o) + } + r.attempts = o.RetryMaxAttempts + return r.MockMemoryDBClient.PurchaseReservedNodesOffering(ctx, p, optFns...) +} + +func TestPurchaseCommitment_EmptyToken(t *testing.T) { + call := func(t *testing.T, token string, apiErr error, emptyResp bool) (int, common.PurchaseResult, error) { + m := &MockMemoryDBClient{} + rec := common.Recommendation{ + Service: common.ServiceCache, ResourceType: "db.r6gd.xlarge", Count: 1, + PaymentOption: "partial-upfront", Term: "1yr", + Details: &common.CacheDetails{Engine: "redis", NodeType: "db.r6gd.xlarge"}, + } + m.On("DescribeReservedNodesOfferings", mock.Anything, mock.Anything). + Return(&memorydb.DescribeReservedNodesOfferingsOutput{ + ReservedNodesOfferings: []types.ReservedNodesOffering{{ + ReservedNodesOfferingId: aws.String("offering-123"), + NodeType: aws.String("db.r6gd.xlarge"), + Duration: 31536000, + OfferingType: aws.String("Partial Upfront"), + }}, + }, nil) + m.On("DescribeReservedNodes", mock.Anything, mock.Anything). + Return(&memorydb.DescribeReservedNodesOutput{}, nil) + out := purchasetest.Pick(&memorydb.PurchaseReservedNodesOfferingOutput{ReservedNode: &types.ReservedNode{ReservationId: aws.String("rn-1")}}, + &memorydb.PurchaseReservedNodesOfferingOutput{}, apiErr, emptyResp) + m.On("PurchaseReservedNodesOffering", mock.Anything, mock.Anything).Return(out, apiErr) + rec2 := &optRecordingMemoryDB{MockMemoryDBClient: m} + res, err := (&Client{client: rec2, region: "us-east-1"}).PurchaseCommitment(context.Background(), rec, common.PurchaseOptions{IdempotencyToken: token}) + return rec2.attempts, res, err + } + purchasetest.RunEmptyToken(t, call, &types.ReservedNodeAlreadyExistsFault{}) +} diff --git a/providers/aws/services/opensearch/client.go b/providers/aws/services/opensearch/client.go index 959e0011..f6f976f1 100644 --- a/providers/aws/services/opensearch/client.go +++ b/providers/aws/services/opensearch/client.go @@ -208,7 +208,10 @@ func (c *Client) PurchaseCommitment(ctx context.Context, rec common.Recommendati InstanceCount: aws.Int32(instanceCount), } - response, err := c.client.PurchaseReservedInstanceOffering(ctx, input) + // With no token the API cannot dedupe, so an SDK retry after a lost response + // could re-send the purchase: send once and report a lost response as + // unknown (precedent: ec2/client.go). + response, err := c.client.PurchaseReservedInstanceOffering(ctx, input, purchasecfg.SingleAttemptIf(opts.IdempotencyToken, func(o *opensearch.Options) { o.RetryMaxAttempts = 1 })...) if err != nil { if existingID, recovered := c.recoverAlreadyExists(ctx, opts.IdempotencyToken, reservationName, err); recovered { result.Success = true @@ -216,6 +219,7 @@ func (c *Client) PurchaseCommitment(ctx context.Context, rec common.Recommendati result.CommitmentID = existingID return result, nil } + err = purchasecfg.ClassifyUnlessToken(opts.IdempotencyToken, err) result.Error = fmt.Errorf("failed to purchase OpenSearch RI: %w", err) return result, result.Error } @@ -224,7 +228,7 @@ func (c *Client) PurchaseCommitment(ctx context.Context, rec common.Recommendati result.Success = true result.CommitmentID = aws.ToString(response.ReservedInstanceId) } else { - result.Error = fmt.Errorf("purchase response was empty") + result.Error = purchasecfg.UnknownUnlessToken(opts.IdempotencyToken, fmt.Errorf("purchase response was empty")) return result, result.Error } diff --git a/providers/aws/services/opensearch/purchase_empty_token_test.go b/providers/aws/services/opensearch/purchase_empty_token_test.go new file mode 100644 index 00000000..45d5084b --- /dev/null +++ b/providers/aws/services/opensearch/purchase_empty_token_test.go @@ -0,0 +1,58 @@ +package opensearch + +import ( + "context" + "testing" + + "github.com/LeanerCloud/cloud-commitments-go/pkg/common" + "github.com/LeanerCloud/cloud-commitments-go/providers/aws/internal/purchasecfg/purchasetest" + "github.com/aws/aws-sdk-go-v2/aws" + "github.com/aws/aws-sdk-go-v2/service/opensearch" + "github.com/aws/aws-sdk-go-v2/service/opensearch/types" + "github.com/stretchr/testify/mock" +) + +// optRecordingOpenSearch records the RetryMaxAttempts the per-operation options set. +type optRecordingOpenSearch struct { + *MockOpenSearchClient + attempts int +} + +func (r *optRecordingOpenSearch) PurchaseReservedInstanceOffering(ctx context.Context, p *opensearch.PurchaseReservedInstanceOfferingInput, optFns ...func(*opensearch.Options)) (*opensearch.PurchaseReservedInstanceOfferingOutput, error) { + var o opensearch.Options + for _, f := range optFns { + f(&o) + } + r.attempts = o.RetryMaxAttempts + return r.MockOpenSearchClient.PurchaseReservedInstanceOffering(ctx, p, optFns...) +} + +func TestPurchaseCommitment_EmptyToken(t *testing.T) { + call := func(t *testing.T, token string, apiErr error, emptyResp bool) (int, common.PurchaseResult, error) { + m := &MockOpenSearchClient{} + rec := common.Recommendation{ + Service: common.ServiceSearch, ResourceType: "m5.large.search", Count: 1, + PaymentOption: "partial-upfront", Term: "1yr", + Details: common.SearchDetails{InstanceType: "m5.large.search"}, + } + m.On("DescribeReservedInstanceOfferings", mock.Anything, mock.Anything). + Return(&opensearch.DescribeReservedInstanceOfferingsOutput{ + ReservedInstanceOfferings: []types.ReservedInstanceOffering{{ + ReservedInstanceOfferingId: aws.String("offering-123"), + InstanceType: types.OpenSearchPartitionInstanceTypeM5LargeSearch, + Duration: 31536000, + PaymentOption: types.ReservedInstancePaymentOptionPartialUpfront, + }}, + }, nil) + m.On("DescribeReservedInstances", mock.Anything, mock.Anything). + Return(&opensearch.DescribeReservedInstancesOutput{}, nil) + m.On("AddTags", mock.Anything, mock.Anything).Return(&opensearch.AddTagsOutput{}, nil).Maybe() + out := purchasetest.Pick(&opensearch.PurchaseReservedInstanceOfferingOutput{ReservedInstanceId: aws.String("ri-1")}, + &opensearch.PurchaseReservedInstanceOfferingOutput{}, apiErr, emptyResp) + m.On("PurchaseReservedInstanceOffering", mock.Anything, mock.Anything).Return(out, apiErr) + rec2 := &optRecordingOpenSearch{MockOpenSearchClient: m} + res, err := (&Client{client: rec2, region: "us-east-1"}).PurchaseCommitment(context.Background(), rec, common.PurchaseOptions{IdempotencyToken: token}) + return rec2.attempts, res, err + } + purchasetest.RunEmptyToken(t, call, &types.ResourceAlreadyExistsException{}) +} diff --git a/providers/aws/services/rds/client.go b/providers/aws/services/rds/client.go index c8887656..57973a0f 100644 --- a/providers/aws/services/rds/client.go +++ b/providers/aws/services/rds/client.go @@ -165,7 +165,10 @@ func (c *Client) PurchaseCommitment(ctx context.Context, rec common.Recommendati Tags: c.createPurchaseTags(rec, opts.Source), } - response, err := c.client.PurchaseReservedDBInstancesOffering(ctx, input) + // With no token the API cannot dedupe, so an SDK retry after a lost response + // could re-send the purchase: send once and report a lost response as + // unknown (precedent: ec2/client.go). + response, err := c.client.PurchaseReservedDBInstancesOffering(ctx, input, purchasecfg.SingleAttemptIf(opts.IdempotencyToken, func(o *rds.Options) { o.RetryMaxAttempts = 1 })...) if err != nil { if existingID, recovered := c.recoverAlreadyExists(ctx, opts.IdempotencyToken, reservationID, err); recovered { result.Success = true @@ -173,6 +176,7 @@ func (c *Client) PurchaseCommitment(ctx context.Context, rec common.Recommendati result.CommitmentID = existingID return result, nil } + err = purchasecfg.ClassifyUnlessToken(opts.IdempotencyToken, err) result.Error = fmt.Errorf("failed to purchase RDS RI: %w", err) return result, result.Error } @@ -186,7 +190,7 @@ func (c *Client) PurchaseCommitment(ctx context.Context, rec common.Recommendati result.Cost = &total } } else { - result.Error = fmt.Errorf("purchase response was empty") + result.Error = purchasecfg.UnknownUnlessToken(opts.IdempotencyToken, fmt.Errorf("purchase response was empty")) return result, result.Error } diff --git a/providers/aws/services/rds/purchase_empty_token_test.go b/providers/aws/services/rds/purchase_empty_token_test.go new file mode 100644 index 00000000..58ad52fd --- /dev/null +++ b/providers/aws/services/rds/purchase_empty_token_test.go @@ -0,0 +1,59 @@ +package rds + +import ( + "context" + "testing" + + "github.com/LeanerCloud/cloud-commitments-go/pkg/common" + "github.com/LeanerCloud/cloud-commitments-go/providers/aws/internal/purchasecfg/purchasetest" + "github.com/aws/aws-sdk-go-v2/aws" + "github.com/aws/aws-sdk-go-v2/service/rds" + "github.com/aws/aws-sdk-go-v2/service/rds/types" + "github.com/stretchr/testify/mock" +) + +// optRecordingRDS records the RetryMaxAttempts the per-operation options set. +type optRecordingRDS struct { + *MockRDSClient + attempts int +} + +func (r *optRecordingRDS) PurchaseReservedDBInstancesOffering(ctx context.Context, p *rds.PurchaseReservedDBInstancesOfferingInput, optFns ...func(*rds.Options)) (*rds.PurchaseReservedDBInstancesOfferingOutput, error) { + var o rds.Options + for _, f := range optFns { + f(&o) + } + r.attempts = o.RetryMaxAttempts + return r.MockRDSClient.PurchaseReservedDBInstancesOffering(ctx, p, optFns...) +} + +func TestPurchaseCommitment_EmptyToken(t *testing.T) { + call := func(t *testing.T, token string, apiErr error, emptyResp bool) (int, common.PurchaseResult, error) { + m := &MockRDSClient{} + rec := common.Recommendation{ + Service: common.ServiceRelationalDB, ResourceType: "db.t3.micro", Count: 1, + PaymentOption: "all-upfront", Term: "1yr", + Details: &common.DatabaseDetails{Engine: "mysql", AZConfig: "single-az"}, + } + m.On("DescribeReservedDBInstancesOfferings", mock.Anything, mock.Anything). + Return(&rds.DescribeReservedDBInstancesOfferingsOutput{ + ReservedDBInstancesOfferings: []types.ReservedDBInstancesOffering{{ + ReservedDBInstancesOfferingId: aws.String("offering-123"), + DBInstanceClass: aws.String("db.t3.micro"), + ProductDescription: aws.String("mysql"), + MultiAZ: aws.Bool(false), + OfferingType: aws.String("All Upfront"), + Duration: aws.Int32(31536000), + }}, + }, nil) + m.On("DescribeReservedDBInstances", mock.Anything, mock.Anything). + Return(&rds.DescribeReservedDBInstancesOutput{}, nil) + out := purchasetest.Pick(&rds.PurchaseReservedDBInstancesOfferingOutput{ReservedDBInstance: &types.ReservedDBInstance{ReservedDBInstanceId: aws.String("ri-1")}}, + &rds.PurchaseReservedDBInstancesOfferingOutput{}, apiErr, emptyResp) + m.On("PurchaseReservedDBInstancesOffering", mock.Anything, mock.Anything).Return(out, apiErr) + rec2 := &optRecordingRDS{MockRDSClient: m} + res, err := (&Client{client: rec2, region: "us-east-1"}).PurchaseCommitment(context.Background(), rec, common.PurchaseOptions{IdempotencyToken: token}) + return rec2.attempts, res, err + } + purchasetest.RunEmptyToken(t, call, &types.ReservedDBInstanceAlreadyExistsFault{}) +}