Skip to content

Commit 654e705

Browse files
committed
fix(purchase): address CodeRabbit review on PR #684
Three changes per CR pass-1 triage: 1. Actionable (execution.go): distinguish context.DeadlineExceeded from context.Canceled in the post-PurchaseCommitment error log. The previous branch logged "per-rec deadline 30s exceeded" for any non-nil recCtx.Err(), including parent cancellations -- mislabeling Canceled as a timeout makes diagnostic triage harder. Logic extracted into logRecCtxErr helper to keep executeSinglePurchase cyclomatic complexity within the gocyclo limit. 2. Nitpick (execution_test.go): replace mock.Anything with a deadline-aware mock.MatchedBy on the first arg of all 12 CreateAndValidateProvider expectations. The 30s per-rec budget is set before the factory call, so all three mocked stages (factory, GetServiceClient, PurchaseCommitment) now assert deadline propagation end-to-end. 3. Nitpick (purchasecfg/config.go + config_test.go): preserve the caller's *http.Client when one is provided in base config. NewConfig now shallow- copies the client and only overrides Timeout, keeping any custom Transport, Jar, or CheckRedirect. Falls back to a fresh client when base.HTTPClient is nil or a non-*http.Client implementation. Adds TestNewConfig_PreservesCustomTransport to pin the contract.
1 parent d738687 commit 654e705

4 files changed

Lines changed: 67 additions & 17 deletions

File tree

‎internal/purchase/execution.go‎

Lines changed: 20 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@ package purchase
22

