Skip to content

Billing APIs return "internal" for errors the caller can act on #1836

Description

@whoAbhishekSah

The problem

Almost every billing handler wraps failures in connect.CodeInternal. That code means "something broke inside Frontier". But many of these failures are not Frontier bugs — they describe the state of the caller's billing account, and often the caller can fix them.

Returning internal for these cases causes two problems:

  • For the user: the error says nothing. They cannot tell whether they did something wrong, whether their account is in a bad state, or whether Frontier is down. There is nothing to act on, so they either retry blindly or file a support ticket.
  • For anyone running Frontier: internal errors stop meaning anything. Real bugs and ordinary account states look identical in logs and metrics, so genuine failures are hard to spot.

The clearest example: if an org's billing account points to a Stripe customer that no longer exists (see #1835 for how that happens), GetUpcomingInvoice returns internal on every single call — even though nothing in Frontier is broken.

The root cause is that we inspect Stripe errors in only four narrow places in the billing services. Everywhere else, the raw stripe.Error bubbles up to the handler, and the handler's fallback for any unrecognized error is internal. There are ~88 such fallback wraps across the billing handlers.

RPCs affected by state-caused failures that show as internal today:

RPC Example trigger
GetUpcomingInvoice billing account points to a deleted Stripe customer
GetBillingAccount (with payment methods) same
CreateCheckout, DelegatedCheckout checkout against a deleted Stripe customer, payment failures
CancelSubscription subscription already canceled, or already gone on Stripe
ChangeSubscription plan change already in progress ("try again later")
CreateBillingAccount, UpdateBillingAccount, RegisterBillingAccount Stripe rejects the input, or the Stripe customer is gone

The fix

Fix it once at each layer instead of patching 88 call sites:

  1. A Stripe error translator in the billing package. One helper that every service wraps its Stripe calls with. It turns *stripe.Error into typed domain errors:

    • resource_missingErrProviderResourceMissing
    • card and payment errors → ErrPaymentFailed (keep Stripe's human-readable message)
    • rate limits and Stripe outages → ErrProviderUnavailable
    • anything else stays as-is
      After this, no raw Stripe error ever leaves the billing services.
  2. A shared error mapper for billing handlers. Used as the fallback instead of bare CodeInternal:

    • ErrProviderResourceMissingfailed_precondition, message "billing account is no longer linked to the payment provider"
    • ErrPaymentFailedfailed_precondition with the payment message
    • ErrProviderUnavailableunavailable, so clients know to retry
    • state errors (already canceled, change in progress, pending dues) → failed_precondition
    • everything unknown → internal, same as today

Why this way

Error codes are the API's way of telling the caller whose move it is. internal says "wait for Frontier to fix it"; failed_precondition says "your account is in a state you need to change"; unavailable says "retry in a moment". Getting these right means SDK users get errors they can act on, and internal goes back to meaning what it should: a real bug in Frontier.

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    No type

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions