Repository navigation
fix(ri-exchange): InvalidOfferingId 400 -- UI accepts instance type for the Target Offering ID field, no validation, AWS 400 surfaces as 500 #695
Description
Symptom
User attempted to exchange a freshly-purchased Convertible RI via the RI Exchange UI. The UI returned:
Quote failed: exchange quote failed
Three attempts logged in CloudWatch (/aws/lambda/cudly-dev-426fc8af-api):
2026/05/22 22:26:52 [ERROR] exchange quote failed: operation error EC2: GetReservedInstancesExchangeQuote,
StatusCode: 400, api error InvalidOfferingId: Invalid Offering ID provided
2026/05/22 22:27:04 [ERROR] exchange quote failed: ... InvalidOfferingId: Invalid Offering ID provided
2026/05/22 22:34:59 [ERROR] exchange quote failed: ... InvalidOfferingId: Invalid Offering ID provided
UI screenshot showed:
- Source RI:
296818b6-73f8-4cd2-94bc-dbb95f794812(CUDly internal record UUID) - Target Offering ID field:
t3.medium(an instance type, NOT an AWS offering UUID) - Count:
1
Root cause
The "Target Offering ID" form field accepts free-form text and we forward it straight to AWS's GetReservedInstancesExchangeQuote as TargetConfigurations[].OfferingId. AWS expects a ReservedInstancesOfferingId UUID (e.g. 4b2293b4-5fbc-4017-9c75-d5a9d3aa8c91), so "t3.medium" triggers a 400 InvalidOfferingId.
Three distinct defects compound here:
Defect 1 -- UI is asking the user to type the wrong thing
The field labeled "Target Offering ID" is unusable as-is. A typical user has no way to obtain a valid AWS offering UUID outside of a CLI call. The UI must instead:
- Show a picker of valid target offerings (instance type / family / size, term, payment option) sourced from
DescribeReservedInstancesOfferingsfiltered by convertible + same-term + same-OS as the source RI, OR - Show a picker of cross-family alternatives sourced from the cached Cost Explorer recs (the
fillAlternativesFromRecspath already exists), OR - Both, side by side.
Free-text input for an opaque AWS UUID is never the right UX here.
Defect 2 -- No client- or server-side validation of the offering-id shape
The frontend accepts any string, sends it. The backend (internal/api/handler_ri_exchange.go around line 374 / 412) only checks the field is non-empty:
if t.OfferingID == "" {
return nil, NewClientError(400, fmt.Sprintf("targets[%d].offering_id is required", i))
}It does not validate the value matches an AWS offering-id pattern (UUID-ish hex). Even adding a regex guard would have caught t3.medium before the AWS round-trip.
Defect 3 -- 400 from AWS is re-emitted as 500 to the user
handler_ri_exchange.go:391-392:
logging.Errorf("exchange quote failed: %v", err)
return nil, NewClientError(500, "exchange quote failed")AWS's InvalidOfferingId is a 4xx (client supplied bad input). We re-classify it as a 500 and drop the underlying error message. Matches the feedback_http_status_classification.md memory entry: transient server-side failures must be 5xx; definite client faults are 4xx; never swap the categories. The UI sees "exchange quote failed" instead of "the offering ID you provided is invalid".
Fix scope
- Frontend (highest impact): replace the free-text "Target Offering ID" input with a dropdown / search-as-you-type populated from a new GET endpoint that lists valid target offerings for the selected source RI. The endpoint runs
DescribeReservedInstancesOfferingsserver-side with the right narrowing filters (same convertible + term + product description + scope as the source RI, varying instance type / family). Samepkg/exchangetyped-fields shape as PR fix(purchases): narrow Describe*Offerings + cap pagination (#688) #690 -- use typedOfferingTypeetc., notFilters[]. - Backend validation: add a regex / format check on
targets[].offering_idinhandler_ri_exchange.gothat fails fast with a 400 + descriptive message ("looks like an instance type rather than an offering UUID; expected a value like 4b2293b4-...") before calling AWS. - Status-code mapping: at
handler_ri_exchange.go:391-392, inspect the AWS error -- asmithy.APIErrorwithErrorCode() == "InvalidOfferingId"(or any code AWS classifies as 4xx) should produceNewClientError(400, awsErr.ErrorMessage()), not a generic 500. Other AWS error codes that are documented client-fault:InvalidInstanceID.NotFound,InvalidParameter,ValidationError,InvalidReservedInstancesId.NotFound. Same handling needed at the corresponding ExecuteExchange call site around line 445. - Tests: handler-level test asserting an
InvalidOfferingIdmock error from the EC2 client surfaces as a 400 to the caller with the AWS message preserved. Frontend test exercising the new picker (no free-text input path remains).
Acceptance criteria
- Frontend never lets the user type an instance type into a field that expects an AWS offering UUID
- Backend validates the offering-id format before any AWS call
- AWS 4xx errors surface as 4xx to the client with the underlying error message preserved
- Regression test asserts the InvalidOfferingId scenario produces a 4xx, not a 500
- Same RI exchanges cleanly via the UI in the dev account (manual verification)
Cross-references
- Issue fix(purchases): narrow Describe*Offerings calls + cap pagination across 7 services (lookup root cause behind 60s budget overrun) #688 / PR fix(purchases): narrow Describe*Offerings + cap pagination (#688) #690 (the purchase path that produced this RI)
- Issue feat(settings): configurable EC2 RI OfferingClass (Convertible vs Standard) #694 (configurable OfferingClass) -- adjacent settings work
internal/api/handler_ri_exchange.go,pkg/exchange/*,internal/api/exchange_lookup.go-- the code under investigationfeedback_http_status_classification.md-- defect AWS + Azure: add read-only sanity checks + AWS RI exchange #3 is a textbook hitfeedback_empty_string_vs_error.md-- defect Setup CI/CD Process #2 is the same shape (accepting an invalid value silently instead of rejecting at the boundary)
Activity
fix(ri-exchange): picker UI + offering-id validation + AWS 4xx mapping (
cristim commentedon May 28, 2026
Verified shipped: PR #696 (commit f3240f4, merged 2026-05-25) is in feat/multicloud-web-frontend. Fixes all three defects: (1) free-text offering-id input replaced with picker backed by new GET /api/ri-exchange/target-offerings endpoint, (2) UUID format validation before AWS call, (3) AWS InvalidOfferingId 400 mapped to client 400 with message preserved. Tests added in handler_ri_exchange_test.go and riexchange.test.ts.
fix(ri-exchange): picker UI + offering-id validation + AWS 4xx mapping (