33
import (
44
"context"
5+
"errors"
56
"fmt"
67
"strconv"
78
"strings"
@@ -684,6 +685,24 @@ func (m *Manager) buildPurchaseConfirmationData(exec *config.PurchaseExecution,
684685
return data
685686
}
686687

688+
// logRecCtxErr emits a diagnostic log line when a per-recommendation context has
689+
// been cancelled or timed out. It distinguishes DeadlineExceeded (the 30s per-rec
690+
// budget fired) from Canceled (a parent context stopped the execution) so that
691+
// CloudWatch filters can tell the two apart without parsing error strings.
692+
// It is a no-op when recCtxErr is nil.
693+
func logRecCtxErr(executionID, recTuple string, elapsed time.Duration, recCtxErr error) {
694+
if recCtxErr == nil {
695+
return
696+
}
697+
if errors.Is(recCtxErr, context.DeadlineExceeded) {
698+
logging.Errorf("purchase[%s]: per-rec deadline exceeded for %s (elapsed=%s)",
699+
executionID, recTuple, elapsed)
700+
} else if errors.Is(recCtxErr, context.Canceled) {
701+
logging.Errorf("purchase[%s]: recommendation context canceled for %s (elapsed=%s)",
702+
executionID, recTuple, elapsed)
703+
}
704+
}
705+
687706
// executeSinglePurchase executes a single purchase using the appropriate provider.
688707
// provCfg carries optional per-account credentials; pass nil to use ambient credentials.
689708
// opts carries execution-level metadata (the source surface) that providers stamp
@@ -788,10 +807,7 @@ func (m *Manager) executeSinglePurchase(ctx context.Context, rec config.Recommen
788807
result, err := serviceClient.PurchaseCommitment(recCtx, recommendation, opts)
789808
elapsed := time.Since(tCall)
790809
if err != nil {
791-
if recCtx.Err() != nil {
792-
logging.Errorf("purchase[%s]: per-rec deadline 30s exceeded for %s (total elapsed %s)",
793-
opts.ExecutionID, recTuple, elapsed)
794-
}
810+
logRecCtxErr(opts.ExecutionID, recTuple, elapsed, recCtx.Err())
795811
logging.Errorf("purchase[%s]: %s/%s/%s/%s PurchaseCommitment failed after %s: %v",
796812
opts.ExecutionID, rec.Provider, rec.Service, rec.Region, rec.ResourceType, elapsed, err)
797813
return result, fmt.Errorf("purchase failed: %w", err)

‎internal/purchase/execution_test.go‎

Lines changed: 12 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -67,7 +67,7 @@ func TestManager_ExecutePurchase(t *testing.T) {
6767
}, nil)
6868

6969
// Mock provider factory to return a mock provider
70-
mockFactory.On("CreateAndValidateProvider", mock.Anything, "aws", mock.Anything).Return(mockProviderInst, nil)
70+
mockFactory.On("CreateAndValidateProvider", mock.MatchedBy(func(c context.Context) bool { _, ok := c.Deadline(); return ok }), "aws", mock.Anything).Return(mockProviderInst, nil)
7171
mockProviderInst.On("GetServiceClient", mock.MatchedBy(func(c context.Context) bool { _, ok := c.Deadline(); return ok }), common.ServiceEC2, "us-east-1").Return(mockServiceClient, nil)
7272
mockServiceClient.On("PurchaseCommitment", mock.MatchedBy(func(c context.Context) bool { _, ok := c.Deadline(); return ok }), mock.AnythingOfType("common.Recommendation"), mock.AnythingOfType("common.PurchaseOptions")).Return(common.PurchaseResult{
7373
Success: true,
@@ -127,7 +127,7 @@ func TestManager_ExecutePurchase_WebSourcePropagates(t *testing.T) {
127127
mockSTS.On("GetCallerIdentity", ctx, mock.AnythingOfType("*sts.GetCallerIdentityInput")).Return(&sts.GetCallerIdentityOutput{
128128
Account: aws.String("123456789012"),
129129
}, nil)
130-
mockFactory.On("CreateAndValidateProvider", mock.Anything, "aws", mock.Anything).Return(mockProviderInst, nil)
130+
mockFactory.On("CreateAndValidateProvider", mock.MatchedBy(func(c context.Context) bool { _, ok := c.Deadline(); return ok }), "aws", mock.Anything).Return(mockProviderInst, nil)
131131
mockProviderInst.On("GetServiceClient", mock.MatchedBy(func(c context.Context) bool { _, ok := c.Deadline(); return ok }), common.ServiceEC2, "us-east-1").Return(mockServiceClient, nil)
132132
// The deterministic idempotency token (issue #636) is derived per-rec from
133133
// the execution ID and the rec's index, so the web path now also carries it.
@@ -183,7 +183,7 @@ func TestManager_ExecutePurchase_InvalidSourceFallsBackUntagged(t *testing.T) {
183183
mockSTS.On("GetCallerIdentity", ctx, mock.AnythingOfType("*sts.GetCallerIdentityInput")).Return(&sts.GetCallerIdentityOutput{
184184
Account: aws.String("123456789012"),
185185
}, nil)
186-
mockFactory.On("CreateAndValidateProvider", mock.Anything, "aws", mock.Anything).Return(mockProviderInst, nil)
186+
mockFactory.On("CreateAndValidateProvider", mock.MatchedBy(func(c context.Context) bool { _, ok := c.Deadline(); return ok }), "aws", mock.Anything).Return(mockProviderInst, nil)
187187
mockProviderInst.On("GetServiceClient", mock.MatchedBy(func(c context.Context) bool { _, ok := c.Deadline(); return ok }), common.ServiceEC2, "us-east-1").Return(mockServiceClient, nil)
188188
// Expect EMPTY source, not "cudly-evil" — NormalizeSource must have wiped it.
189189
// The idempotency token (issue #636) is still derived regardless of source.
@@ -528,7 +528,7 @@ func TestManager_ExecutePurchase_MultiAccount(t *testing.T) {
528528
mockStore.On("SavePurchaseHistory", ctx, mock.AnythingOfType("*config.PurchaseHistoryRecord")).Return(nil).Times(2)
529529
mockEmail.On("SendPurchaseConfirmation", ctx, mock.AnythingOfType("email.NotificationData")).Return(nil).Times(2)
530530

531-
mockFactory.On("CreateAndValidateProvider", mock.Anything, "aws", mock.Anything).Return(mockProvider, nil)
531+
mockFactory.On("CreateAndValidateProvider", mock.MatchedBy(func(c context.Context) bool { _, ok := c.Deadline(); return ok }), "aws", mock.Anything).Return(mockProvider, nil)
532532
mockProvider.On("GetServiceClient", mock.MatchedBy(func(c context.Context) bool { _, ok := c.Deadline(); return ok }), common.ServiceEC2, "us-east-1").Return(mockServiceClient, nil)
533533
mockServiceClient.On("PurchaseCommitment", mock.MatchedBy(func(c context.Context) bool { _, ok := c.Deadline(); return ok }), mock.AnythingOfType("common.Recommendation"), mock.AnythingOfType("common.PurchaseOptions")).Return(common.PurchaseResult{
534534
Success: true, CommitmentID: "ri-99999",
@@ -756,7 +756,7 @@ func TestExecuteMultiAccount_PartialFailure_IsolatesAccounts(t *testing.T) {
756756

757757
// Only account-V reaches the provider; account-I fails at credential resolution.
758758
// Use .Once() so testify fails if the factory is called for account-I as well.
759-
mockFactory.On("CreateAndValidateProvider", mock.Anything, "aws", mock.Anything).Return(mockProviderInst, nil).Once()
759+
mockFactory.On("CreateAndValidateProvider", mock.MatchedBy(func(c context.Context) bool { _, ok := c.Deadline(); return ok }), "aws", mock.Anything).Return(mockProviderInst, nil).Once()
760760
mockProviderInst.On("GetServiceClient", mock.MatchedBy(func(c context.Context) bool { _, ok := c.Deadline(); return ok }), common.ServiceEC2, "us-east-1").Return(mockServiceClient, nil).Once()
761761
mockServiceClient.On("PurchaseCommitment", mock.MatchedBy(func(c context.Context) bool { _, ok := c.Deadline(); return ok }),
762762
mock.AnythingOfType("common.Recommendation"),
@@ -921,7 +921,7 @@ func TestExecuteMultiAccount_RunsAccountsInParallel(t *testing.T) {
921921
return nil
922922
}
923923

924-
mockFactory.On("CreateAndValidateProvider", mock.Anything, "aws", mock.Anything).Return(mockProviderInst, nil).Twice()
924+
mockFactory.On("CreateAndValidateProvider", mock.MatchedBy(func(c context.Context) bool { _, ok := c.Deadline(); return ok }), "aws", mock.Anything).Return(mockProviderInst, nil).Twice()
925925
mockProviderInst.On("GetServiceClient", mock.MatchedBy(func(c context.Context) bool { _, ok := c.Deadline(); return ok }), common.ServiceEC2, "us-east-1").Return(mockServiceClient, nil).Twice()
926926

927927
// Each PurchaseCommitment call blocks for perCallDelay. Under serial
@@ -1039,7 +1039,7 @@ func TestExecutePurchase_SingleAccount_AzureUsesResolvedCreds(t *testing.T) {
10391039
// The core assertion: factory must receive a non-nil provCfg.
10401040
// Before the fix, nil was passed and Azure SDK fell back to DefaultAzureCredential.
10411041
// After the fix, resolveAzureProvider populates ProviderOverride on the config.
1042-
mockFactory.On("CreateAndValidateProvider", mock.Anything, "azure",
1042+
mockFactory.On("CreateAndValidateProvider", mock.MatchedBy(func(c context.Context) bool { _, ok := c.Deadline(); return ok }), "azure",
10431043
mock.MatchedBy(func(cfg *provider.ProviderConfig) bool {
10441044
return cfg != nil && cfg.ProviderOverride != nil
10451045
}),
@@ -1168,7 +1168,7 @@ func TestExecutePurchase_AzureCanonicalServiceTypes(t *testing.T) {
11681168
Account: aws.String("123456789012"),
11691169
}, nil)
11701170

1171-
mockFactory.On("CreateAndValidateProvider", mock.Anything, "azure",
1171+
mockFactory.On("CreateAndValidateProvider", mock.MatchedBy(func(c context.Context) bool { _, ok := c.Deadline(); return ok }), "azure",
11721172
mock.MatchedBy(func(cfg *provider.ProviderConfig) bool {
11731173
return cfg != nil && cfg.ProviderOverride != nil
11741174
}),
@@ -1391,7 +1391,7 @@ func TestManager_ExecuteAndFinalize_HistorySaveFailure_StaysVisible(t *testing.T
13911391
mockSTS.On("GetCallerIdentity", ctx, mock.AnythingOfType("*sts.GetCallerIdentityInput")).Return(&sts.GetCallerIdentityOutput{
13921392
Account: aws.String("123456789012"),
13931393
}, nil)
1394-
mockFactory.On("CreateAndValidateProvider", mock.Anything, "aws", mock.Anything).Return(mockProviderInst, nil)
1394+
mockFactory.On("CreateAndValidateProvider", mock.MatchedBy(func(c context.Context) bool { _, ok := c.Deadline(); return ok }), "aws", mock.Anything).Return(mockProviderInst, nil)
13951395
mockProviderInst.On("GetServiceClient", mock.MatchedBy(func(c context.Context) bool { _, ok := c.Deadline(); return ok }), common.ServiceEC2, "us-east-1").Return(mockServiceClient, nil)
13961396
mockServiceClient.On("PurchaseCommitment", mock.MatchedBy(func(c context.Context) bool { _, ok := c.Deadline(); return ok }), mock.AnythingOfType("common.Recommendation"), mock.AnythingOfType("common.PurchaseOptions")).Return(common.PurchaseResult{
13971397
Success: true,
@@ -1465,7 +1465,7 @@ func TestManager_ExecuteAndFinalize_SingleAccount_PartialSuccess(t *testing.T) {
14651465
Account: aws.String("123456789012"),
14661466
}, nil).Maybe()
14671467

1468-
mockFactory.On("CreateAndValidateProvider", mock.Anything, "aws", mock.Anything).Return(mockProviderInst, nil)
1468+
mockFactory.On("CreateAndValidateProvider", mock.MatchedBy(func(c context.Context) bool { _, ok := c.Deadline(); return ok }), "aws", mock.Anything).Return(mockProviderInst, nil)
14691469
mockProviderInst.On("GetServiceClient", mock.MatchedBy(func(c context.Context) bool { _, ok := c.Deadline(); return ok }), common.ServiceEC2, "us-east-1").Return(mockServiceClient, nil)
14701470
// m5.large succeeds; c5.xlarge fails.
14711471
mockServiceClient.On("PurchaseCommitment", mock.MatchedBy(func(c context.Context) bool { _, ok := c.Deadline(); return ok }),
@@ -1547,7 +1547,7 @@ func TestExecuteForAccount_PartialSuccess(t *testing.T) {
15471547
},
15481548
}
15491549

1550-
mockFactory.On("CreateAndValidateProvider", mock.Anything, "aws", mock.Anything).Return(mockProviderInst, nil)
1550+
mockFactory.On("CreateAndValidateProvider", mock.MatchedBy(func(c context.Context) bool { _, ok := c.Deadline(); return ok }), "aws", mock.Anything).Return(mockProviderInst, nil)
15511551
mockProviderInst.On("GetServiceClient", mock.MatchedBy(func(c context.Context) bool { _, ok := c.Deadline(); return ok }), common.ServiceEC2, "us-east-1").Return(mockServiceClient, nil)
15521552
mockServiceClient.On("PurchaseCommitment", mock.MatchedBy(func(c context.Context) bool { _, ok := c.Deadline(); return ok }),
15531553
mock.MatchedBy(func(r common.Recommendation) bool { return r.ResourceType == "m5.large" }),
@@ -1685,7 +1685,7 @@ func TestManager_ExecutePurchase_SingleAccount_StampsTargetAccount(t *testing.T)
16851685
mockSTS.On("GetCallerIdentity", ctx, mock.AnythingOfType("*sts.GetCallerIdentityInput")).
16861686
Return(&sts.GetCallerIdentityOutput{Account: aws.String(hostAccount)}, nil).Maybe()
16871687

1688-
mockFactory.On("CreateAndValidateProvider", mock.Anything, tc.provider, mock.Anything).Return(mockProviderInst, nil)
1688+
mockFactory.On("CreateAndValidateProvider", mock.MatchedBy(func(c context.Context) bool { _, ok := c.Deadline(); return ok }), tc.provider, mock.Anything).Return(mockProviderInst, nil)
16891689
mockProviderInst.On("GetServiceClient", mock.MatchedBy(func(c context.Context) bool { _, ok := c.Deadline(); return ok }), tc.expectedType, tc.region).Return(mockServiceClient, nil)
16901690
mockServiceClient.On("PurchaseCommitment", mock.MatchedBy(func(c context.Context) bool { _, ok := c.Deadline(); return ok }),
16911691
mock.AnythingOfType("common.Recommendation"),

‎providers/aws/internal/purchasecfg/config.go‎

Lines changed: 12 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -38,9 +38,20 @@ const (
3838
// Applies:
3939
// - RetryMaxAttempts = 2 (overrides the SDK default of 3)
4040
// - HTTPClient with Timeout = 15s (overrides the SDK default of no timeout)
41+
//
42+
// If base.HTTPClient is an *http.Client, it is cloned and its Timeout is set
43+
// to HTTPTimeout, preserving any custom Transport, Jar, or CheckRedirect the
44+
// caller installed. If base.HTTPClient is nil or a non-*http.Client
45+
// implementation, a fresh *http.Client{Timeout: HTTPTimeout} is used instead.
4146
func NewConfig(base aws.Config) aws.Config {
4247
cfg := base.Copy()
4348
cfg.RetryMaxAttempts = MaxAttempts
44-
cfg.HTTPClient = &http.Client{Timeout: HTTPTimeout}
49+
if hc, ok := base.HTTPClient.(*http.Client); ok && hc != nil {
50+
clone := *hc // shallow copy preserves Transport, Jar, CheckRedirect
51+
clone.Timeout = HTTPTimeout
52+
cfg.HTTPClient = &clone
53+
} else {
54+
cfg.HTTPClient = &http.Client{Timeout: HTTPTimeout}
55+
}
4556
return cfg
4657
}

‎providers/aws/internal/purchasecfg/config_test.go‎

Lines changed: 23 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -52,6 +52,29 @@ func TestNewConfig_DoesNotMutateBase(t *testing.T) {
5252
assert.Equal(t, 60*time.Second, httpClient.Timeout, "NewConfig must not modify the original config's HTTPClient")
5353
}
5454

55+
// TestNewConfig_PreservesCustomTransport verifies that a non-nil *http.Client
56+
// supplied in the base config has its Transport kept intact in the result.
57+
// NewConfig must not silently drop custom transports, TLS settings, or
58+
// instrumentation hooks by replacing the whole client unconditionally.
59+
func TestNewConfig_PreservesCustomTransport(t *testing.T) {
60+
t.Helper()
61+
customTransport := &http.Transport{MaxIdleConns: 42}
62+
base := aws.Config{
63+
Region: "us-east-1",
64+
HTTPClient: &http.Client{Transport: customTransport, Timeout: 60 * time.Second},
65+
}
66+
67+
cfg := NewConfig(base)
68+
69+
require.NotNil(t, cfg.HTTPClient, "HTTPClient must not be nil after NewConfig")
70+
httpClient, ok := cfg.HTTPClient.(*http.Client)
71+
require.True(t, ok, "HTTPClient must be *http.Client")
72+
assert.Equal(t, HTTPTimeout, httpClient.Timeout,
73+
"NewConfig must override the Timeout to HTTPTimeout even when cloning a caller-provided client")
74+
assert.Same(t, customTransport, httpClient.Transport,
75+
"NewConfig must preserve the caller-provided Transport on the cloned client")
76+
}
77+
5578
func TestNewConfig_PreservesRegion(t *testing.T) {
5679
t.Helper()
5780
base := aws.Config{Region: "ap-southeast-1"}

0 commit comments

Comments
 (0)