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:
-
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_missing → ErrProviderResourceMissing
- 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.
-
A shared error mapper for billing handlers. Used as the fallback instead of bare CodeInternal:
ErrProviderResourceMissing → failed_precondition, message "billing account is no longer linked to the payment provider"
ErrPaymentFailed → failed_precondition with the payment message
ErrProviderUnavailable → unavailable, 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.
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
internalfor these cases causes two problems:internalerrors 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),
GetUpcomingInvoicereturnsinternalon 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.Errorbubbles up to the handler, and the handler's fallback for any unrecognized error isinternal. There are ~88 such fallback wraps across the billing handlers.RPCs affected by state-caused failures that show as
internaltoday:GetUpcomingInvoiceGetBillingAccount(with payment methods)CreateCheckout,DelegatedCheckoutCancelSubscriptionChangeSubscriptionCreateBillingAccount,UpdateBillingAccount,RegisterBillingAccountThe fix
Fix it once at each layer instead of patching 88 call sites:
A Stripe error translator in the billing package. One helper that every service wraps its Stripe calls with. It turns
*stripe.Errorinto typed domain errors:resource_missing→ErrProviderResourceMissingErrPaymentFailed(keep Stripe's human-readable message)ErrProviderUnavailableAfter this, no raw Stripe error ever leaves the billing services.
A shared error mapper for billing handlers. Used as the fallback instead of bare
CodeInternal:ErrProviderResourceMissing→failed_precondition, message "billing account is no longer linked to the payment provider"ErrPaymentFailed→failed_preconditionwith the payment messageErrProviderUnavailable→unavailable, so clients know to retryfailed_preconditioninternal, same as todayWhy this way
Error codes are the API's way of telling the caller whose move it is.
internalsays "wait for Frontier to fix it";failed_preconditionsays "your account is in a state you need to change";unavailablesays "retry in a moment". Getting these right means SDK users get errors they can act on, andinternalgoes back to meaning what it should: a real bug in Frontier.