From 848ee42786dbc55aa29afa4588b537f06adaf2bb Mon Sep 17 00:00:00 2001 From: Cristian Magherusan-Stanciu Date: Wed, 4 Nov 2026 05:58:10 +0100 Subject: [PATCH 1/3] feat(common): wrap approved-upfront guard refusals in ErrRefusedBeforePurchase Add common.ErrRefusedBeforePurchase and wrap it at the five AWS guard call sites (ec2, rds, opensearch, redshift, memorydb) so callers can tell the call sent no purchase request. Purchase-API failures do not carry it. Co-Authored-By: Claude Opus 5.5 --- CHANGELOG.md | 3 +++ pkg/common/types.go | 10 ++++++++++ providers/aws/services/ec2/client.go | 2 +- providers/aws/services/ec2/client_test.go | 1 + .../aws/services/ec2/purchase_price_guard_test.go | 2 ++ providers/aws/services/memorydb/client.go | 2 +- providers/aws/services/memorydb/purchase_guard_test.go | 2 ++ .../aws/services/memorydb/purchase_price_guard_test.go | 2 ++ providers/aws/services/opensearch/client.go | 2 +- .../aws/services/opensearch/purchase_guard_test.go | 2 ++ .../services/opensearch/purchase_price_guard_test.go | 2 ++ providers/aws/services/rds/client.go | 2 +- providers/aws/services/rds/purchase_guard_test.go | 2 ++ .../aws/services/rds/purchase_price_guard_test.go | 3 +++ providers/aws/services/redshift/client.go | 2 +- providers/aws/services/redshift/dedupe_test.go | 1 + .../aws/services/redshift/purchase_price_guard_test.go | 2 ++ 17 files changed, 37 insertions(+), 5 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 4c6cae44..e12f1b93 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -32,6 +32,9 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/). ### Added +- **`common.ErrRefusedBeforePurchase`:** the AWS EC2, RDS, OpenSearch, Redshift + and MemoryDB approved-upfront guard refusals now wrap it, so callers can tell + with `errors.Is` that the call sent no purchase request. - **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/pkg/common/types.go b/pkg/common/types.go index 715c1c49..3f4b3025 100644 --- a/pkg/common/types.go +++ b/pkg/common/types.go @@ -34,6 +34,16 @@ var ErrCurrencyNotAccepted = errors.New("currency not accepted") // contract until the platform persists a typed flag (platform #778). var ErrOutcomeUnknown = errors.New("purchase outcome unknown") +// ErrRefusedBeforePurchase marks a PurchaseCommitment failure raised by the +// approved-upfront guard (price read, currency, or ceiling check) before the +// purchase request was built or sent. Its presence is sufficient to know that +// call sent no purchase request, but its absence proves nothing: other refusals +// do not carry it. It describes only the call that returned it and says nothing +// about earlier calls with the same idempotency token. A wrapper that combines +// several purchase calls must not propagate it unless none of them sent a +// request. Detect it with errors.Is(err, ErrRefusedBeforePurchase). +var ErrRefusedBeforePurchase = errors.New("refused before purchase") + // ProviderType identifies the cloud provider. type ProviderType string 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..59744e34 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,6 +159,7 @@ 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.False(t, res.Success) m.AssertNotCalled(t, "PurchaseReservedDBInstancesOffering", mock.Anything, mock.Anything) } 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) From da179aba29aa38caa62ad333d4cfa55489862d4c Mon Sep 17 00:00:00 2001 From: Cristian Magherusan-Stanciu Date: Wed, 4 Nov 2026 06:03:08 +0100 Subject: [PATCH 2/3] test(rds): cover post-guard purchase failure and widen sentinel doc Add an RDS case where the guard passes and the purchase API fails (no sentinel), assert res.Error on the price-read refusal, and say the sentinel marks any error from the approved-upfront check. Co-Authored-By: Claude Opus 5.5 --- pkg/common/types.go | 8 ++++---- .../services/rds/purchase_price_guard_test.go | 20 +++++++++++++++++++ 2 files changed, 24 insertions(+), 4 deletions(-) diff --git a/pkg/common/types.go b/pkg/common/types.go index 3f4b3025..d3ee21ae 100644 --- a/pkg/common/types.go +++ b/pkg/common/types.go @@ -34,10 +34,10 @@ var ErrCurrencyNotAccepted = errors.New("currency not accepted") // contract until the platform persists a typed flag (platform #778). var ErrOutcomeUnknown = errors.New("purchase outcome unknown") -// ErrRefusedBeforePurchase marks a PurchaseCommitment failure raised by the -// approved-upfront guard (price read, currency, or ceiling check) before the -// purchase request was built or sent. Its presence is sufficient to know that -// call sent no purchase request, but its absence proves nothing: other refusals +// ErrRefusedBeforePurchase marks a PurchaseCommitment failure raised by any +// error from the approved-upfront check before the purchase request was built +// or sent. Its presence is sufficient to know that this call sent no purchase +// request, but its absence proves nothing: other refusals // do not carry it. It describes only the call that returned it and says nothing // about earlier calls with the same idempotency token. A wrapper that combines // several purchase calls must not propagate it unless none of them sent a diff --git a/providers/aws/services/rds/purchase_price_guard_test.go b/providers/aws/services/rds/purchase_price_guard_test.go index 59744e34..ea69d454 100644 --- a/providers/aws/services/rds/purchase_price_guard_test.go +++ b/providers/aws/services/rds/purchase_price_guard_test.go @@ -160,10 +160,30 @@ func TestPurchaseCommitment_RefusesWhenPriceReadFails(t *testing.T) { 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")} From 8f3e065a905fd641a8c8742e1f6512c773e9e77e Mon Sep 17 00:00:00 2001 From: Cristian Magherusan-Stanciu Date: Wed, 4 Nov 2026 07:55:11 +0100 Subject: [PATCH 3/3] chore(deps): pin providers/aws to pkg on main 3efa339 Re-pin pkg to the go#673 merge commit, which adds common.ErrRefusedBeforePurchase, so the standalone (GOWORK=off) provider build resolves it. golang.org/x/sys moves to v0.49.0, the version that pkg commit requires. Co-Authored-By: Claude Opus 5.5 --- providers/aws/go.mod | 4 ++-- providers/aws/go.sum | 8 ++++---- 2 files changed, 6 insertions(+), 6 deletions(-) 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=