Skip to content

fix(aws/recommendations): protect RateLimiter against concurrent access (closes #271)#865

Open
cristim wants to merge 3 commits into
mainfrom
fix/271-wave18
Open

fix(aws/recommendations): protect RateLimiter against concurrent access (closes #271)#865
cristim wants to merge 3 commits into
mainfrom
fix/271-wave18

Conversation

@cristim

@cristim cristim commented May 30, 2026

Copy link
Copy Markdown
Member

Summary

  • RateLimiter was stored as a single *RateLimiter field on Client and shared across all 6 concurrent goroutines spawned by GetAllRecommendations. Reset, ShouldRetry, and GetRetryCount all mutate retryCount without synchronisation, causing data races detected by go test -race.
  • Replaced the shared rateLimiter *RateLimiter field with a newRateLimiter func() *RateLimiter factory. Each of the four fetch*WithRetry / fetch*Page functions now calls rl := c.newRateLimiter() at entry, giving every call site its own independent retry budget (correct: these are not shared throughput limiters).
  • Guarded callCount and riCalls mutations in mockCostExplorerAPI with a sync.Mutex - they were also racing when GetAllRecommendations called the mock from multiple goroutines.

Test plan

  • go test -race github.com/LeanerCloud/CUDly/providers/aws/recommendations/... passes (280 tests, no data race report)

🤖 Generated with claude-flow

Summary by CodeRabbit

  • Bug Fixes

    • Improved error handling in batch recommendations retrieval to explicitly report failures instead of silently ignoring them.
  • Refactor

    • Updated AWS recommendations client to use per-call rate limiting instead of shared instances, enhancing reliability and testability.
    • Strengthened thread-safety in internal test mocking mechanisms.

@cristim cristim added triaged Item has been triaged priority/p3 Polish / idea / may never ship severity/low Minor harm urgency/eventually No deadline impact/internal Team-internal only effort/s Hours type/bug Defect labels May 30, 2026
@coderabbitai

coderabbitai Bot commented May 30, 2026

Copy link
Copy Markdown
Contributor

Review Change Stack

Important

Review skipped

No new commits to review since the last review.

⚙️ Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Pro

Run ID: e56745eb-1c7b-458d-a5d1-2ddc4ac8de02

You can disable this status message by setting the reviews.review_status to false in the CodeRabbit configuration file.

Use the checkbox below for a quick retry:

  • 🔍 Trigger review
📝 Walkthrough

Walkthrough

Replaces the shared rateLimiter field on Client with a newRateLimiter func() *RateLimiter factory, so each fetch function creates its own limiter per API call. NewClient now accepts *aws.Config. NewClientWithAPI is added for test injection. Savings Plans internal functions are migrated to *common.RecommendationParams. normalizeFilterSet is extracted. GetAllRecommendations panics on unexpected non-nil errgroup.Wait() results.

Changes

Rate limiter factory, SP params pointer, and errgroup panic

Layer / File(s) Summary
Client struct field and constructor changes
providers/aws/recommendations/client.go, providers/aws/service_client.go
Replaces rateLimiter *RateLimiter with newRateLimiter func() *RateLimiter on Client. NewClient now takes *aws.Config and sets newRateLimiter: NewRateLimiter. NewClientWithAPI is added for test-time API injection. service_client.go callers updated to pass &cfg.
Per-call rate limiter in fetch functions
providers/aws/recommendations/client.go, providers/aws/recommendations/coverage.go, providers/aws/recommendations/parser_sp.go, providers/aws/recommendations/utilization.go
fetchRIPageWithRetry, fetchCoveragePage, fetchSPPageWithRetry, and fetchUtilizationPage each call c.newRateLimiter() to get a fresh limiter, removing all c.rateLimiter.Reset() calls.
Savings Plans functions migrated to *RecommendationParams
providers/aws/recommendations/parser_sp.go, providers/aws/recommendations/client.go
getSavingsPlansRecommendations, fetchSPAllPages, parseSavingsPlansRecommendations, parseSavingsPlanDetail, and planTypesForParams now accept *common.RecommendationParams. GetRecommendations passes &params. normalizeFilterSet is extracted and used in getFilteredPlanTypes.
GetAllRecommendations errgroup panic
providers/aws/recommendations/client.go
Replaces _ = g.Wait() with an explicit panic if g.Wait() returns non-nil. Updates comment to state per-service errors are logged via mergeServiceResults.
Test updates: mutex mock, factory injection, pointer params
providers/aws/recommendations/client_test.go, providers/aws/recommendations/parser_sp_additional_test.go, providers/aws/recommendations/parser_sp_test.go
mockCostExplorerAPI gains a sync.Mutex. TestNewClient passes &cfg and asserts newRateLimiter. Rate-limiter injection tests switch to factory pattern. All SP test call sites pass &params.

Estimated code review effort

🎯 3 (Moderate) | ⏱️ ~25 minutes

Possibly related issues

Possibly related PRs

  • LeanerCloud/CUDly#254: Modifies parseSavingsPlanDetail in parser_sp.go, the same function whose signature is changed to pointer params in this PR.
  • LeanerCloud/CUDly#269: Modifies GetAllRecommendations errgroup/Wait() error handling and mergeServiceResults logging, directly overlapping with the panic change in this PR.
  • LeanerCloud/CUDly#270: Modifies fetchSPPageWithRetry retry/rate-limiting flow in parser_sp.go, the same function refactored to use c.newRateLimiter() here.

Suggested labels

priority/p1, severity/high, urgency/this-sprint, impact/many, effort/m

Poem

🐇 Hoppity hop through the rate-limit maze,
No more shared limiter in a concurrent haze!
Each API call gets its own little clock,
Pointers to params—no more value shock.
The errgroup shall panic if nil goes awry,
A cleaner refactor beneath the cloud sky! ☁️

🚥 Pre-merge checks | ✅ 4 | ❌ 1

❌ Failed checks (1 warning)

Check name Status Explanation Resolution
Docstring Coverage ⚠️ Warning Docstring coverage is 58.33% which is insufficient. The required threshold is 80.00%. Write docstrings for the functions missing them to satisfy the coverage threshold.
✅ Passed checks (4 passed)
Check name Status Explanation
Description Check ✅ Passed Check skipped - CodeRabbit’s high-level summary is enabled.
Title check ✅ Passed The title clearly and specifically describes the main change: fixing a data race condition in RateLimiter by protecting against concurrent access, directly corresponding to the core change of replacing a shared rateLimiter with a per-call factory.
Linked Issues check ✅ Passed Check skipped because no linked issues were found for this pull request.
Out of Scope Changes check ✅ Passed Check skipped because no linked issues were found for this pull request.

✏️ Tip: You can configure your own custom pre-merge checks in the settings.

✨ Finishing Touches
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Commit unit tests in branch fix/271-wave18

Comment @coderabbitai help to get the list of available commands and usage tips.

@cristim

cristim commented May 30, 2026

Copy link
Copy Markdown
Member Author

@coderabbitai review

@coderabbitai

coderabbitai Bot commented May 30, 2026

Copy link
Copy Markdown
Contributor
✅ Actions performed

Review triggered.

Note: CodeRabbit is an incremental review system and does not re-review already reviewed commits. This command is applicable only when automatic reviews are paused.

@cristim

cristim commented Jun 4, 2026

Copy link
Copy Markdown
Member Author

@coderabbitai review

@coderabbitai

coderabbitai Bot commented Jun 4, 2026

Copy link
Copy Markdown
Contributor
✅ Action performed

Review finished.

Note: CodeRabbit is an incremental review system and does not re-review already reviewed commits. This command is applicable only when automatic reviews are paused.

@cristim

cristim commented Jun 7, 2026

Copy link
Copy Markdown
Member Author

Rebased fix/271-wave18 onto current feat/multicloud-web-frontend (was DIRTY/CONFLICTING).

Conflict resolution (providers/aws/recommendations/client.go): the base branch added ec2API, instanceTypePagerFactory, and skuCatalog fields plus their initialization; this PR replaced the shared rateLimiter *RateLimiter field with a per-call newRateLimiter func() *RateLimiter factory. Both sides are additive, so the resolution keeps all of the base's new fields AND the per-call factory. coverage.go, parser_sp.go, and utilization.go auto-merged; client_test.go (mock-counter sync.Mutex guard + factory-based test setup) merged cleanly.

The fix (08-C1) is unchanged and applied at all four goroutine call sites:

  • client.go (fetchRIPageWithRetry)
  • coverage.go (fetchCoveragePage)
  • parser_sp.go (fetchSPPageWithRetry)
  • utilization.go (GetRIUtilization)

Verification post-rebase:

  • go build ./... (repo root) - pass
  • go build ./... + go vet ./recommendations/... (providers/aws) - pass
  • go test -race ./recommendations/... - 302 tests pass, zero data races (the Critical race is gone)

Note: go vet ./... in providers/aws surfaces a pre-existing base-branch compile error in services/savingsplans/client_test.go:991 (findOfferingID now takes 3 args; the test still calls it with 2). This PR does not touch services/savingsplans/, so it is out of scope here and tracked separately.

@coderabbitai review

@coderabbitai

coderabbitai Bot commented Jun 7, 2026

Copy link
Copy Markdown
Contributor

@cristim: Thanks for the detailed rebase notes. The conflict resolution looks clean — keeping all of the base branch's new fields alongside the per-call newRateLimiter factory is the correct additive merge. The pre-existing savingsplans/client_test.go compile error being out of scope is duly noted.

Triggering a fresh review of the rebased branch now.


🧠 Learnings used
Learnt from: cristim
Repo: LeanerCloud/CUDly PR: 0
File: :0-0
Timestamp: 2026-05-05T15:44:54.463Z
Learning: In the LeanerCloud/CUDly repository, when a CodeRabbit review flags something as "outside-diff" that was already implemented in an earlier commit of the same PR, it is likely a stale-snapshot artifact where the review diff was generated against a pre-fix view. The inline comment convention `(CR pass on PR #<number> Finding N)` is used by the maintainer (cristim) to mark lines that were changed in direct response to a prior CodeRabbit review pass.

Learnt from: cristim
Repo: LeanerCloud/CUDly PR: 339
File: providers/aws/recommendations/coverage.go:177-205
Timestamp: 2026-05-13T21:30:43.093Z
Learning: In the CUDly project (LeanerCloud/CUDly), the CLI flag for target-based coverage sizing is `--target-coverage` (not `--target-utilization`). The PR renamed it during design iteration: "utilization" (how full each RI is) stays ~100% under the new sizing, while "coverage" (% of demand covered) is the user-facing knob. `ce:GetReservationCoverage` is required only when `--target-coverage` is set, gated by `cfg.TargetCoverage <= 0` in `cmd/multi_service.go`. The legacy `--coverage` path is unaffected and pays no CE cost or IAM change.

Learnt from: cristim
Repo: LeanerCloud/CUDly PR: 0
File: :0-0
Timestamp: 2026-05-04T14:28:19.903Z
Learning: In providers/azure/recommendations.go (Go, Azure provider), the service constructors (compute.NewClient, cache.NewClient, etc.) build concrete Azure SDK types with no interface injection seam. Introducing a concurrency timing test that asserts elapsed ≈ max-single-service latency would require adding package-level function variables — a refactor that is out of scope for small performance PRs.
✅ Action performed

Review finished.

Note: CodeRabbit is an incremental review system and does not re-review already reviewed commits. This command is applicable only when automatic reviews are paused.

cristim added a commit that referenced this pull request Jun 7, 2026
…ilent defaults (#1085)

* fix(aws/savingsplans): error on unknown payment-option/term, no silent default

convertPaymentOption and convertTermToSeconds previously logged a warning
and silently substituted All Upfront (largest cash outlay) or 1 year when
given an unrecognized or empty value, risking a mis-purchase on any typo or
new/renamed AWS enum value.

Both functions now return an explicit error on unrecognized input so the
purchase and offering-lookup paths fail loud instead of buying the wrong
product. Mirrors the pattern already used by the RDS convertPaymentOption.

Regression tests for H1 (unknown/empty payment option) and H2 (unknown/empty
term) are added to TestClient_FindOfferingID_AllPaymentOptions and
TestClient_FindOfferingID_TermVariations; the previously-passing "default term"
case is updated to assert the error path.

Fixes H1 and H2 from docs/code-review/19-hardcoded-fallbacks-aws.md (PR #1039).

* fix(aws): fail loud on unknown payment-option/term/engine

Fixes H3, H4, M1-M6, L1, L2 from the 19-hardcoded-fallbacks-aws.md
audit plus H1/H2 from the now-merged #1039 branch (savingsplans).

H3 (converters.go): add convertPaymentOptionE/convertTermInYearsE/
convertLookbackPeriodE that error on unrecognized input; wire into the
SP recommendation path (parser_sp.go). The legacy non-erroring wrappers
are retained for the RI path in client.go (owned by #865/#1075) with a
deprecation comment and a follow-up reference.

H4 (elasticache/client.go): convertPaymentOption now returns (string, error)
mirroring the RDS sibling that already errors correctly.

H1/H2 (savingsplans/client.go): convertPaymentOption and
convertTermToSeconds now return (type, error); cherry-picked from the
merged #1039 branch where the fix was staged.

M4 (parser_services.go, rds/client.go): parseRDSDetails no longer
silently defaults AZConfig to "single-az" when CE omits DeploymentOption;
findOfferingID rejects empty AZConfig with an explicit error.

M5 (parser_services.go): resolveEC2Tenancy now maps only the known CE
values ("shared"/nil -> default, "dedicated" -> dedicated); any other
value (e.g. "host" for Dedicated Hosts) returns an error rather than
silently collapsing to default tenancy.

M6 (rds/client.go): normalizeEngineName now returns (string, error) and
errors for ambiguous Oracle ("oracle*"), SQL Server ("sqlserver*"/"sql-server*"),
and bare Aurora inputs; unambiguous engines (mysql, postgresql, mariadb,
aurora-mysql, aurora-postgresql) pass through unchanged.

M1/M2/M3 (ec2/client.go): replace raw "convertible" literals with
types.OfferingClassTypeConvertible; buildEC2OfferingQuery errors on empty
Platform (purchase path); centralize "Linux/UNIX" literal into
defaultEC2Platform constant (exchange-path helpers retain their safe
fallback; the purchase path does not).

Regression tests added for H3, H1/H2, H4, M2, M4, M5, M6, L1, L2; each
fails pre-fix and passes post-fix.

Closes #1084

* test(aws/rds): clarify ambiguous-engine regression test

Simplify TestNormalizeEngineName_AmbiguousErrors: remove the confusing
oracle-se2 case (strings.Contains("oracle-se2","oracle") is true, so it
errors like all other oracle inputs -- no special case needed) and remove
the misleading inline comment. All remaining cases assert that any Oracle,
SQL Server, or bare Aurora input errors, which is the invariant under test.

* refactor(aws): extract helpers to fix gocyclo violations in rds and savingsplans

gocyclo -over 10 fails on two functions added by the fail-loud refactor:
- savingsplans.(*Client).findOfferingID (complexity 12): extract
  resolveSPPlanType and buildSPOfferingsInput helpers
- rds.(*Client).paginateRDSOfferings (complexity 11): extract
  fetchRDSOfferingPage to isolate the per-page API call and scan

Also apply gofmt to rds/client.go (trailing whitespace / brace style).
Behaviour and error paths are unchanged; all rds and savingsplans
tests still pass.

* fix(aws/rds): tighten AZ-config and engine-name validation (CR #1085)

Three related fail-loud fixes in the AWS RDS purchase path:

1. parseRDSDetails: unknown DeploymentOption now errors instead of
   silently folding into "single-az". Only "Multi-AZ" and "Single-AZ"
   are accepted; any other non-nil value (e.g. "Multi-AZ-Readable-Standbys")
   returns an explicit error so the bad token surfaces rather than
   driving findOfferingID to the wrong RI class.

2. findOfferingID: AZConfig validation now uses a switch with an
   explicit default error case rather than only checking for empty string.
   A non-empty but invalid value (typo, future AWS enum addition) would
   previously fall through to multiAZ==false in paginateRDSOfferings,
   silently treating the bad input as single-AZ.

3. normalizeEngineName: Oracle and SQL Server checks changed from
   strings.Contains to == so that edition-qualified tokens (oracle-se2,
   oracle-ee, sqlserver-se, sqlserver-web, etc.) pass through as valid
   RDS ProductDescription values. The previous Contains check contradicted
   the error message itself, which told callers to supply those exact
   edition tokens.

Regression tests added for all three: each new test was verified to
fail against the pre-fix code and pass after.
@cristim

cristim commented Jun 8, 2026

Copy link
Copy Markdown
Member Author

@coderabbitai full review

@coderabbitai

coderabbitai Bot commented Jun 8, 2026

Copy link
Copy Markdown
Contributor
✅ Action performed

Full review finished.

@cristim cristim changed the base branch from feat/multicloud-web-frontend to main June 9, 2026 15:43
cristim added 2 commits June 19, 2026 23:32
)

RateLimiter was a single *RateLimiter field on Client, shared across the
6 concurrent goroutines that GetAllRecommendations fans out via errgroup.
Reset/ShouldRetry/GetRetryCount all mutate retryCount without a lock,
triggering data races under go test -race.

Per feedback_rate_limiter_per_call.md: each goroutine needs its own
independent retry budget (not shared throughput), so per-call
instantiation is correct over adding a mutex.

Replace rateLimiter *RateLimiter with newRateLimiter func() *RateLimiter.
Each fetch*WithRetry / fetch*Page function calls rl := c.newRateLimiter()
at entry. Tests inject a factory returning a faster limiter for speed.

Also fix mockCostExplorerAPI: callCount and riCalls were mutated by
concurrent goroutines without synchronisation. Add sync.Mutex guard.
…-touched files

- godot: add missing periods on doc comments in client.go, client_test.go, parser_sp.go
- misspell (US locale): catalogue->catalog, behaviour->behavior, amortised->amortized, cancelled->canceled in touched files
- errcheck: replace blank _ = g.Wait() with explicit invariant-enforcing panic so the linter sees the error handled
- gocritic unlambda: extract normalizeFilters closure to package-level normalizeFilterSet function
- gocritic hugeParam: suppress on functions taking RecommendationParams by value (read-only; pointer-cascade is a larger refactor deferred to a follow-up issue)
- nolint:gocritic on NewClient (aws.Config by-value per SDK convention)
@cristim

cristim commented Jun 19, 2026

Copy link
Copy Markdown
Member Author

@coderabbitai review

@coderabbitai

coderabbitai Bot commented Jun 19, 2026

Copy link
Copy Markdown
Contributor
✅ Action performed

Review finished.

Note: CodeRabbit is an incremental review system and does not re-review already reviewed commits. This command is applicable only when automatic reviews are paused.

Remove 6 gocritic hugeParam nolint suppressions from
providers/aws/recommendations:

- NewClient now accepts *aws.Config instead of aws.Config;
  callers in service_client.go updated to pass &cfg.
- getSavingsPlansRecommendations, fetchSPAllPages,
  parseSavingsPlansRecommendations, parseSavingsPlanDetail,
  planTypesForParams all accept *common.RecommendationParams
  instead of the value type; internal callers updated.

Tests in client_test.go, parser_sp_test.go, and
parser_sp_additional_test.go updated to pass pointer arguments.
@cristim

cristim commented Jun 20, 2026

Copy link
Copy Markdown
Member Author

@coderabbitai review

@coderabbitai

coderabbitai Bot commented Jun 20, 2026

Copy link
Copy Markdown
Contributor
✅ Action performed

Review finished.

Note: CodeRabbit is an incremental review system and does not re-review already reviewed commits. This command is applicable only when automatic reviews are paused.

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🧹 Nitpick comments (1)
providers/aws/recommendations/client.go (1)

359-364: ⚡ Quick win

Return the unexpected wait error instead of panicking.

GetAllRecommendations already returns an error; if this invariant ever regresses, Line 363 can crash callers instead of surfacing the fan-out failure through the API.

Proposed fix
 	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())
+		return nil, fmt.Errorf("recommendations fan-out failed unexpectedly: %w", waitErr)
 	}
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@providers/aws/recommendations/client.go` around lines 359 - 364, The code
panics when g.Wait() returns a non-nil error in the GetAllRecommendations
function, but since this function already has an error return type, the panic
should be replaced with a proper error return. Remove the panic statement and
instead return the waitErr directly through the function's error return value to
properly surface the failure through the API rather than crashing the caller.
🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Nitpick comments:
In `@providers/aws/recommendations/client.go`:
- Around line 359-364: The code panics when g.Wait() returns a non-nil error in
the GetAllRecommendations function, but since this function already has an error
return type, the panic should be replaced with a proper error return. Remove the
panic statement and instead return the waitErr directly through the function's
error return value to properly surface the failure through the API rather than
crashing the caller.

ℹ️ Review info
⚙️ Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Pro

Run ID: 09455575-bad4-4852-940f-bf3c0cb26acf

📥 Commits

Reviewing files that changed from the base of the PR and between 451a70f and 948ccdd.

📒 Files selected for processing (8)
  • providers/aws/recommendations/client.go
  • providers/aws/recommendations/client_test.go
  • providers/aws/recommendations/coverage.go
  • providers/aws/recommendations/parser_sp.go
  • providers/aws/recommendations/parser_sp_additional_test.go
  • providers/aws/recommendations/parser_sp_test.go
  • providers/aws/recommendations/utilization.go
  • providers/aws/service_client.go

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

effort/s Hours impact/internal Team-internal only priority/p3 Polish / idea / may never ship severity/low Minor harm triaged Item has been triaged type/bug Defect urgency/eventually No deadline

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant