Every function in LatticeStripe returns either {:ok, result} or {:error, %LatticeStripe.Error{}}.
There are no raw strings or bare atoms in the error path — every failure is a fully structured
%LatticeStripe.Error{} struct that you can pattern match on with confidence.
For a complete reference of Stripe error codes and types, see Stripe's error documentation.
The Error Struct
LatticeStripe.Error is an Elixir exception struct (it also implements defexception, so you can
raise it or use it with rescue/1). All fields are available for pattern matching:
%LatticeStripe.Error{
# Always present — use this to drive your case statement
type: :card_error,
# Stripe error code string, e.g. "card_declined", "missing_param", "resource_missing"
code: "card_declined",
# Human-readable message from Stripe (safe to log, not always safe to show users)
message: "Your card was declined.",
# HTTP status code — nil only for :connection_error (no HTTP response received)
status: 402,
# Stripe's Request-Id header value — include this when contacting Stripe support
request_id: "req_abc123xyz",
# Parameter name that caused the error (for :invalid_request_error)
param: nil,
# Card decline reason (for :card_error only)
decline_code: "insufficient_funds",
# Stripe charge ID associated with a card error
charge: "ch_abc123",
# URL to Stripe documentation for this specific error
doc_url: "https://docs.stripe.com/error-codes/card-declined",
# Full decoded error body — escape hatch for fields not yet in the struct
raw_body: %{"error" => %{...}}
}Error Types
The :type field is always one of these atoms:
| Type | When | User-facing? |
|---|---|---|
:card_error | Card was declined or has an issue (expired, wrong CVC, insufficient funds) | Yes — show a friendly message |
:invalid_request_error | Invalid or missing parameters in the request | No — this is a developer error |
:authentication_error | API key is invalid, revoked, or missing | No — ops/infrastructure issue |
:rate_limit_error | Too many requests in too short a time | No — back off and retry |
:api_error | Stripe server error or unexpected response | No — already retried automatically |
:idempotency_error | Idempotency key reused with different parameters | No — developer/race condition issue |
:connection_error | Network failure — no HTTP response received | No — retry with backoff |
Pattern Matching on Error Types
Use a case statement to handle each error type appropriately. The pattern is: handle user-facing
errors gracefully, log infrastructure errors, and crash on developer errors (so they get fixed during
development).
case LatticeStripe.PaymentIntent.create(client, params) do
{:ok, intent} ->
# Success — proceed with the payment intent
{:ok, intent}
{:error, %LatticeStripe.Error{type: :card_error, decline_code: decline_code, code: code}} ->
# Card was declined — show a user-friendly message based on decline_code
# Common decline codes: "insufficient_funds", "card_declined", "expired_card",
# "incorrect_cvc", "do_not_honor", "lost_card", "stolen_card"
message = friendly_decline_message(decline_code || code)
{:error, {:card_declined, message}}
{:error, %LatticeStripe.Error{type: :authentication_error}} ->
# API key is wrong, revoked, or missing — this is an ops issue, not a user issue
Logger.error("Stripe authentication failed — check your API key configuration")
{:error, :service_unavailable}
{:error, %LatticeStripe.Error{type: :rate_limit_error, request_id: req_id}} ->
# Too many requests — LatticeStripe already retried with backoff; now fully exhausted
Logger.warning("Stripe rate limit exhausted", request_id: req_id)
{:error, :rate_limited}
{:error, %LatticeStripe.Error{type: :invalid_request_error, param: param, message: message}} ->
# Bad request parameters — fix the code that's sending this request
Logger.error("Invalid Stripe request", param: param, message: message)
{:error, {:invalid_params, param}}
{:error, %LatticeStripe.Error{type: :idempotency_error, request_id: req_id}} ->
# Idempotency key reused with different parameters — race condition or bug
Logger.error("Idempotency key conflict", request_id: req_id)
{:error, :idempotency_conflict}
{:error, %LatticeStripe.Error{type: :api_error, message: message, request_id: req_id} = err} ->
# Stripe server error — LatticeStripe already retried automatically
# Log the request_id so you can share it with Stripe support
Logger.error("Stripe API error: #{message}", request_id: req_id, status: err.status)
{:error, :service_unavailable}
{:error, %LatticeStripe.Error{type: :connection_error}} ->
# Network failure — LatticeStripe already retried; DNS/TLS/timeout at OS level
Logger.warning("Could not reach Stripe — network error")
{:error, :service_unavailable}
endMatching Decline Codes
For :card_error, the :decline_code field gives you more specific information about why the card
was declined. Use it to show appropriate messages or take action:
def friendly_decline_message("insufficient_funds"),
do: "Your card has insufficient funds. Please use a different card."
def friendly_decline_message("card_declined"),
do: "Your card was declined. Please try a different card or contact your bank."
def friendly_decline_message("expired_card"),
do: "Your card has expired. Please update your payment method."
def friendly_decline_message("incorrect_cvc"),
do: "Your card's security code is incorrect. Please check and try again."
def friendly_decline_message("lost_card"),
do: "Your card was declined. Please use a different payment method."
def friendly_decline_message("stolen_card"),
do: "Your card was declined. Please use a different payment method."
def friendly_decline_message(_other),
do: "Your card was declined. Please try a different card or contact your bank."Bang Variants
All resource functions have a ! variant that raises LatticeStripe.Error instead of returning
{:error, error}. Use bang functions in scripts, data migrations, or contexts where you want an
immediate crash on failure:
# In a script — crash if anything goes wrong
customer = LatticeStripe.Customer.create!(client, %{
"email" => "user@example.com",
"name" => "Alice"
})
# In a test — let ExUnit show the error
intent = LatticeStripe.PaymentIntent.create!(client, %{
"amount" => 2000,
"currency" => "usd"
})In production application code, prefer the non-bang variants and handle errors explicitly.
Automatic Retries
LatticeStripe automatically retries failed requests following Stripe's official SDK conventions. You don't need to implement your own retry loop for transient failures.
Default retry behavior:
- 2 retries by default (3 total attempts)
- Exponential backoff with jitter:
min(500ms * 2^(attempt-1), 5000ms), jittered to 50-100% of that value - Stripe-Should-Retry header respected: When Stripe explicitly tells the SDK to retry (or not), that instruction takes precedence over all other logic
- Retry-After header respected: On 429 responses, the
Retry-Afterheader value is used (capped at 5 seconds) - Idempotency keys preserved across retries: The same key is reused on all attempts so retrying a POST is safe
What gets retried automatically:
- Connection errors (network failure, DNS, timeout) —
:connection_error - Rate limit errors (429) —
:rate_limit_error - Stripe server errors (500, 502, 503, 504) —
:api_error
What is never retried:
- Card errors (402) —
:card_error— the card was declined, retrying won't help - Invalid request errors (400) —
:invalid_request_error— fix the request - Authentication errors (401) —
:authentication_error— fix the API key - Idempotency conflicts (409) —
:idempotency_error— retrying would cause the same conflict again
Configuring Retries
Override the number of retries when building a client:
# High-reliability client: 4 retries (5 total attempts)
client = LatticeStripe.Client.new!(
api_key: System.fetch_env!("STRIPE_API_KEY"),
finch: MyApp.Finch,
max_retries: 4
)
# No retries — useful if your caller has its own retry/circuit-breaker logic
client = LatticeStripe.Client.new!(
api_key: System.fetch_env!("STRIPE_API_KEY"),
finch: MyApp.Finch,
max_retries: 0
)Override per-request:
# Retry up to 5 times just for this critical payment
{:ok, intent} = LatticeStripe.PaymentIntent.create(client, params, max_retries: 5)For custom retry behavior (circuit breakers, custom backoff), see Extending LatticeStripe.
Using request_id for Support
Every successful or failed Stripe API response includes a request_id — Stripe's internal
identifier for the exact server-side execution of your request. Always log it when something
goes wrong.
case LatticeStripe.PaymentIntent.create(client, params) do
{:ok, %LatticeStripe.PaymentIntent{} = intent} ->
# The response includes request_id too (via the raw response headers)
Logger.info("Payment intent created", payment_intent_id: intent.id)
{:ok, intent}
{:error, %LatticeStripe.Error{} = err} ->
# ALWAYS log the request_id — it's what Stripe support needs to investigate
Logger.error("Payment intent creation failed",
type: err.type,
code: err.code,
message: err.message,
request_id: err.request_id,
status: err.status
)
{:error, err}
endWhen filing a Stripe support ticket, include the request_id value (e.g., req_abc123xyz).
Stripe support can look up the exact server-side context using this ID.
Exception Format
LatticeStripe.Error implements Exception, so Exception.message/1 (and to_string/1) produce
a readable summary:
err = %LatticeStripe.Error{
type: :card_error,
status: 402,
code: "card_declined",
message: "Your card was declined.",
request_id: "req_abc123"
}
Exception.message(err)
# => "(card_error) 402 card_declined Your card was declined. (request: req_abc123)"
# Can also be raised:
raise err
# ** (LatticeStripe.Error) (card_error) 402 card_declined Your card was declined. (request: req_abc123)Common Pitfalls
Don't catch all errors in one clause
This silently swallows important signals:
# Bad: loses error type and context
{:error, _err} -> {:error, :stripe_failed}
# Good: handle each type appropriately
{:error, %LatticeStripe.Error{type: :card_error}} -> {:error, :card_declined}
{:error, %LatticeStripe.Error{type: :api_error}} -> {:error, :service_unavailable}Distinguish user-facing errors from infrastructure errors
:card_error should result in a user-visible message. :api_error, :authentication_error, and
:connection_error are infrastructure problems — log them and show a generic "something went wrong"
message to users.
Always log request_id
Even when an error is expected (like :card_error), log the request_id. It's invaluable for
debugging edge cases and filing Stripe support tickets.
Idempotency conflicts signal a bug or race condition
An :idempotency_error means you sent two requests with the same idempotency key but different
parameters. This is almost always a developer error or a race condition in your code — investigate
before retrying.
Connection errors may mean Stripe was reached
A :connection_error means the TCP connection failed or timed out. The request may or may not have
reached Stripe. If you sent a POST without an idempotency key, you can't safely retry — you might
create duplicate records. LatticeStripe auto-generates idempotency keys for POST requests to make
safe retries possible.