diff --git a/providers/aws/ladder/factory.go b/providers/aws/ladder/factory.go index 5e748a2da..8f10dd377 100644 --- a/providers/aws/ladder/factory.go +++ b/providers/aws/ladder/factory.go @@ -64,7 +64,7 @@ func NewFromAWSConfig(ctx context.Context, region, accountID string) (pkgladder. // recommendations.Client satisfies riCoverageSource and utilizationSource // directly, and is the underlying client for the on-demand series and SP // coverage/utilization adapters. - recoClient := recommendations.NewClient(awsCfg) + recoClient := recommendations.NewClient(&awsCfg) // ec2svc.Client satisfies riLister (ListConvertibleReservedInstances). ec2Client := ec2svc.NewClient(awsCfg) diff --git a/providers/aws/recommendations/client.go b/providers/aws/recommendations/client.go index 8ea6c2bea..e75391582 100644 --- a/providers/aws/recommendations/client.go +++ b/providers/aws/recommendations/client.go @@ -32,7 +32,7 @@ const maxRecommendationPages = 20 // from before --rec-lookback-period existed. const DefaultRecLookbackPeriod = "7d" -// CostExplorerAPI defines the interface for Cost Explorer operations +// CostExplorerAPI defines the interface for Cost Explorer operations. type CostExplorerAPI interface { GetReservationPurchaseRecommendation(ctx context.Context, params *costexplorer.GetReservationPurchaseRecommendationInput, optFns ...func(*costexplorer.Options)) (*costexplorer.GetReservationPurchaseRecommendationOutput, error) GetSavingsPlansPurchaseRecommendation(ctx context.Context, params *costexplorer.GetSavingsPlansPurchaseRecommendationInput, optFns ...func(*costexplorer.Options)) (*costexplorer.GetSavingsPlansPurchaseRecommendationOutput, error) @@ -46,11 +46,14 @@ type CostExplorerAPI interface { GetCostAndUsage(ctx context.Context, params *costexplorer.GetCostAndUsageInput, optFns ...func(*costexplorer.Options)) (*costexplorer.GetCostAndUsageOutput, error) } -// Client wraps the AWS Cost Explorer client for RI recommendations +// Client wraps the AWS Cost Explorer client for RI recommendations. type Client struct { costExplorerClient CostExplorerAPI region string - rateLimiter *RateLimiter + + // newRateLimiter is called once per API call (not shared across goroutines). + // Tests can replace it with a factory returning a faster limiter. + newRateLimiter func() *RateLimiter // ec2API is the EC2 client used to build the DescribeInstanceTypes paginator. // Populated by NewClient from aws.Config; nil when created via NewClientWithAPI. @@ -61,7 +64,7 @@ type Client struct { // for hermetic tests. When nil, instanceTypeLookup returns (0,0). instanceTypePagerFactory func() InstanceTypePager - // skuCatalog caches the per-instance-type vCPU/memory catalogue, fetched + // skuCatalog caches the per-instance-type vCPU/memory catalog, fetched // lazily once per Client lifetime via sync.Once (one DescribeInstanceTypes // fan-out per scheduler tick). skuCatalog skuCatalog @@ -71,18 +74,18 @@ type Client struct { recLookbackPeriod string } -// NewClient creates a new recommendations client -func NewClient(cfg aws.Config) *Client { +// NewClient creates a new recommendations client. +func NewClient(cfg *aws.Config) *Client { // Force Cost Explorer to use us-east-1 with explicit endpoint ceConfig := cfg.Copy() ceConfig.Region = "us-east-1" ceConfig.BaseEndpoint = aws.String("https://ce.us-east-1.amazonaws.com") - ec2Client := awsec2.NewFromConfig(cfg) + ec2Client := awsec2.NewFromConfig(*cfg) return &Client{ costExplorerClient: costexplorer.NewFromConfig(ceConfig), region: cfg.Region, - rateLimiter: NewRateLimiter(), + newRateLimiter: NewRateLimiter, ec2API: ec2Client, // Factory wraps the EC2 client so the paginator is created lazily // on the first EC2 recommendation parse (not at construction time). @@ -92,19 +95,19 @@ func NewClient(cfg aws.Config) *Client { } } -// NewClientWithAPI creates a new recommendations client with a custom Cost Explorer API (for testing) +// NewClientWithAPI creates a new recommendations client with a custom Cost Explorer API (for testing). func NewClientWithAPI(api CostExplorerAPI, region string) *Client { return &Client{ costExplorerClient: api, region: region, - rateLimiter: NewRateLimiter(), + newRateLimiter: NewRateLimiter, // ec2API left nil: instanceTypeLookup falls back to VCPU=0/MemoryGB=0 // unless the caller sets instanceTypePagerFactory. } } // SetInstanceTypePagerFactory injects a pager factory for the instance-type -// SKU catalogue. Must be called before the first GetRecommendations call. +// SKU catalog. Must be called before the first GetRecommendations call. // Intended for tests that need to verify the one-fetch-per-lifetime invariant // without hitting AWS. func (c *Client) SetInstanceTypePagerFactory(f func() InstanceTypePager) { @@ -112,9 +115,9 @@ func (c *Client) SetInstanceTypePagerFactory(f func() InstanceTypePager) { } // instanceTypeLookup returns the cached SKU entry for instanceType. -// On the first call the catalogue is built by calling the pager factory. -// ok=false when no factory is configured, the catalogue fetch failed, or -// the instance type was not in the catalogue -- the caller falls back to +// On the first call the catalog is built by calling the pager factory. +// ok=false when no factory is configured, the catalog fetch failed, or +// the instance type was not in the catalog - the caller falls back to // VCPU=0/MemoryGB=0 (graceful-degradation contract from Azure PR #810). func (c *Client) instanceTypeLookup(ctx context.Context, instanceType string) (instanceTypeSKUEntry, bool) { if c.instanceTypePagerFactory == nil { @@ -130,7 +133,7 @@ func (c *Client) SetRecLookbackPeriod(period string) { c.recLookbackPeriod = period } -// GetRecommendations fetches Reserved Instance recommendations for any service +// GetRecommendations fetches Reserved Instance recommendations for any service. func (c *Client) GetRecommendations(ctx context.Context, params common.RecommendationParams) ([]common.Recommendation, error) { // Handle Savings Plans separately — they use a different Cost Explorer API // (GetSavingsPlansPurchaseRecommendation, not GetReservationPurchaseRecommendation). @@ -138,7 +141,7 @@ func (c *Client) GetRecommendations(ctx context.Context, params common.Recommend // via the IsSavingsPlan family predicate so the dispatch keeps working as // callers migrate. if common.IsSavingsPlan(params.Service) { - return c.getSavingsPlansRecommendations(ctx, params) + return c.getSavingsPlansRecommendations(ctx, ¶ms) } input := &costexplorer.GetReservationPurchaseRecommendationInput{ @@ -204,12 +207,12 @@ func (c *Client) fetchRIPageWithRetry( ctx context.Context, input *costexplorer.GetReservationPurchaseRecommendationInput, ) (*costexplorer.GetReservationPurchaseRecommendationOutput, error) { - c.rateLimiter.Reset() + rl := c.newRateLimiter() var result *costexplorer.GetReservationPurchaseRecommendationOutput var err error for { - if waitErr := c.rateLimiter.Wait(ctx); waitErr != nil { + if waitErr := rl.Wait(ctx); waitErr != nil { return nil, fmt.Errorf("rate limiter wait failed: %w", waitErr) } @@ -218,13 +221,13 @@ func (c *Client) fetchRIPageWithRetry( } result, err = c.costExplorerClient.GetReservationPurchaseRecommendation(ctx, input) concurrency.Release(ctx) - if !c.rateLimiter.ShouldRetry(err) { + if !rl.ShouldRetry(err) { break } } if err != nil { - return nil, fmt.Errorf("failed to get RI recommendations after %d retries: %w", c.rateLimiter.GetRetryCount(), err) + return nil, fmt.Errorf("failed to get RI recommendations after %d retries: %w", rl.GetRetryCount(), err) } return result, nil @@ -345,7 +348,7 @@ func (c *Client) GetRecommendationsForService(ctx context.Context, service commo // the canonical order EC2 → RDS → ElastiCache → OpenSearch → Redshift after // all goroutines finish so order-sensitive consumers stay stable. // -// Behaviour change vs the previous sequential loop: per-service errors are +// Behavior change vs the previous sequential loop: per-service errors are // now logged at WARN via mergeServiceResults — the previous loop swallowed // them silently with a bare `continue`, leaving operators no signal when a // single service was misbehaving. Mirrors the Azure parallelisation in @@ -401,7 +404,12 @@ func (c *Client) GetAllRecommendations(ctx context.Context) ([]common.Recommenda // Wait, propagate ctx cancellation so callers can distinguish "all six // services completed (with possibly per-service errors)" from "the // parent ctx was canceled mid-fan-out". - _ = g.Wait() + if waitErr := g.Wait(); waitErr != nil { + // This branch is unreachable: every goroutine above returns nil. + // The panic makes the invariant explicit and visible to the race + // detector rather than silently discarding an unexpected error. + panic("errgroup returned non-nil despite all goroutines returning nil: " + waitErr.Error()) + } if err := ctx.Err(); err != nil { return nil, err } diff --git a/providers/aws/recommendations/client_test.go b/providers/aws/recommendations/client_test.go index 66551ce6b..d5bef8a2e 100644 --- a/providers/aws/recommendations/client_test.go +++ b/providers/aws/recommendations/client_test.go @@ -3,6 +3,7 @@ package recommendations import ( "context" "fmt" + "sync" "testing" "time" @@ -15,8 +16,9 @@ import ( "github.com/LeanerCloud/CUDly/pkg/common" ) -// Mock CostExplorerAPI for testing +// Mock CostExplorerAPI for testing. type mockCostExplorerAPI struct { + mu sync.Mutex riRecommendations *costexplorer.GetReservationPurchaseRecommendationOutput spRecommendations *costexplorer.GetSavingsPlansPurchaseRecommendationOutput riError error @@ -28,20 +30,28 @@ type mockCostExplorerAPI struct { } func (m *mockCostExplorerAPI) GetReservationPurchaseRecommendation(ctx context.Context, params *costexplorer.GetReservationPurchaseRecommendationInput, optFns ...func(*costexplorer.Options)) (*costexplorer.GetReservationPurchaseRecommendationOutput, error) { + m.mu.Lock() m.callCount++ m.riCalls = append(m.riCalls, params) - if m.riError != nil { - return nil, m.riError + riErr := m.riError + riRecs := m.riRecommendations + m.mu.Unlock() + if riErr != nil { + return nil, riErr } - return m.riRecommendations, nil + return riRecs, nil } func (m *mockCostExplorerAPI) GetSavingsPlansPurchaseRecommendation(ctx context.Context, params *costexplorer.GetSavingsPlansPurchaseRecommendationInput, optFns ...func(*costexplorer.Options)) (*costexplorer.GetSavingsPlansPurchaseRecommendationOutput, error) { + m.mu.Lock() m.callCount++ - if m.spError != nil { - return nil, m.spError + spErr := m.spError + spRecs := m.spRecommendations + m.mu.Unlock() + if spErr != nil { + return nil, spErr } - return m.spRecommendations, nil + return spRecs, nil } func (m *mockCostExplorerAPI) GetReservationUtilization(ctx context.Context, params *costexplorer.GetReservationUtilizationInput, optFns ...func(*costexplorer.Options)) (*costexplorer.GetReservationUtilizationOutput, error) { @@ -69,11 +79,11 @@ func TestNewClient(t *testing.T) { Region: "us-west-2", } - client := NewClient(cfg) + client := NewClient(&cfg) assert.NotNil(t, client) assert.NotNil(t, client.costExplorerClient) - assert.NotNil(t, client.rateLimiter) + assert.NotNil(t, client.newRateLimiter) assert.Equal(t, "us-west-2", client.region) } @@ -86,7 +96,7 @@ func TestNewClientWithAPI(t *testing.T) { assert.NotNil(t, client) assert.Equal(t, mockAPI, client.costExplorerClient) assert.Equal(t, region, client.region) - assert.NotNil(t, client.rateLimiter) + assert.NotNil(t, client.newRateLimiter) } func TestGetRecommendations_EC2_Success(t *testing.T) { @@ -274,9 +284,11 @@ func TestGetRecommendations_Error(t *testing.T) { riError: newThrottleError(), } - // Use custom rate limiter to speed up test + // Use custom rate limiter factory to speed up test client := NewClientWithAPI(mockAPI, "us-east-1") - client.rateLimiter = NewRateLimiterWithOptions(1*time.Millisecond, 10*time.Millisecond, 2) + client.newRateLimiter = func() *RateLimiter { + return NewRateLimiterWithOptions(1*time.Millisecond, 10*time.Millisecond, 2) + } params := common.RecommendationParams{ Service: common.ServiceEC2, @@ -373,7 +385,7 @@ func TestGetRecommendationsForService(t *testing.T) { // `PaymentOption` on each request and returns recs for that single // (term, payment) cell — so to let the user choose between every // variant in the UI we MUST issue one request per combo. The previous -// behaviour hardcoded ("3yr", "partial-upfront") and the user-visible +// behavior hardcoded ("3yr", "partial-upfront") and the user-visible // symptoms were "AWS recs only ever show Term = 3 Years" plus "no // all-upfront / no-upfront variants ever appear". We assert directly // against the captured input slice that all 6 (term, payment) combos @@ -518,7 +530,9 @@ func TestGetRecommendations_ContextCancellation(t *testing.T) { } client := NewClientWithAPI(mockAPI, "us-east-1") - client.rateLimiter = NewRateLimiterWithOptions(100*time.Millisecond, 1*time.Second, 5) + client.newRateLimiter = func() *RateLimiter { + return NewRateLimiterWithOptions(100*time.Millisecond, 1*time.Second, 5) + } ctx, cancel := context.WithCancel(context.Background()) cancel() // Cancel immediately @@ -534,7 +548,7 @@ func TestGetRecommendations_ContextCancellation(t *testing.T) { // With the pagination loop added (issue #692), ctx.Err() is checked at // the top of the first page iteration before the rate-limiter runs. A - // pre-cancelled context therefore returns context.Canceled directly, which + // pre-canceled context therefore returns context.Canceled directly, which // is the correct behavior per feedback_ctx_cancel_terminal.md. assert.Error(t, err) assert.Nil(t, recs) @@ -543,7 +557,7 @@ func TestGetRecommendations_ContextCancellation(t *testing.T) { // TestGetAllRecommendations_PropagatesContextCancellation pins the contract // that GetAllRecommendations propagates ctx.Err() to its caller after the -// errgroup Wait() — the parent context being cancelled or its deadline +// errgroup Wait() — the parent context being canceled or its deadline // exceeding must surface as an error rather than being swallowed by the // per-service error-isolation goroutines (which all return nil to the // errgroup so a single per-service failure does not cancel siblings). @@ -563,8 +577,8 @@ func TestGetAllRecommendations_PropagatesContextCancellation(t *testing.T) { // Cancel the context BEFORE the call so we don't depend on race-y // timing inside the SDK clients. The Cost Explorer calls inside the - // goroutines observe the cancelled gctx (derived from ctx via - // errgroup.WithContext) and either short-circuit or return cancelled + // goroutines observe the canceled gctx (derived from ctx via + // errgroup.WithContext) and either short-circuit or return canceled // errors; either way, our post-Wait ctx.Err() check returns // context.Canceled. ctx, cancel := context.WithCancel(context.Background()) diff --git a/providers/aws/recommendations/coverage.go b/providers/aws/recommendations/coverage.go index 6c7ac438f..d45b519e9 100644 --- a/providers/aws/recommendations/coverage.go +++ b/providers/aws/recommendations/coverage.go @@ -324,9 +324,9 @@ func serviceRegionFilter(service, region string) *types.Expression { // held only across the outbound SDK call, matching fetchRIPageWithRetry, // so the coverage-enrichment fan-out stays inside the global IO cap. func (c *Client) fetchCoveragePage(ctx context.Context, input *costexplorer.GetReservationCoverageInput) (*costexplorer.GetReservationCoverageOutput, error) { - c.rateLimiter.Reset() + rl := c.newRateLimiter() for { - if waitErr := c.rateLimiter.Wait(ctx); waitErr != nil { + if waitErr := rl.Wait(ctx); waitErr != nil { return nil, fmt.Errorf("rate limiter wait failed: %w", waitErr) } if acqErr := concurrency.Acquire(ctx); acqErr != nil { @@ -334,7 +334,7 @@ func (c *Client) fetchCoveragePage(ctx context.Context, input *costexplorer.GetR } result, err := c.costExplorerClient.GetReservationCoverage(ctx, input) concurrency.Release(ctx) - if !c.rateLimiter.ShouldRetry(err) { + if !rl.ShouldRetry(err) { if err != nil { return nil, fmt.Errorf("failed to get reservation coverage: %w", err) } diff --git a/providers/aws/recommendations/ondemand_series.go b/providers/aws/recommendations/ondemand_series.go index abbac66b4..159d7a0ee 100644 --- a/providers/aws/recommendations/ondemand_series.go +++ b/providers/aws/recommendations/ondemand_series.go @@ -173,13 +173,13 @@ func validateOnDemandSeriesArgs(region string, lookbackDays int) error { // utilization.go. Each attempt acquires one rate-limiter slot at the API call // site (not at goroutine creation) per feedback_semaphore_at_api_call. func (c *Client) fetchOnDemandPage(ctx context.Context, input *costexplorer.GetCostAndUsageInput) (*costexplorer.GetCostAndUsageOutput, error) { - c.rateLimiter.Reset() + rl := c.newRateLimiter() for { - if waitErr := c.rateLimiter.Wait(ctx); waitErr != nil { + if waitErr := rl.Wait(ctx); waitErr != nil { return nil, fmt.Errorf("rate limiter wait: %w", waitErr) } out, err := c.costExplorerClient.GetCostAndUsage(ctx, input) - if !c.rateLimiter.ShouldRetry(err) { + if !rl.ShouldRetry(err) { if err != nil { return nil, fmt.Errorf("GetCostAndUsage: %w", err) } diff --git a/providers/aws/recommendations/parser_sp.go b/providers/aws/recommendations/parser_sp.go index 41b865eaf..5e203b44b 100644 --- a/providers/aws/recommendations/parser_sp.go +++ b/providers/aws/recommendations/parser_sp.go @@ -27,7 +27,7 @@ import ( // 2. Otherwise, fall back to the legacy IncludeSPTypes/ExcludeSPTypes // filter mechanism (for callers passing the umbrella ServiceSavingsPlans // slug or for direct CLI invocations that haven't been migrated yet). -func (c *Client) getSavingsPlansRecommendations(ctx context.Context, params common.RecommendationParams) ([]common.Recommendation, error) { +func (c *Client) getSavingsPlansRecommendations(ctx context.Context, params *common.RecommendationParams) ([]common.Recommendation, error) { planTypes := planTypesForParams(params) if len(planTypes) == 0 { @@ -85,7 +85,7 @@ func (c *Client) getSavingsPlansRecommendations(ctx context.Context, params comm func (c *Client) fetchSPAllPages( ctx context.Context, input *costexplorer.GetSavingsPlansPurchaseRecommendationInput, - params common.RecommendationParams, + params *common.RecommendationParams, planType types.SupportedSavingsPlansType, ) ([]common.Recommendation, error) { var allRecs []common.Recommendation @@ -132,12 +132,12 @@ func (c *Client) fetchSPPageWithRetry( ctx context.Context, input *costexplorer.GetSavingsPlansPurchaseRecommendationInput, ) (*costexplorer.GetSavingsPlansPurchaseRecommendationOutput, error) { - c.rateLimiter.Reset() + rl := c.newRateLimiter() var result *costexplorer.GetSavingsPlansPurchaseRecommendationOutput var err error for { - if waitErr := c.rateLimiter.Wait(ctx); waitErr != nil { + if waitErr := rl.Wait(ctx); waitErr != nil { return nil, fmt.Errorf("rate limiter wait failed: %w", waitErr) } @@ -146,7 +146,7 @@ func (c *Client) fetchSPPageWithRetry( } result, err = c.costExplorerClient.GetSavingsPlansPurchaseRecommendation(ctx, input) concurrency.Release(ctx) - if !c.rateLimiter.ShouldRetry(err) { + if !rl.ShouldRetry(err) { break } } @@ -158,10 +158,10 @@ func (c *Client) fetchSPPageWithRetry( return result, nil } -// parseSavingsPlansRecommendations converts Savings Plans recommendations +// parseSavingsPlansRecommendations converts Savings Plans recommendations. func (c *Client) parseSavingsPlansRecommendations( spRec *types.SavingsPlansPurchaseRecommendation, - params common.RecommendationParams, + params *common.RecommendationParams, planType types.SupportedSavingsPlansType, ) []common.Recommendation { var recommendations []common.Recommendation @@ -196,7 +196,7 @@ const hoursPerMonth = 730.0 func (c *Client) parseSavingsPlanDetail( detail *types.SavingsPlansPurchaseRecommendationDetail, - params common.RecommendationParams, + params *common.RecommendationParams, planType types.SupportedSavingsPlansType, ) *common.Recommendation { hourlyCommitment := parseOptionalFloat("HourlyCommitmentToPurchase", detail.HourlyCommitmentToPurchase) @@ -252,7 +252,7 @@ func (c *Client) parseSavingsPlanDetail( // frontend can distinguish "known-zero" from "data not provided". // - partial-upfront / no-upfront: recurring ≈ HourlyCommitmentToPurchase // × 730. For partial-upfront this slightly over-counts (it includes the - // amortised upfront portion), but AWS CE does not expose the recurring- + // amortized upfront portion), but AWS CE does not expose the recurring- // only hourly rate directly, so this is the best available approximation. // - nil: HourlyCommitmentToPurchase was absent from the API response // (should not happen for well-formed CE responses, but handled defensively). @@ -296,7 +296,7 @@ func (c *Client) parseSavingsPlanDetail( // otherwise it falls back to the legacy IncludeSPTypes/ExcludeSPTypes filter. // See the getSavingsPlansRecommendations docstring for the full resolution // order. -func planTypesForParams(params common.RecommendationParams) []types.SupportedSavingsPlansType { +func planTypesForParams(params *common.RecommendationParams) []types.SupportedSavingsPlansType { if pt, ok := planTypeForServiceSlug(params.Service); ok { return []types.SupportedSavingsPlansType{pt} } @@ -339,10 +339,21 @@ func serviceSlugForPlanType(pt types.SupportedSavingsPlansType) common.ServiceTy return common.ServiceSavingsPlans } +// normalizeFilterSet lowercases each entry in filters and returns them as a +// set (map[string]bool). Extracted from getFilteredPlanTypes so the function +// literal does not capture outer variables (gocritic unlambda). +func normalizeFilterSet(filters []string) map[string]bool { + result := make(map[string]bool, len(filters)) + for _, f := range filters { + result[strings.ToLower(f)] = true + } + return result +} + // getFilteredPlanTypes returns the list of Savings Plan types to query based // on include/exclude filters. Iterates a fixed-order slice rather than a map // so the returned order is deterministic — downstream "first plan type wins" -// behaviour and test assertions can rely on it. +// behavior and test assertions can rely on it. func getFilteredPlanTypes(includeSPTypes, excludeSPTypes []string) []types.SupportedSavingsPlansType { allPlanTypes := []struct { name string @@ -354,21 +365,12 @@ func getFilteredPlanTypes(includeSPTypes, excludeSPTypes []string) []types.Suppo {"database", types.SupportedSavingsPlansTypeDatabaseSp}, } - // Normalize filter values to lowercase - normalizeFilters := func(filters []string) map[string]bool { - result := make(map[string]bool) - for _, f := range filters { - result[strings.ToLower(f)] = true - } - return result - } - - includeMap := normalizeFilters(includeSPTypes) - excludeMap := normalizeFilters(excludeSPTypes) + includeMap := normalizeFilterSet(includeSPTypes) + excludeMap := normalizeFilterSet(excludeSPTypes) var result []types.SupportedSavingsPlansType - // If include list is specified, only include those types + // If include list is specified, only include those types. if len(includeMap) > 0 { for _, item := range allPlanTypes { if includeMap[item.name] && !excludeMap[item.name] { @@ -376,7 +378,7 @@ func getFilteredPlanTypes(includeSPTypes, excludeSPTypes []string) []types.Suppo } } } else { - // Include all types except those in the exclude list + // Include all types except those in the exclude list. for _, item := range allPlanTypes { if !excludeMap[item.name] { result = append(result, item.typ) diff --git a/providers/aws/recommendations/parser_sp_additional_test.go b/providers/aws/recommendations/parser_sp_additional_test.go index 70f864d22..ae6914a4c 100644 --- a/providers/aws/recommendations/parser_sp_additional_test.go +++ b/providers/aws/recommendations/parser_sp_additional_test.go @@ -149,7 +149,7 @@ func TestParseSavingsPlanDetail(t *testing.T) { for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { - rec := client.parseSavingsPlanDetail(tt.detail, tt.params, tt.planType) + rec := client.parseSavingsPlanDetail(tt.detail, &tt.params, tt.planType) require.NotNil(t, rec) if tt.validate != nil { tt.validate(t, rec) @@ -186,7 +186,7 @@ func TestParseSavingsPlansRecommendations(t *testing.T) { LookbackPeriod: "7d", } - recs := client.parseSavingsPlansRecommendations(spRec, params, types.SupportedSavingsPlansTypeComputeSp) + recs := client.parseSavingsPlansRecommendations(spRec, ¶ms, types.SupportedSavingsPlansTypeComputeSp) assert.Len(t, recs, 2) @@ -212,7 +212,7 @@ func TestParseSavingsPlansRecommendations_Empty(t *testing.T) { LookbackPeriod: "7d", } - recs := client.parseSavingsPlansRecommendations(spRec, params, types.SupportedSavingsPlansTypeComputeSp) + recs := client.parseSavingsPlansRecommendations(spRec, ¶ms, types.SupportedSavingsPlansTypeComputeSp) assert.Empty(t, recs) } @@ -296,7 +296,7 @@ func TestGetSavingsPlansRecommendations_WithFilters(t *testing.T) { IncludeSPTypes: []string{"Compute", "Database"}, } - recs, err := client.getSavingsPlansRecommendations(context.Background(), params) + recs, err := client.getSavingsPlansRecommendations(context.Background(), ¶ms) require.NoError(t, err) assert.Len(t, recs, 2) @@ -314,7 +314,7 @@ func TestGetSavingsPlansRecommendations_EmptyFilters(t *testing.T) { ExcludeSPTypes: []string{"Compute", "EC2Instance", "SageMaker", "Database"}, } - recs, err := client.getSavingsPlansRecommendations(context.Background(), params) + recs, err := client.getSavingsPlansRecommendations(context.Background(), ¶ms) require.NoError(t, err) assert.Empty(t, recs) @@ -367,7 +367,7 @@ func TestParseSavingsPlanDetail_OnDemandCost(t *testing.T) { for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { - rec := client.parseSavingsPlanDetail(tt.detail, params, types.SupportedSavingsPlansTypeComputeSp) + rec := client.parseSavingsPlanDetail(tt.detail, ¶ms, types.SupportedSavingsPlansTypeComputeSp) require.NotNil(t, rec) assert.InDelta(t, tt.wantOnDemand, rec.OnDemandCost, 0.001, "OnDemandCost should equal CurrentAverageHourlyOnDemandSpend × 730") @@ -533,7 +533,7 @@ func TestGetSavingsPlansRecommendations_Paginates(t *testing.T) { LookbackPeriod: "7d", } - recs, err := client.getSavingsPlansRecommendations(context.Background(), params) + recs, err := client.getSavingsPlansRecommendations(context.Background(), ¶ms) require.NoError(t, err) // 2 + 3 + 4 = 9 detail items assert.Len(t, recs, 9, "must accumulate recs across all 3 SP pages") @@ -558,7 +558,7 @@ func TestGetSavingsPlansRecommendations_EmptyTokenTerminates(t *testing.T) { LookbackPeriod: "7d", } - recs, err := client.getSavingsPlansRecommendations(context.Background(), params) + recs, err := client.getSavingsPlansRecommendations(context.Background(), ¶ms) require.NoError(t, err) assert.Len(t, recs, 2) assert.Equal(t, 1, mock.calls, "empty-string token must terminate pagination after page 1") @@ -576,7 +576,7 @@ func TestGetSavingsPlansRecommendations_PaginationCapError(t *testing.T) { LookbackPeriod: "7d", } - _, err := client.getSavingsPlansRecommendations(context.Background(), params) + _, err := client.getSavingsPlansRecommendations(context.Background(), ¶ms) require.Error(t, err) assert.Contains(t, err.Error(), "pagination cap reached") assert.Equal(t, maxRecommendationPages, mock.calls, diff --git a/providers/aws/recommendations/parser_sp_test.go b/providers/aws/recommendations/parser_sp_test.go index f896d427c..fa4fc5784 100644 --- a/providers/aws/recommendations/parser_sp_test.go +++ b/providers/aws/recommendations/parser_sp_test.go @@ -194,7 +194,7 @@ func TestParseSavingsPlanDetail_RecommendedUtilization(t *testing.T) { HourlyCommitmentToPurchase: aws.String("1.0"), EstimatedAverageUtilization: tt.utilizationStr, } - rec := client.parseSavingsPlanDetail(detail, params, types.SupportedSavingsPlansTypeComputeSp) + rec := client.parseSavingsPlanDetail(detail, ¶ms, types.SupportedSavingsPlansTypeComputeSp) require.NotNil(t, rec) assert.Equal(t, tt.wantUtilization, rec.RecommendedUtilization, "SP utilization should be parsed into rec.RecommendedUtilization") diff --git a/providers/aws/recommendations/sp_coverage.go b/providers/aws/recommendations/sp_coverage.go index 808c25662..f72d8873b 100644 --- a/providers/aws/recommendations/sp_coverage.go +++ b/providers/aws/recommendations/sp_coverage.go @@ -298,11 +298,9 @@ func (a *spCoverageAccumulator) summarize(windowHours float64) SPCoverageSummary // Days==0 as "no data for this scope", not "no SPs in the account", and // check Days before dereferencing the pointer fields. // -// Concurrency: Client is NOT safe for concurrent SP calls - the shared -// rateLimiter's Reset() is unsynchronized (see -// feedback_rate_limiter_per_call), so concurrent callers race on the retry -// counter. Call from a single goroutine per Client; the ladder consumer -// (PR 5) is sequential by design. +// Concurrency: each fetch builds its own RateLimiter via c.newRateLimiter() +// (see feedback_rate_limiter_per_call), so concurrent SP calls no longer +// race on a shared retry counter. func (c *Client) GetSPCoverageSummary(ctx context.Context, region string, lookbackDays int) (SPCoverageSummary, error) { if lookbackDays <= 0 { return SPCoverageSummary{}, fmt.Errorf("sp coverage: lookbackDays must be positive, got %d", lookbackDays) @@ -361,13 +359,13 @@ func (c *Client) GetSPCoverageSummary(ctx context.Context, region string, lookba // fetchSPCoveragePage calls GetSavingsPlansCoverage with rate-limit retry. // Mirrors fetchCoveragePage in coverage.go so both paths back off consistently. func (c *Client) fetchSPCoveragePage(ctx context.Context, input *costexplorer.GetSavingsPlansCoverageInput) (*costexplorer.GetSavingsPlansCoverageOutput, error) { - c.rateLimiter.Reset() + rl := c.newRateLimiter() for { - if waitErr := c.rateLimiter.Wait(ctx); waitErr != nil { + if waitErr := rl.Wait(ctx); waitErr != nil { return nil, fmt.Errorf("rate limiter wait failed: %w", waitErr) } result, err := c.costExplorerClient.GetSavingsPlansCoverage(ctx, input) - if !c.rateLimiter.ShouldRetry(err) { + if !rl.ShouldRetry(err) { if err != nil { return nil, fmt.Errorf("failed to get SP coverage: %w", err) } @@ -412,11 +410,9 @@ func (c *Client) fetchSPCoveragePage(ctx context.Context, input *costexplorer.Ge // real GetSavingsPlansUtilization per plan type) and confirm the returned // dimension values match the map. // -// Concurrency: Client is NOT safe for concurrent SP calls - the shared -// rateLimiter's Reset() is unsynchronized (see -// feedback_rate_limiter_per_call), so concurrent callers race on the retry -// counter. Call from a single goroutine per Client; the ladder consumer -// (PR 5) is sequential by design. +// Concurrency: each fetch builds its own RateLimiter via c.newRateLimiter() +// (see feedback_rate_limiter_per_call), so concurrent SP calls no longer +// race on a shared retry counter. func (c *Client) GetSPUtilization(ctx context.Context, planType types.SupportedSavingsPlansType, region string, lookbackDays int) (SPUtilizationSummary, error) { if err := validateSPPlanType(planType); err != nil { return SPUtilizationSummary{}, fmt.Errorf("sp utilization: %w", err) @@ -452,13 +448,13 @@ func (c *Client) GetSPUtilization(ctx context.Context, planType types.SupportedS // fetchSPUtilizationPage calls GetSavingsPlansUtilization with rate-limit retry. // Mirrors fetchUtilizationPage in utilization.go so both paths back off consistently. func (c *Client) fetchSPUtilizationPage(ctx context.Context, input *costexplorer.GetSavingsPlansUtilizationInput) (*costexplorer.GetSavingsPlansUtilizationOutput, error) { - c.rateLimiter.Reset() + rl := c.newRateLimiter() for { - if waitErr := c.rateLimiter.Wait(ctx); waitErr != nil { + if waitErr := rl.Wait(ctx); waitErr != nil { return nil, fmt.Errorf("rate limiter wait failed: %w", waitErr) } result, err := c.costExplorerClient.GetSavingsPlansUtilization(ctx, input) - if !c.rateLimiter.ShouldRetry(err) { + if !rl.ShouldRetry(err) { if err != nil { return nil, fmt.Errorf("failed to get SP utilization: %w", err) } diff --git a/providers/aws/recommendations/utilization.go b/providers/aws/recommendations/utilization.go index c17f6a4db..2e8fa14dc 100644 --- a/providers/aws/recommendations/utilization.go +++ b/providers/aws/recommendations/utilization.go @@ -102,9 +102,9 @@ func buildUtilizations(agg map[string]*riAccumulator) []RIUtilization { // across the outbound SDK call, matching fetchRIPageWithRetry, so callers // running under a semaphore-carrying context stay inside the global IO cap. func (c *Client) fetchUtilizationPage(ctx context.Context, input *costexplorer.GetReservationUtilizationInput) (*costexplorer.GetReservationUtilizationOutput, error) { - c.rateLimiter.Reset() + rl := c.newRateLimiter() for { - if waitErr := c.rateLimiter.Wait(ctx); waitErr != nil { + if waitErr := rl.Wait(ctx); waitErr != nil { return nil, fmt.Errorf("rate limiter wait failed: %w", waitErr) } @@ -113,7 +113,7 @@ func (c *Client) fetchUtilizationPage(ctx context.Context, input *costexplorer.G } result, err := c.costExplorerClient.GetReservationUtilization(ctx, input) concurrency.Release(ctx) - if !c.rateLimiter.ShouldRetry(err) { + if !rl.ShouldRetry(err) { if err != nil { return nil, fmt.Errorf("failed to get reservation utilization: %w", err) } diff --git a/providers/aws/service_client.go b/providers/aws/service_client.go index 902187860..51c9a595c 100644 --- a/providers/aws/service_client.go +++ b/providers/aws/service_client.go @@ -66,7 +66,7 @@ type RecommendationsClientAdapter struct { // NewRecommendationsClient creates a new recommendations client func NewRecommendationsClient(cfg aws.Config) provider.RecommendationsClient { return &RecommendationsClientAdapter{ - client: recommendations.NewClient(cfg), + client: recommendations.NewClient(&cfg), } } @@ -184,7 +184,7 @@ func (r *RecommendationsClientAdapter) SetRecLookbackPeriod(period string) { // (needed for GetRIUtilization which is not part of the generic provider interface). func NewRecommendationsClientDirect(cfg aws.Config) *RecommendationsClientAdapter { return &RecommendationsClientAdapter{ - client: recommendations.NewClient(cfg), + client: recommendations.NewClient(&cfg), } }