diff --git a/CHANGELOG.md b/CHANGELOG.md index d0631603..5ca93e67 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -33,7 +33,8 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/). ### Added - **`common.ErrRefusedBeforePurchase`:** a sentinel for a purchase refused before - the call sent any purchase request, detectable with `errors.Is`. + the call sent any purchase request, detectable with `errors.Is`. The AWS EC2, RDS, + OpenSearch, Redshift and MemoryDB approved-upfront guard refusals wrap it. - **Azure `NewPartialSubscriptionFailure` constructor:** builds a `PartialSubscriptionFailureError`, optionally with skip reasons readable through `Skips()`, so code outside the module can construct one, for example diff --git a/providers/aws/go.mod b/providers/aws/go.mod index 55a9e443..d63423f6 100644 --- a/providers/aws/go.mod +++ b/providers/aws/go.mod @@ -3,7 +3,7 @@ module github.com/LeanerCloud/cloud-commitments-go/providers/aws go 1.26.10 require ( - github.com/LeanerCloud/cloud-commitments-go/pkg v0.0.0-20261020191912-205cf99b8ef3 + github.com/LeanerCloud/cloud-commitments-go/pkg v0.0.0-20261104063122-3efa339480ac github.com/aws/aws-sdk-go-v2 v1.47.4 github.com/aws/aws-sdk-go-v2/config v1.33.10 github.com/aws/aws-sdk-go-v2/credentials v1.20.10 @@ -34,5 +34,5 @@ require ( github.com/aws/aws-sdk-go-v2/service/ssooidc v1.43.5 // indirect github.com/stretchr/objx v0.5.3 // indirect go.yaml.in/yaml/v3 v3.0.5 // indirect - golang.org/x/sys v0.47.0 // indirect + golang.org/x/sys v0.49.0 // indirect ) diff --git a/providers/aws/go.sum b/providers/aws/go.sum index c249a8f8..f246862e 100644 --- a/providers/aws/go.sum +++ b/providers/aws/go.sum @@ -1,5 +1,5 @@ -github.com/LeanerCloud/cloud-commitments-go/pkg v0.0.0-20261020191912-205cf99b8ef3 h1:NBjYpYKzw1mRs77nRaCya+HcuJdV6SaPCJEbJVbza0U= -github.com/LeanerCloud/cloud-commitments-go/pkg v0.0.0-20261020191912-205cf99b8ef3/go.mod h1:awNjqN4KJHvFvb2wzKWrwXK954pkHtevPl0TtYkS2io= +github.com/LeanerCloud/cloud-commitments-go/pkg v0.0.0-20261104063122-3efa339480ac h1:EvEUCt1uH5oBtPcJJPt1mX6XYQWAKtL9QBSo9CfeaOw= +github.com/LeanerCloud/cloud-commitments-go/pkg v0.0.0-20261104063122-3efa339480ac/go.mod h1:pseCFRgm1rzBKomktjw6E0ayxmVE7XfHHiOmew4ka+g= github.com/aws/aws-sdk-go-v2 v1.47.4 h1:bTvk6UdSSSvaQsfLVj1JRDEEqj/PDRwGf1KanaRzau0= github.com/aws/aws-sdk-go-v2 v1.47.4/go.mod h1:XkrjAZvr705Nytv6PkPUhoSB2W9+vVHBp5oAWpkh8Ak= github.com/aws/aws-sdk-go-v2/config v1.33.10 h1:RRb0GghYqq2ikMXzfT4r9NO9lVhOld3FIhJJWDIdk+w= @@ -54,5 +54,5 @@ go.yaml.in/yaml/v3 v3.0.5 h1:N6y/pJk8buWs9NY5ERU2HSMfm+IuD/OtfdAnq6kESPw= go.yaml.in/yaml/v3 v3.0.5/go.mod h1:HVTZu1O7/Vkt2N+BFy8Zza+lnLsABggaTM2ZpNIGuKg= golang.org/x/sync v0.24.0 h1:iqiDrGoDgyuvAKrhLQthUEzU/R378N78gXbBjxed7oA= golang.org/x/sync v0.24.0/go.mod h1:sUUOizhqBxiL6pEWpqNLUiaJn1ShEbZ6BBqskPbjZm0= -golang.org/x/sys v0.47.0 h1:o7XGOvZQCADBQQ4Y7VNq2dRWQR7JmOUW8Kxx4ZsNgWs= -golang.org/x/sys v0.47.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw= +golang.org/x/sys v0.49.0 h1:XbzkgYJHdqh/8m2Uu0W/dQv8nktxx4BFHp1M0gROTXA= +golang.org/x/sys v0.49.0/go.mod h1:hNLxWAXmnKAxqDtdwIYC4bM9oQPEecfsnNMuSxOs3og= diff --git a/providers/aws/services/ec2/client.go b/providers/aws/services/ec2/client.go index 6642300c..d6fea4b5 100644 --- a/providers/aws/services/ec2/client.go +++ b/providers/aws/services/ec2/client.go @@ -168,7 +168,7 @@ func (c *Client) PurchaseCommitment(ctx context.Context, rec common.Recommendati } if err = checkApprovedUpfront(rec, offering); err != nil { - result.Error = err + result.Error = fmt.Errorf("%w: %w", common.ErrRefusedBeforePurchase, err) return result, result.Error } diff --git a/providers/aws/services/ec2/client_test.go b/providers/aws/services/ec2/client_test.go index 90aafd1b..5eda8754 100644 --- a/providers/aws/services/ec2/client_test.go +++ b/providers/aws/services/ec2/client_test.go @@ -1724,6 +1724,7 @@ func TestPurchaseCommitment_ExistingCommitmentFlag(t *testing.T) { result, err := client.PurchaseCommitment(context.Background(), rec, common.PurchaseOptions{IdempotencyToken: token}) require.Error(t, err) + assert.NotErrorIs(t, err, common.ErrRefusedBeforePurchase) assert.False(t, result.Success) assert.False(t, result.ExistingCommitment) }) diff --git a/providers/aws/services/ec2/purchase_price_guard_test.go b/providers/aws/services/ec2/purchase_price_guard_test.go index 19ca6be5..595ecca5 100644 --- a/providers/aws/services/ec2/purchase_price_guard_test.go +++ b/providers/aws/services/ec2/purchase_price_guard_test.go @@ -37,6 +37,8 @@ func TestPurchaseCommitment_RefusesOfferingAboveApprovedUpfront(t *testing.T) { m := offeringsMock(pricedOffering(600)) res, err := (&Client{client: m, region: "us-east-1"}).PurchaseCommitment(context.Background(), pricedRec(), common.PurchaseOptions{}) require.ErrorContains(t, err, "exceeds the approved upfront") + assert.ErrorIs(t, err, common.ErrRefusedBeforePurchase) + assert.ErrorIs(t, res.Error, common.ErrRefusedBeforePurchase) assert.False(t, res.Success) m.AssertNotCalled(t, "PurchaseReservedInstancesOffering", mock.Anything, mock.Anything) } diff --git a/providers/aws/services/memorydb/client.go b/providers/aws/services/memorydb/client.go index 556679ec..42e4c28b 100644 --- a/providers/aws/services/memorydb/client.go +++ b/providers/aws/services/memorydb/client.go @@ -170,7 +170,7 @@ func (c *Client) PurchaseCommitment(ctx context.Context, rec common.Recommendati // After the dedupe: a re-drive that already bought must not be refused for a // later price rise (ec2/client.go does the same). if err = c.checkApprovedUpfront(ctx, rec, offeringID); err != nil { - result.Error = err + result.Error = fmt.Errorf("%w: %w", common.ErrRefusedBeforePurchase, err) return result, result.Error } diff --git a/providers/aws/services/memorydb/purchase_guard_test.go b/providers/aws/services/memorydb/purchase_guard_test.go index e3b3a0ea..f4bb5b80 100644 --- a/providers/aws/services/memorydb/purchase_guard_test.go +++ b/providers/aws/services/memorydb/purchase_guard_test.go @@ -132,6 +132,8 @@ func TestPurchaseGuard_OtherPurchaseErrorNotRecovered(t *testing.T) { result, err := guardClient(m).PurchaseCommitment(context.Background(), mdbIdemRec(), common.PurchaseOptions{IdempotencyToken: common.DeriveIdempotencyToken("exec-other", 0)}) require.Error(t, err) + assert.NotErrorIs(t, err, common.ErrRefusedBeforePurchase) + assert.NotErrorIs(t, result.Error, common.ErrRefusedBeforePurchase) assert.False(t, result.Success) m.AssertNumberOfCalls(t, "DescribeReservedNodes", 1) } diff --git a/providers/aws/services/memorydb/purchase_price_guard_test.go b/providers/aws/services/memorydb/purchase_price_guard_test.go index 67eba173..b149a425 100644 --- a/providers/aws/services/memorydb/purchase_price_guard_test.go +++ b/providers/aws/services/memorydb/purchase_price_guard_test.go @@ -63,6 +63,8 @@ func TestPurchaseCommitment_RefusesOfferingAboveApprovedUpfront(t *testing.T) { m := guardMock(525.01) // 2 x 525.01 = 1050.02 > ceiling 1050.00 res, err := buy(m, guardRec()) require.ErrorContains(t, err, "exceeds the approved upfront") + assert.ErrorIs(t, err, common.ErrRefusedBeforePurchase) + assert.ErrorIs(t, res.Error, common.ErrRefusedBeforePurchase) assert.ErrorContains(t, err, "1050.02") assert.ErrorContains(t, err, "1000.00") assert.False(t, res.Success) diff --git a/providers/aws/services/opensearch/client.go b/providers/aws/services/opensearch/client.go index 66ff62f3..9dcfee01 100644 --- a/providers/aws/services/opensearch/client.go +++ b/providers/aws/services/opensearch/client.go @@ -200,7 +200,7 @@ func (c *Client) PurchaseCommitment(ctx context.Context, rec common.Recommendati // After the dedupe: a re-drive that already bought must not be refused for a // later price rise (ec2/client.go does the same). if err = c.checkApprovedUpfront(ctx, rec, offeringID, instanceCount); err != nil { - result.Error = err + result.Error = fmt.Errorf("%w: %w", common.ErrRefusedBeforePurchase, err) return result, result.Error } diff --git a/providers/aws/services/opensearch/purchase_guard_test.go b/providers/aws/services/opensearch/purchase_guard_test.go index a7509de5..12310b39 100644 --- a/providers/aws/services/opensearch/purchase_guard_test.go +++ b/providers/aws/services/opensearch/purchase_guard_test.go @@ -132,6 +132,8 @@ func TestPurchaseGuard_OtherPurchaseErrorNotRecovered(t *testing.T) { result, err := guardClient(m).PurchaseCommitment(context.Background(), osIdemRec(), common.PurchaseOptions{IdempotencyToken: common.DeriveIdempotencyToken("exec-other", 0)}) require.Error(t, err) + assert.NotErrorIs(t, err, common.ErrRefusedBeforePurchase) + assert.NotErrorIs(t, result.Error, common.ErrRefusedBeforePurchase) assert.False(t, result.Success) m.AssertNumberOfCalls(t, "DescribeReservedInstances", 1) } diff --git a/providers/aws/services/opensearch/purchase_price_guard_test.go b/providers/aws/services/opensearch/purchase_price_guard_test.go index 9bd198e0..76bbb093 100644 --- a/providers/aws/services/opensearch/purchase_price_guard_test.go +++ b/providers/aws/services/opensearch/purchase_price_guard_test.go @@ -66,6 +66,8 @@ func TestPurchaseCommitment_RefusesOfferingAboveApprovedUpfront(t *testing.T) { m := guardMock(525.01, "USD") // 2 x 525.01 = 1050.02 > ceiling 1050.00 res, err := buy(m, guardRec()) require.ErrorContains(t, err, "exceeds the approved upfront") + assert.ErrorIs(t, err, common.ErrRefusedBeforePurchase) + assert.ErrorIs(t, res.Error, common.ErrRefusedBeforePurchase) assert.ErrorContains(t, err, "1050.02") assert.ErrorContains(t, err, "1000.00") assert.False(t, res.Success) diff --git a/providers/aws/services/rds/client.go b/providers/aws/services/rds/client.go index 3ee62b48..83dddf67 100644 --- a/providers/aws/services/rds/client.go +++ b/providers/aws/services/rds/client.go @@ -167,7 +167,7 @@ func (c *Client) PurchaseCommitment(ctx context.Context, rec common.Recommendati // After the dedupe: a re-drive that already bought must not be refused for a // later price rise (ec2/client.go does the same). if err = c.checkApprovedUpfront(ctx, rec, offeringID); err != nil { - result.Error = err + result.Error = fmt.Errorf("%w: %w", common.ErrRefusedBeforePurchase, err) return result, result.Error } diff --git a/providers/aws/services/rds/purchase_guard_test.go b/providers/aws/services/rds/purchase_guard_test.go index d37a6dfa..8dd4de9d 100644 --- a/providers/aws/services/rds/purchase_guard_test.go +++ b/providers/aws/services/rds/purchase_guard_test.go @@ -132,6 +132,8 @@ func TestPurchaseGuard_OtherPurchaseErrorNotRecovered(t *testing.T) { result, err := guardClient(m).PurchaseCommitment(context.Background(), idempotencyTestRec(), common.PurchaseOptions{IdempotencyToken: common.DeriveIdempotencyToken("exec-other", 0)}) require.Error(t, err) + assert.NotErrorIs(t, err, common.ErrRefusedBeforePurchase) + assert.NotErrorIs(t, result.Error, common.ErrRefusedBeforePurchase) assert.False(t, result.Success) m.AssertNumberOfCalls(t, "DescribeReservedDBInstances", 1) } diff --git a/providers/aws/services/rds/purchase_price_guard_test.go b/providers/aws/services/rds/purchase_price_guard_test.go index 11f5e246..ea69d454 100644 --- a/providers/aws/services/rds/purchase_price_guard_test.go +++ b/providers/aws/services/rds/purchase_price_guard_test.go @@ -53,6 +53,8 @@ func TestPurchaseCommitment_RefusesOfferingAboveApprovedUpfront(t *testing.T) { m := guardMock(525.01, "USD") // 2 x 525.01 = 1050.02 > ceiling 1050.00 res, err := buy(m, guardRec()) require.ErrorContains(t, err, "exceeds the approved upfront") + assert.ErrorIs(t, err, common.ErrRefusedBeforePurchase) + assert.ErrorIs(t, res.Error, common.ErrRefusedBeforePurchase) assert.ErrorContains(t, err, "1050.02") assert.ErrorContains(t, err, "1000.00") assert.False(t, res.Success) @@ -157,10 +159,31 @@ func TestPurchaseCommitment_RefusesWhenPriceReadFails(t *testing.T) { res, err := buy(m, guardRec()) require.ErrorContains(t, err, "approved upfront check") assert.ErrorContains(t, err, "throttled") + assert.ErrorIs(t, err, common.ErrRefusedBeforePurchase) + assert.ErrorIs(t, res.Error, common.ErrRefusedBeforePurchase) assert.False(t, res.Success) m.AssertNotCalled(t, "PurchaseReservedDBInstancesOffering", mock.Anything, mock.Anything) } +// The guard passes (2 x 500 == approved 1000), so a purchase API failure is not +// a refusal before purchase. +func TestPurchaseCommitment_PurchaseFailureAfterGuardIsNotRefused(t *testing.T) { + t.Parallel() + m := &MockRDSClient{} + priced := exactOffering("offering-1") + priced.FixedPrice = aws.Float64(500) + priced.CurrencyCode = aws.String("USD") + m.On("DescribeReservedDBInstancesOfferings", mock.Anything, mock.Anything). + Return(&rds.DescribeReservedDBInstancesOfferingsOutput{ReservedDBInstancesOfferings: []types.ReservedDBInstancesOffering{priced}}, nil) + m.On("PurchaseReservedDBInstancesOffering", mock.Anything, mock.Anything). + Return((*rds.PurchaseReservedDBInstancesOfferingOutput)(nil), errors.New("boom")) + res, err := buy(m, guardRec()) + require.Error(t, err) + assert.NotErrorIs(t, err, common.ErrRefusedBeforePurchase) + assert.NotErrorIs(t, res.Error, common.ErrRefusedBeforePurchase) + m.AssertCalled(t, "PurchaseReservedDBInstancesOffering", mock.Anything, mock.Anything) +} + func TestPurchaseCommitment_RefusesWhenPriceReadIsNotOneOffering(t *testing.T) { t.Parallel() two := []types.ReservedDBInstancesOffering{exactOffering("offering-1"), exactOffering("offering-1")} diff --git a/providers/aws/services/redshift/client.go b/providers/aws/services/redshift/client.go index e2701316..056d73ed 100644 --- a/providers/aws/services/redshift/client.go +++ b/providers/aws/services/redshift/client.go @@ -149,7 +149,7 @@ func (c *Client) PurchaseCommitment(ctx context.Context, rec common.Recommendati // After the dedupe: a re-drive that already bought must not be refused for a // later price rise (ec2/client.go does the same). if err = c.checkApprovedUpfront(ctx, rec, offeringID); err != nil { - result.Error = err + result.Error = fmt.Errorf("%w: %w", common.ErrRefusedBeforePurchase, err) return result, result.Error } diff --git a/providers/aws/services/redshift/dedupe_test.go b/providers/aws/services/redshift/dedupe_test.go index 13eb5a86..e91cf7a8 100644 --- a/providers/aws/services/redshift/dedupe_test.go +++ b/providers/aws/services/redshift/dedupe_test.go @@ -268,6 +268,7 @@ func TestDedupe_RecoverAfterUnknownOutcome(t *testing.T) { return } assert.ErrorIs(t, err, common.ErrOutcomeUnknown) + assert.NotErrorIs(t, err, common.ErrRefusedBeforePurchase) assert.False(t, result.Success) }) } diff --git a/providers/aws/services/redshift/purchase_price_guard_test.go b/providers/aws/services/redshift/purchase_price_guard_test.go index ddca97f7..0d927096 100644 --- a/providers/aws/services/redshift/purchase_price_guard_test.go +++ b/providers/aws/services/redshift/purchase_price_guard_test.go @@ -72,6 +72,8 @@ func TestPurchaseCommitment_RefusesOfferingAboveApprovedUpfront(t *testing.T) { m := guardMock(525.01, "USD") // 2 x 525.01 = 1050.02 > ceiling 1050.00 res, err := buy(m, guardRec()) require.ErrorContains(t, err, "exceeds the approved upfront") + assert.ErrorIs(t, err, common.ErrRefusedBeforePurchase) + assert.ErrorIs(t, res.Error, common.ErrRefusedBeforePurchase) assert.ErrorContains(t, err, "1050.02") assert.ErrorContains(t, err, "1000.00") assert.False(t, res.Success)