Repository navigation
fix(purchases): narrow Describe*Offerings calls + cap pagination across 7 services (lookup root cause behind 60s budget overrun) #688
Description
Activity
- addedtriagedItem has been triagedItem has been triagedpriority/p1Next up; this sprintNext up; this sprintseverity/highSignificant harmSignificant harmurgency/nowDrop other thingsDrop other thingsimpact/all-usersAffects every userAffects every usereffort/mDaysDaystype/bugDefectDefect
on May 22, 2026 - added a commit that references this issue
on May 22, 2026 Surveying the codebase outside the PR #690 footprint (Azure, GCP, and AWS recommendations client) for the same failure patterns.
Smell #2 + #1: Azure pagination loops -- no
ctx.Err()check, no page capAll five Azure service clients iterate
for pager.More() { pager.NextPage(ctx) }for three operations each (GetRecommendations, GetExistingCommitments, GetValidResourceTypes) with no page budget and noctx.Err()guard between pages. The Azure SDK pager does propagate context cancellation throughNextPage(ctx), but only on the next call -- a slow page that takes the remaining Lambda budget will not be interrupted mid-wait.providers/azure/services/compute/client.go:136,171,362-- Smell Setup CI/CD Process #2, medium effort (5 loops, 3 files share identical shape)providers/azure/services/cache/client.go:136,170,359-- Smell Setup CI/CD Process #2providers/azure/services/database/client.go:138,172-- Smell Setup CI/CD Process #2providers/azure/services/cosmosdb/client.go:139,173,364-- Smell Setup CI/CD Process #2providers/azure/services/search/client.go:136,170,358-- Smell Setup CI/CD Process #2
Risk: could exhaust Lambda budget on a slow or high-volume subscription result set.
Effort: small (addif err := ctx.Err(); err != nil { return ..., err }at the top of each loop body, add a page cap constant).
Smell #5: Azure Retail Prices API --
NextPageLinkparsed but never followedAzureRetailPricestructs incache/client.go:115,database/client.go:117,cosmosdb/client.go:118, andsearch/client.go:115decodeNextPageLinkfrom the response but none of theget*Pricingfunctions follow it. If the pricing API paginates (it does for broad queries like Cosmos DB'sserviceName eq 'Azure Cosmos DB'without an SKU filter), only page-1 pricing is used. The compute client (compute/client.go) decodes into a bareAzureRetailPricestruct with noNextPageLinkfield at all, so it always truncates silently.providers/azure/services/cosmosdb/client.go:417-491-- Smell Remove Hard SES dependency #5, risk: could silently truncate pricing data, producing wrong cost estimatesproviders/azure/services/search/client.go:408-483-- Smell Remove Hard SES dependency #5 (broad filter, no SKU predicate)providers/azure/services/cache/client.go:417-492-- Smell Remove Hard SES dependency #5providers/azure/services/database/client.go:425-499-- Smell Remove Hard SES dependency #5providers/azure/services/compute/client.go:410-484-- Smell Remove Hard SES dependency #5 (noNextPageLinkfield at all)
Risk: silently truncates; wrong pricing leads to bad purchase decisions.
Effort: small-medium (addNextPageLinkfollow loop in eachget*Pricinghelper).
Smell #5: GCP Cloud Billing
ListSKUs-- single-page call, no paginationrealBillingService.ListSKUscalls.Do()once and returns. ThecloudbillingREST API returns anextPageTokeninListSkusResponse; SKU catalogs regularly span multiple pages. The callers incomputeengine/client.go:485-520,cloudsql/client.go:398-430, andmemorystore/client.go:377-411silently truncate at the first page.providers/gcp/services/computeengine/client.go:143-145(realBillingService.ListSKUs) -- Smell Remove Hard SES dependency #5, risk: silently truncates SKU catalog; pricing lookup may return zero, triggering hardcoded fallback discountproviders/gcp/services/cloudsql/client.go:101-103-- sameproviders/gcp/services/memorystore/client.go:113-115-- same
Effort: small (add
PageTokenloop inListSKUs; the interface signature can stay the same by collecting all pages internally).
Smell #2: GCP iterator loops -- no
ctx.Err()checkcomputeengine/client.go:205,247,440,cloudsql/client.go:163,memorystore/client.go:163,202use unboundedfor { it.Next() }loops. GCP iterators honour context cancellation onNext(), but only surface it as an error -- there is no early-exit check between iterations.providers/gcp/services/computeengine/client.go:205-- Smell Setup CI/CD Process #2providers/gcp/services/computeengine/client.go:247-- Smell Setup CI/CD Process #2
Risk: could exhaust Lambda budget on large result sets before the iterator surfaces cancellation.
Effort: trivial (checkctx.Err()at loop top).
Summary: 14 distinct loop locations across 8 files. Primary smells: #2 (no
ctx.Err()between pages) and #5 (single-page calls that silently truncate). No smell #3 (typed-field vs Filters[]) or #4 (unverified[0]return) found outside the PR #690 footprint.- added a commit that references this issue
on May 22, 2026 Context Cancellation Findings
Found 2 context-cancellation issues in AWS recommendations code (outside PR #690 scope):
Finding 1: GetAllRecommendations (
providers/aws/recommendations/client.go:106-127)- Loops services calling GetRecommendationsForService; silently continues on error
- Returns partial results + nil error when context deadline fires mid-loop
- Line 119-120:
if err != nil { continue }thenreturn allRecommendations, nil
Finding 2: getSavingsPlansRecommendations (
providers/aws/recommendations/client.go:418-464)- Loops plan types; rate-limiter + continue on error
- Same pattern: partial results + nil error when deadline fires mid-loop
- Line 452-454:
if err != nil { fmt.Printf(...); continue }thenreturn allRecommendations, nil
Both match
feedback_ctx_cancel_terminal.md: never continue on context.Canceled/DeadlineExceeded; return immediately.AWS follow-up survey (outside PR #690 footprint)
Surveyed all AWS Go files outside the 7 excluded service dirs:
providers/aws/recommendations/client.go+ratelimiter.goproviders/aws/provider.goproviders/aws/internal/tagging/purchase_tags.goproviders/aws/service_client.gocmd/helpers.go,cmd/main.go,cmd/multi_service.go
Findings
F1 —
cmd/multi_service.go:1335(smell 5 — single-page call that should paginate)
queryMajorEngineVersionscallsrdsClient.DescribeDBMajorEngineVersionsonce per engine type and readsoutput.DBMajorEngineVersionswithout threading theMarkertoken. The SDK confirms bothInput.MarkerandOutput.Markerexist (rds@v1.97.3). On accounts with many engine versions the last page(s) are silently dropped, producing incomplete extended-support data used to filter RI recommendations.
Effort: small.F2 —
providers/aws/recommendations/client.go:79(smell 5 — single-page call that should paginate)
GetRecommendationscallsGetReservationPurchaseRecommendationonce and never readsresult.NextPageToken. The CE SDK (costexplorer@v1.61.0) exposes bothInput.NextPageTokenandOutput.NextPageToken. Large payer accounts (>100 instance-type/region/term combinations) will silently miss later pages of recommendations.
Effort: small.F3 —
providers/aws/recommendations/client.go:446(smell 5 — single-page call that should paginate)
getSavingsPlansRecommendationscallsGetSavingsPlansPurchaseRecommendationonce per plan type without threadingNextPageToken. Same root cause as F2; accounts with many SP details can have results truncated.
Effort: small.F4 —
cmd/multi_service.go:1265(smell 2 — context cancellation swallowed in pagination loop)
queryRunningInstanceEngineVersionsrunsDescribeDBInstancesin a goroutine-per-region fan-out. When the call fails (line 1265), the error is logged and the goroutinebreaks — but context cancellation (ctx.Err()) is treated identically to a transient API error. The parentwg.Wait()at line 1302 then returns a partial result with no error, silently producing an incomplete instance-version map that feeds the recommendation filter.
Effort: small.
Summary
4 findings, all in the AWS-only paths outside PR #690. No High-risk/Small-effort finding that clearly belongs on #691 (which is bounded-pagination on describe/list calls; F2 and F3 are CE purchase-recommendation single-page calls — related shape but different service). F1, F2, F3 are all smell 5 (missing pagination); F4 is smell 2 (ctx cancellation swallowed). Deferring to you on whether any of these warrant adding to #691 or a new issue.
PR #690 (merged into
feat/multicloud-web-frontend) addresses this issue end-to-end across the four offering-lookup-bearing commits on the branch:- 237c30f — initial fix: narrow
Describe*Offeringsserver-side filters (EC2OfferingType, MemoryDBOfferingType+Duration); add pagination cap at 5 pages (maxOfferingPages);ctx.Err()between pages; defense-in-depth payment-variant verification; per-page timing log; cover the seven purchase clients (EC2, RDS, ElastiCache, MemoryDB, OpenSearch, Redshift, SavingsPlans); 21 new regression tests. - 5f8f269 — EC2 follow-up: live-AWS verification showed that packing
InstanceType/ProductDescription/InstanceTenancy/Duration/OfferingClassintoFilters[]was what caused the empty-page walk; promote them to typed top-level fields onDescribeReservedInstancesOfferingsInputso AWS hits its primary indices and returns the matching offering immediately. - 95eca72 — soft-skip guards + nil/empty token parity: EC2/RDS pagination terminator guards against both
niland empty-stringNextToken/Marker; EC2 uses anisLastEC2Pagehelper for clarity; OpenSearch removes a redundant post-match defense-in-depth that could never fire. - f2becf7 — CR fix(purchases): narrow Describe*Offerings + cap pagination (#688) #690 follow-up: ElastiCache pagination terminator gets the same nil-or-empty guard; ElastiCache
convertPaymentOptionnow returns(string, error)so unknown payment options surface explicitly instead of being silently coerced to "Partial Upfront"; OpenSearch and Redshift unreachable variant-mismatch guards repositioned to fire on first mismatch.
CI sanity, AWS/Azure read-only dry runs, and pre-commit (gocyclo, vet, gofmt) all pass on the merged commit. CodeRabbit's review on PR #690 was addressed across the four commits above.
Recommending close.
- 237c30f — initial fix: narrow
Symptom
Execution
9336a111-85c5-4b74-9991-367350de9bac(Lambda 60s budget, dev env, pre-#684) failed with:CloudWatch report:
Duration: 60000.77 ms-- the Lambda spent its entire budget paginating throughDescribeReservedInstancesOfferingsand was killed before it could even write the failed status (compounding into #632-style audit loss).Reproduction via AWS CLI:
Distinction from #667
#667 diagnosed the same failure shape as an SDK-retry budget overrun and asserted "This is NOT a pagination issue -- buildOfferingFilters correctly narrows by ... so the result set is typically a single page." The CLI reproduction above falsifies that assertion: AWS returns empty pages with
NextTokeneven for the narrow filter set, walking sparse offerings indefinitely. #684 (closing #667) caps the per-rec wall clock at 30s viacontext.WithTimeoutand makes hangs observable, but the underlying lookup still spends the full budget; the rec fails every time it hits this offering layout. This issue is the lookup-correctness fix that #684's budget cap is masking.Root cause: offering lookup is under-constrained
providers/aws/services/ec2/client.go:320-346(buildOfferingFilters):Critical gap: the
offering-typefilter (payment option) is never set. AWS therefore returns every payment variant of every offering that matches the other filters, and the loop atclient.go:367-398then returnsresult.ReservedInstancesOfferings[0]from the first non-empty page without verifying its OfferingType matches the requested payment option.Two failure modes follow:
NextTokenuntil it lands on a matching offering set or the budget expires.The same shape recurs across the sibling services that copy
buildOfferingFilters-style logic: RDS, ElastiCache, MemoryDB, OpenSearch, Redshift, SavingsPlans. Each has its own filter syntax (Filters[] for EC2, direct fields for the others) but the same "filter set under-constrains the API" pattern likely applies.Fix scope: sweep all 7 purchase-path services
For each of
providers/aws/services/{ec2,rds,elasticache,memorydb,opensearch,redshift,savingsplans}/client.go:Narrow the describe call to the minimum needed for a deterministic match. Each service's
Describe*OfferingsAPI supports filters beyond what we currently set; identify the maximally narrow filter set (region is implicit via the client's region; instance/node/cluster type, term, payment option, offering class, product description, tenancy/scope, etc. where applicable) and apply all of them.Add the missing payment-option filter explicitly (EC2
offering-type, equivalents per service):no-upfrontNo Upfrontpartial-upfrontPartial Upfrontall-upfrontAll UpfrontVerify the returned offering's payment option matches before returning it (defense in depth -- even with the filter, the loop should reject mismatched variants instead of grabbing
offerings[0]).Cap pagination at a fixed page count (e.g.
maxOfferingPages = 5-- with MaxResults=100 that's up to 500 offerings) and fail fast with an explicitpagination cap reached after N pageserror that names the rec. This converts "Lambda timeout + audit loss" into "fast diagnostic error + clean status save".Honour
ctx.Done()between pages so a parent-context cancellation surfaces immediately instead of waiting for the next AWS response.Increase MaxResults from 100 to 1000 (the AWS max for these APIs) so fewer round-trips are needed when a match is buried.
Acceptance criteria
findOfferingID(or equivalent) across the 7 services applies the maximally narrow filter set, including the payment-option mappingctx.Err()between pagesNextToken-> assert fast failure with the cap error (not a context-deadline error)Risk / blast radius
Cross-references
WithTimeoutmakes this fail faster; this issue makes it not fail at all