Ledger

Error Handling

Sentinel errors returned by Ledger operations.

Ledger defines sentinel errors in the root ledger package. All errors are created with errors.New and can be checked with errors.Is.

Store errors

ErrorDescription
ErrNoStoreNo store was configured
ErrStoreClosedThe store has been closed
ErrMigrationFailedDatabase migration failed
ErrDatabaseConnectionDatabase connection failed

Not-found errors

ErrorDescription
ErrPlanNotFoundPlan with the given ID does not exist
ErrSubscriptionNotFoundSubscription with the given ID does not exist
ErrCustomerNotFoundCustomer with the given ID does not exist
ErrInvoiceNotFoundInvoice with the given ID does not exist
ErrUsageEventNotFoundUsage event with the given ID does not exist
ErrPaymentNotFoundPayment with the given ID does not exist
ErrFeatureNotFoundFeature with the given ID does not exist

Conflict errors

ErrorDescription
ErrAlreadyExistsA resource with the same identifier already exists
ErrDuplicateCustomerEmailCustomer with this email already exists
ErrDuplicatePlanNamePlan with this name already exists
ErrDuplicateIdempotencyKeyUsage event with this idempotency key already exists

State errors

ErrorDescription
ErrInvalidStateInvalid state transition (e.g., activating a canceled subscription)
ErrSubscriptionCanceledThe subscription has been canceled
ErrSubscriptionExpiredThe subscription has expired
ErrInvoiceAlreadyPaidThe invoice has already been paid
ErrInvoiceVoidedThe invoice has been voided
ErrTrialExpiredThe trial period has expired
ErrPaymentFailedPayment processing failed

Billing errors

ErrorDescription
ErrNoPaymentMethodNo payment method attached to customer
ErrInsufficientFundsPayment failed due to insufficient funds
ErrCardDeclinedPayment card was declined
ErrUsageLimitExceededUsage limit exceeded and overage not allowed
ErrInvalidAmountInvalid monetary amount (must be positive)
ErrInvalidCurrencyUnsupported currency code
ErrMissingPricePlan has no pricing tiers configured

Entitlement errors

ErrorDescription
ErrFeatureNotEnabledFeature is not available in customer's plan
ErrQuotaExceededFeature usage quota exceeded
ErrNoActiveSubscriptionCustomer has no active subscription
ErrEntitlementExpiredEntitlement has expired

Provider errors

ErrorDescription
ErrProviderNotConfiguredPayment provider not configured
ErrProviderAPIErrorPayment provider API returned an error
ErrProviderTimeoutPayment provider request timed out
ErrWebhookVerificationFailedWebhook signature verification failed
ErrInvalidWebhookPayloadWebhook payload is invalid or malformed

Validation errors

ErrorDescription
ErrInvalidEmailEmail address format is invalid
ErrInvalidPlanIDPlan ID format is invalid
ErrInvalidCustomerIDCustomer ID format is invalid
ErrInvalidTypeIDTypeID format or prefix is invalid
ErrMissingRequiredFieldRequired field is missing
ErrInvalidDateRangeInvalid date range (end before start)

Error wrapping

Store implementations wrap these sentinel errors with additional context:

return fmt.Errorf("postgres: get subscription %s: %w", subID, ledger.ErrSubscriptionNotFound)

Check errors using errors.Is:

sub, err := store.GetSubscription(ctx, subID)
if errors.Is(err, ledger.ErrSubscriptionNotFound) {
    // handle not found
}

API error mapping

The HTTP API maps sentinel errors to HTTP status codes:

Sentinel errorHTTP status
ErrPlanNotFound, ErrSubscriptionNotFound, etc.404 Not Found
ErrAlreadyExists, ErrDuplicate*409 Conflict
ErrInvalidState, ErrInvalid*, validation errors400 Bad Request
ErrPaymentFailed, ErrCardDeclined402 Payment Required
ErrFeatureNotEnabled, ErrQuotaExceeded403 Forbidden
ErrNoStore, ErrProviderAPIError500 Internal Server Error
ErrProviderTimeout504 Gateway Timeout

Error response format

All API errors follow a consistent JSON structure:

{
  "error": {
    "code": "subscription_not_found",
    "message": "Subscription sub_01h2xcejqtf2nbrexx3vqjhp44 does not exist",
    "type": "not_found",
    "param": "subscription_id",
    "details": {}
  }
}

Fields:

  • code — Machine-readable error code (snake_case)
  • message — Human-readable error message
  • type — Error category: validation, not_found, conflict, payment, provider, server
  • param — The parameter that caused the error (optional)
  • details — Additional context (optional)

Custom error handling

Application code can define custom error types by embedding the base error:

type InsufficientCreditsError struct {
    CustomerID CustomerID
    Required   int64
    Available  int64
}

func (e *InsufficientCreditsError) Error() string {
    return fmt.Sprintf("customer %s has %d credits, but %d required",
        e.CustomerID, e.Available, e.Required)
}

Error logging

Ledger logs errors with structured fields for observability:

logger.Error("subscription creation failed",
    "error", err,
    "customer_id", customerID,
    "plan_id", planID,
    "trace_id", traceID,
)

Integration with OpenTelemetry for distributed tracing is supported.

Retry logic

Certain errors are retryable:

ErrorRetryableStrategy
ErrDatabaseConnectionYesExponential backoff
ErrProviderTimeoutYesFixed delay (1s)
ErrProviderAPIErrorSometimesCheck provider status code
ErrPaymentFailedNoUser intervention required
ErrInvalidStateNoLogic error

Example retry implementation:

func CreateSubscriptionWithRetry(ctx context.Context, sub *Subscription) error {
    backoff := []time.Duration{100*time.Millisecond, 500*time.Millisecond, 1*time.Second}

    for i := 0; i < len(backoff); i++ {
        err := store.CreateSubscription(ctx, sub)
        if err == nil {
            return nil
        }

        if !isRetryable(err) {
            return err
        }

        if i < len(backoff)-1 {
            time.Sleep(backoff[i])
        }
    }

    return fmt.Errorf("max retries exceeded")
}

func isRetryable(err error) bool {
    return errors.Is(err, ledger.ErrDatabaseConnection) ||
           errors.Is(err, ledger.ErrProviderTimeout)
}

On this page