Ledger defines sentinel errors in the root ledger package. All errors are created with errors.New and can be checked with errors.Is.
Store errors01
| Error | Description |
ErrNoStore | No store was configured |
ErrStoreClosed | The store has been closed |
ErrMigrationFailed | Database migration failed |
ErrDatabaseConnection | Database connection failed |
Not-found errors02
| Error | Description |
ErrPlanNotFound | Plan with the given ID does not exist |
ErrSubscriptionNotFound | Subscription with the given ID does not exist |
ErrCustomerNotFound | Customer with the given ID does not exist |
ErrInvoiceNotFound | Invoice with the given ID does not exist |
ErrUsageEventNotFound | Usage event with the given ID does not exist |
ErrPaymentNotFound | Payment with the given ID does not exist |
ErrFeatureNotFound | Feature with the given ID does not exist |
Conflict errors03
| Error | Description |
ErrAlreadyExists | A resource with the same identifier already exists |
ErrDuplicateCustomerEmail | Customer with this email already exists |
ErrDuplicatePlanName | Plan with this name already exists |
ErrDuplicateIdempotencyKey | Usage event with this idempotency key already exists |
State errors04
| Error | Description |
ErrInvalidState | Invalid state transition (e.g., activating a canceled subscription) |
ErrSubscriptionCanceled | The subscription has been canceled |
ErrSubscriptionExpired | The subscription has expired |
ErrInvoiceAlreadyPaid | The invoice has already been paid |
ErrInvoiceVoided | The invoice has been voided |
ErrTrialExpired | The trial period has expired |
ErrPaymentFailed | Payment processing failed |
Billing errors05
| Error | Description |
ErrNoPaymentMethod | No payment method attached to customer |
ErrInsufficientFunds | Payment failed due to insufficient funds |
ErrCardDeclined | Payment card was declined |
ErrUsageLimitExceeded | Usage limit exceeded and overage not allowed |
ErrInvalidAmount | Invalid monetary amount (must be positive) |
ErrInvalidCurrency | Unsupported currency code |
ErrMissingPrice | Plan has no pricing tiers configured |
Entitlement errors06
| Error | Description |
ErrFeatureNotEnabled | Feature is not available in customer's plan |
ErrQuotaExceeded | Feature usage quota exceeded |
ErrNoActiveSubscription | Customer has no active subscription |
ErrEntitlementExpired | Entitlement has expired |
Provider errors07
| Error | Description |
ErrProviderNotConfigured | Payment provider not configured |
ErrProviderAPIError | Payment provider API returned an error |
ErrProviderTimeout | Payment provider request timed out |
ErrWebhookVerificationFailed | Webhook signature verification failed |
ErrInvalidWebhookPayload | Webhook payload is invalid or malformed |
Validation errors08
| Error | Description |
ErrInvalidEmail | Email address format is invalid |
ErrInvalidPlanID | Plan ID format is invalid |
ErrInvalidCustomerID | Customer ID format is invalid |
ErrInvalidTypeID | TypeID format or prefix is invalid |
ErrMissingRequiredField | Required field is missing |
ErrInvalidDateRange | Invalid date range (end before start) |
Error wrapping09
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 mapping10
The HTTP API maps sentinel errors to HTTP status codes:
| Sentinel error | HTTP status |
ErrPlanNotFound, ErrSubscriptionNotFound, etc. | 404 Not Found |
ErrAlreadyExists, ErrDuplicate* | 409 Conflict |
ErrInvalidState, ErrInvalid*, validation errors | 400 Bad Request |
ErrPaymentFailed, ErrCardDeclined | 402 Payment Required |
ErrFeatureNotEnabled, ErrQuotaExceeded | 403 Forbidden |
ErrNoStore, ErrProviderAPIError | 500 Internal Server Error |
ErrProviderTimeout | 504 Gateway Timeout |
Error response format11
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 messagetype— Error category:validation,not_found,conflict,payment,provider,serverparam— The parameter that caused the error (optional)details— Additional context (optional)
Custom error handling12
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 logging13
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 logic14
Certain errors are retryable:
| Error | Retryable | Strategy |
ErrDatabaseConnection | Yes | Exponential backoff |
ErrProviderTimeout | Yes | Fixed delay (1s) |
ErrProviderAPIError | Sometimes | Check provider status code |
ErrPaymentFailed | No | User intervention required |
ErrInvalidState | No | Logic 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)
}