You signed in with another tab or window. Reload to refresh your session.You signed out in another tab or window. Reload to refresh your session.You switched accounts on another tab or window. Reload to refresh your session.Dismiss alert
{{ message }}
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
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 DescribeReservedInstancesOfferings filtered 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 fillAlternativesFromRecs path 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:
ift.OfferingID=="" {
returnnil, 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
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 DescribeReservedInstancesOfferings server-side with the right narrowing filters (same convertible + term + product description + scope as the source RI, varying instance type / family). Same pkg/exchange typed-fields shape as PR fix(purchases): narrow Describe*Offerings + cap pagination (#688) #690 -- use typed OfferingType etc., not Filters[].
Backend validation: add a regex / format check on targets[].offering_id in handler_ri_exchange.go that 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 -- a smithy.APIError with ErrorCode() == "InvalidOfferingId" (or any code AWS classifies as 4xx) should produce NewClientError(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 InvalidOfferingId mock 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)
feedback_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)
Symptom
User attempted to exchange a freshly-purchased Convertible RI via the RI Exchange UI. The UI returned:
Three attempts logged in CloudWatch (
/aws/lambda/cudly-dev-426fc8af-api):UI screenshot showed:
296818b6-73f8-4cd2-94bc-dbb95f794812(CUDly internal record UUID)t3.medium(an instance type, NOT an AWS offering UUID)1Root cause
The "Target Offering ID" form field accepts free-form text and we forward it straight to AWS's
GetReservedInstancesExchangeQuoteasTargetConfigurations[].OfferingId. AWS expects aReservedInstancesOfferingIdUUID (e.g.4b2293b4-5fbc-4017-9c75-d5a9d3aa8c91), so"t3.medium"triggers a 400InvalidOfferingId.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:
DescribeReservedInstancesOfferingsfiltered by convertible + same-term + same-OS as the source RI, ORfillAlternativesFromRecspath already exists), ORFree-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.goaround line 374 / 412) only checks the field is non-empty:It does not validate the value matches an AWS offering-id pattern (UUID-ish hex). Even adding a regex guard would have caught
t3.mediumbefore the AWS round-trip.Defect 3 -- 400 from AWS is re-emitted as 500 to the user
handler_ri_exchange.go:391-392:AWS's
InvalidOfferingIdis a 4xx (client supplied bad input). We re-classify it as a 500 and drop the underlying error message. Matches thefeedback_http_status_classification.mdmemory 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
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[].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.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.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
Cross-references
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)