Skip to content

fix(ri-exchange): InvalidOfferingId 400 -- UI accepts instance type for the Target Offering ID field, no validation, AWS 400 surfaces as 500 #695

Description

@cristim

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 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:

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

  1. 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[].
  2. 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.
  3. 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.
  4. 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)

Cross-references

No activity

Activity on this issue will appear here.

Activity

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

Metadata

Metadata

Assignees

No one assigned

    Type

    No type

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions