Manejo de errores y casos límite
Receta
Gestiona los errores de Stripe con elegancia categorizando los tipos de error, mostrando mensajes amigables, administrando flujos de autenticación 3D Secure e implementando lógica de reintento para fallos transitorios.
Define una utilidad de manejo de errores:
// lib/stripe-errors.ts
import type { StripeError } from "@stripe/stripe-js";
import type Stripe from "stripe";
export function getClientErrorMessage(error: StripeError): string {
switch (error.type) {
case "card_error":
return getCardErrorMessage(error.code);
case "validation_error":
return "Revisa los datos de pago e inténtalo de nuevo.";
case "invalid_request_error":
return "Algo salió mal. Inténtalo de nuevo.";
case "api_error":
return "Nuestro procesador de pagos no está disponible temporalmente. Inténtalo de nuevo en un momento.";
case "api_connection_error":
return "Error de red. Comprueba tu conexión e inténtalo de nuevo.";
case "authentication_error":
return "La autenticación falló. Inténtalo de nuevo.";
case "rate_limit_error":
return "Demasiadas solicitudes. Espera un momento e inténtalo de nuevo.";
default:
return "Ocurrió un error inesperado. Inténtalo de nuevo.";
}
}
function getCardErrorMessage(code: string | undefined): string {
switch (code) {
case "card_declined":
return "Tu tarjeta fue rechazada. Prueba con otra tarjeta.";
case "insufficient_funds":
return "Fondos insuficientes. Prueba con otra tarjeta.";
case "expired_card":
return "Tu tarjeta ha caducado. Usa otra tarjeta.";
case "incorrect_cvc":
return "CVC incorrecto. Compruébalo e inténtalo de nuevo.";
case "incorrect_number":
return "Número de tarjeta incorrecto. Compruébalo e inténtalo de nuevo.";
case "processing_error":
return "Ocurrió un error al procesar tu tarjeta. Inténtalo de nuevo.";
default:
return "Tu tarjeta fue rechazada. Prueba con otro método de pago.";
}
}
export function getServerErrorMessage(error: Stripe.errors.StripeError): string {
switch (error.type) {
case "StripeCardError":
return error.message ?? "Error de tarjeta.";
case "StripeInvalidRequestError":
return "Solicitud inválida. Contacta con soporte.";
case "StripeAPIError":
return "Servicio de pago temporalmente no disponible.";
case "StripeConnectionError":
return "No se pudo conectar con el servicio de pago.";
case "StripeAuthenticationError":
return "Error de configuración de pago. Contacta con soporte.";
case "StripeRateLimitError":
return "Demasiadas solicitudes. Inténtalo de nuevo más tarde.";
default:
return "Ocurrió un error inesperado.";
}
}Maneja la autenticación 3D Secure / SCA:
// lib/confirm-payment.ts
import type { Stripe, StripeElements } from "@stripe/stripe-js";
interface PaymentResult {
success: boolean;
error?: string;
requiresAction?: boolean;
}
export async function confirmPaymentWithSCA(
stripe: Stripe,
elements: StripeElements,
returnUrl: string
): Promise<PaymentResult> {
const { error: submitError } = await elements.submit();
if (submitError) {
return { success: false, error: submitError.message };
}
const { error, paymentIntent } = await stripe.confirmPayment({
elements,
confirmParams: { return_url: returnUrl },
redirect: "if_required",
});
if (error) {
return { success: false, error: error.message };
}
switch (paymentIntent?.status) {
case "succeeded":
return { success: true };
case "processing":
return {
success: false,
error: "Tu pago se está procesando. Te avisaremos cuando se complete.",
};
case "requires_action":
// Se activó 3D Secure pero no se completó
return {
success: false,
requiresAction: true,
error: "Se requiere autenticación adicional.",
};
default:
return { success: false, error: "Estado de pago inesperado." };
}
}Ejemplo funcional
// app/checkout/secure-checkout-form.tsx
"use client";
import { useState, type FormEvent } from "react";
import {
PaymentElement,
useStripe,
useElements,
} from "@stripe/react-stripe-js";
import { getClientErrorMessage } from "@/lib/stripe-errors";
import type { StripeError } from "@stripe/stripe-js";
type FormStatus = "idle" | "validating" | "processing" | "succeeded" | "failed";
export function SecureCheckoutForm() {
const stripe = useStripe();
const elements = useElements();
const [status, setStatus] = useState<FormStatus>("idle");
const [errorMessage, setErrorMessage] = useState<string | null>(null);
const [retryCount, setRetryCount] = useState(0);
function handleError(error: StripeError) {
const message = getClientErrorMessage(error);
setErrorMessage(message);
setStatus("failed");
// Permite reintento para errores transitorios
if (error.type === "api_error" || error.type === "api_connection_error") {
setRetryCount((prev) => prev + 1);
}
}
async function handleSubmit(e: FormEvent) {
e.preventDefault();
if (!stripe || !elements) return;
setStatus("validating");
setErrorMessage(null);
// Paso 1: Validar el formulario
const { error: submitError } = await elements.submit();
if (submitError) {
handleError(submitError);
return;
}
setStatus("processing");
// Paso 2: Confirmar el pago
const { error: confirmError, paymentIntent } =
await stripe.confirmPayment({
elements,
confirmParams: {
return_url: `${window.location.origin}/success`,
},
redirect: "if_required",
});
if (confirmError) {
handleError(confirmError);
return;
}
// Paso 3: Manejar el resultado
switch (paymentIntent?.status) {
case "succeeded":
setStatus("succeeded");
break;
case "processing":
setErrorMessage(
"Tu pago se está procesando. Te avisaremos cuando se complete."
);
setStatus("processing");
break;
case "requires_action":
setErrorMessage(
"Se requiere autenticación adicional. Completa la verificación."
);
setStatus("failed");
break;
default:
setErrorMessage("Algo salió mal. Inténtalo de nuevo.");
setStatus("failed");
}
}
if (status === "succeeded") {
return (
<div className="text-center p-8">
<div className="text-green-500 text-5xl mb-4">✓</div>
<h2 className="text-2xl font-bold">¡Pago exitoso!</h2>
<p className="text-gray-600 mt-2">
Gracias por tu compra.
</p>
</div>
);
}
return (
<form onSubmit={handleSubmit} className="max-w-md mx-auto p-6 space-y-6">
<PaymentElement />
{errorMessage && (
<div
className={`rounded-lg p-4 ${
status === "processing"
? "bg-yellow-50 border border-yellow-200"
: "bg-red-50 border border-red-200"
}`}
role="alert"
>
<p
className={`text-sm font-medium ${
status === "processing" ? "text-yellow-800" : "text-red-800"
}`}
>
{errorMessage}
</p>
</div>
)}
<button
type="submit"
disabled={
!stripe || status === "validating" || status === "processing"
}
className="w-full bg-blue-600 text-white py-3 rounded-lg font-medium
hover:bg-blue-700 disabled:opacity-50 transition-colors"
>
{status === "validating" && "Validando..."}
{status === "processing" && "Procesando pago..."}
{status === "idle" && "Pagar ahora"}
{status === "failed" && (retryCount > 0 ? `Reintentar (${retryCount})` : "Intentar de nuevo")}
</button>
{retryCount >= 3 && (
<p className="text-sm text-gray-500 text-center">
¿Tienes problemas?{" "}
<a href="/support" className="text-blue-600 underline">
Contacta con soporte
</a>
</p>
)}
</form>
);
}Manejo de errores en el servidor:
// app/actions/safe-checkout.ts
"use server";
import { stripe } from "@/lib/stripe";
import { getServerErrorMessage } from "@/lib/stripe-errors";
import Stripe from "stripe";
export async function safeCreateCheckout(priceId: string) {
try {
const session = await stripe.checkout.sessions.create({
mode: "payment",
line_items: [{ price: priceId, quantity: 1 }],
success_url: `${process.env.NEXT_PUBLIC_APP_URL}/success?session_id={CHECKOUT_SESSION_ID}`,
cancel_url: `${process.env.NEXT_PUBLIC_APP_URL}/pricing`,
});
return { url: session.url };
} catch (err) {
if (err instanceof Stripe.errors.StripeError) {
console.error(`Stripe error [${err.type}]: ${err.message}`);
return { error: getServerErrorMessage(err) };
}
console.error("Unexpected error:", err);
return { error: "Ocurrió un error inesperado." };
}
}Profundización
Cómo funciona
- Los errores de Stripe se dividen en categorías distintas. Los errores del lado del cliente (
StripeErrorde@stripe/stripe-js) y los del lado del servidor (Stripe.errors.StripeErrordestripe) tienen jerarquías de tipos diferentes pero categorías similares. card_errores el tipo más común. Significa que el banco emisor rechazó la tarjeta. El campocodeproporciona el motivo específico (fondos insuficientes, caducada, etc.).validation_errorocurre cuando la entrada del usuario no pasa la validación del lado del cliente de Stripe.js (formato de número de tarjeta inválido, campos faltantes).- 3D Secure (SCA) se activa automáticamente cuando el emisor de la tarjeta lo requiere. Stripe gestiona la redirección a la página de autenticación del banco y el regreso. El estado
requires_actionindica que el flujo está en curso. api_erroryapi_connection_errorson transitorios. Son seguros de reintentar porque las operaciones de Stripe son idempotentes cuando pasas unidempotencyKey.confirmPaymentconredirect: "if_required"solo redirige para métodos de pago que lo requieran (como 3D Secure). Para pagos simples con tarjeta, se resuelve en el mismo lugar.
Variaciones
Reintento con backoff exponencial (lado del servidor):
async function withRetry<T>(
fn: () => Promise<T>,
maxRetries = 3
): Promise<T> {
for (let attempt = 0; attempt <= maxRetries; attempt++) {
try {
return await fn();
} catch (err) {
if (
err instanceof Stripe.errors.StripeError &&
(err.type === "StripeConnectionError" ||
err.type === "StripeAPIError") &&
attempt < maxRetries
) {
const delay = Math.pow(2, attempt) * 1000;
await new Promise((r) => setTimeout(r, delay));
continue;
}
throw err;
}
}
throw new Error("Max retries exceeded");
}
// Uso
const session = await withRetry(() =>
stripe.checkout.sessions.create({ /* ... */ })
);Solicitudes idempotentes:
const paymentIntent = await stripe.paymentIntents.create(
{
amount: 2999,
currency: "usd",
automatic_payment_methods: { enabled: true },
},
{
idempotencyKey: `pi_${userId}_${orderId}`,
}
);Notas de TypeScript
StripeErrordel lado del cliente se importa desde@stripe/stripe-js.- Los errores del lado del servidor extienden
Stripe.errors.StripeErrordel paquetestripe. - La propiedad
error.codeen errores de tarjeta está tipada comostring | undefined. Usa una sentencia switch con literales de string para el estrechamiento de tipos.
import type { StripeError } from "@stripe/stripe-js";
function isRetryable(error: StripeError): boolean {
return (
error.type === "api_error" || error.type === "api_connection_error"
);
}Errores comunes
- Nunca muestres mensajes de error sin procesar de Stripe a los usuarios en producción. Pueden contener detalles técnicos. Siempre mapea a mensajes amigables.
confirmPaymentpuede redirigir al usuario para 3D Secure. Cuando regrese, comprueba el estado del PaymentIntent mediante el parámetro de consultapayment_intent.- El estado
requires_actionno significa que el pago falló. Significa que el usuario debe completar un paso adicional (como 3D Secure). No muestres un mensaje de fallo para este estado. api_connection_errorpuede deberse a la red del usuario, no a los servidores de Stripe. Reintentar puede no ayudar si el usuario está sin conexión.- En el lado del servidor, captura siempre
Stripe.errors.StripeErrorde forma específica. Otros errores (como timeouts de red) requieren un manejo diferente. - No reintentes
card_errorniinvalid_request_error. Son fallos permanentes que requieren acción del usuario (otra tarjeta o entrada corregida). - Los errores de límite de tasa (
rate_limit_error) deben manejarse con backoff. El límite de tasa de Stripe es de 100 solicitudes por segundo en modo live.
Referencia de tipos de error de Stripe
| Tipo de error | Causa | Reintentable | Acción del usuario |
|---|---|---|---|
| card_error | Tarjeta rechazada por el emisor | No | Usar otra tarjeta |
| validation_error | Entrada de formulario inválida | No | Corregir campos de entrada |
| invalid_request_error | Parámetros de API incorrectos | No | Corregir código o entrada |
| api_error | Problema en el servidor de Stripe | Sí | Reintentar tras una espera |
| api_connection_error | Fallo de red | Sí | Comprobar conexión, reintentar |
| authentication_error | Clave de API inválida | No | Corregir configuración |
| rate_limit_error | Demasiadas solicitudes | Sí | Reintentar con backoff |
Alternativas
| Enfoque | Ventajas | Desventajas |
|---|---|---|
| Mapeo de errores personalizado | Control total sobre los mensajes | Hay que mantener el mapa de errores |
| Mensajes predeterminados de Stripe | Menos código, siempre actualizados | Demasiado técnicos para usuarios finales |
| Error boundary + fallback | Captura errores inesperados de React | No captura errores de Stripe.js |
| Notificaciones toast | Visualización de errores no bloqueante | Fácil de pasar por alto en errores de pago |
FAQs
¿Cuáles son los dos tipos de error de Stripe diferentes y dónde aplica cada uno?
- Los errores del lado del cliente usan
StripeErrorde@stripe/stripe-js(usado en código del navegador). - Los errores del lado del servidor usan
Stripe.errors.StripeErrordel paquete Nodestripe. - Tienen jerarquías de tipos diferentes pero cubren categorías de error similares.
¿Qué tipos de error de Stripe son seguros de reintentar y cuáles no?
api_erroryapi_connection_errorson transitorios y seguros de reintentar.rate_limit_errores reintentable con backoff.card_error,validation_erroreinvalid_request_errorson fallos permanentes que requieren acción del usuario.
¿Cómo funciona la autenticación 3D Secure (SCA) con confirmPayment?
- 3D Secure se activa automáticamente cuando el emisor de la tarjeta lo requiere.
- Usar
redirect: "if_required"solo redirige para métodos de pago que lo necesiten. - Un estado
requires_actionsignifica que el usuario debe completar un paso adicional; no significa que el pago falló.
¿Por qué nunca debes mostrar mensajes de error sin procesar de Stripe a los usuarios?
- Los errores sin procesar de Stripe pueden contener detalles técnicos sobre tu integración.
- Siempre mapea los errores a mensajes amigables usando una utilidad como
getClientErrorMessage. - Esto mejora tanto la seguridad como la experiencia del usuario.
¿Cómo funciona la lógica de reintento con backoff exponencial?
async function withRetry<T>(
fn: () => Promise<T>,
maxRetries = 3
): Promise<T> {
for (let attempt = 0; attempt <= maxRetries; attempt++) {
try {
return await fn();
} catch (err) {
if (isRetryableStripeError(err) && attempt < maxRetries) {
await new Promise((r) => setTimeout(r, Math.pow(2, attempt) * 1000));
continue;
}
throw err;
}
}
throw new Error("Max retries exceeded");
}¿Cuál es el propósito de las claves de idempotencia al reintentar solicitudes de Stripe?
- Las claves de idempotencia garantizan que reintentar una solicitud no cree cargos o recursos duplicados.
- Pasa una clave única como
pi_${userId}_${orderId}en el objeto de opciones. - Stripe devuelve el mismo resultado para solicitudes repetidas con la misma clave de idempotencia.
Trampa: ¿Qué ocurre si muestras un mensaje de fallo cuando el estado del paymentIntent es "requires_action"?
- El usuario ve un error engañoso aunque el pago no haya fallado realmente.
requires_actionsignifica que el usuario debe completar la autenticación 3D Secure.- Trátalo como un estado en curso, no como un fallo.
Trampa: ¿Qué ocurre cuando confirmPayment redirige para 3D Secure y el usuario regresa?
- El usuario es redirigido de vuelta a tu
return_urlcon un parámetro de consultapayment_intent. - Debes comprobar el estado del PaymentIntent en la página de regreso para confirmar el éxito.
- No hacerlo puede dejar al usuario en una página que no refleja el resultado real del pago.
¿Cómo ayuda el tipo FormStatus a gestionar el estado del formulario de checkout?
type FormStatus = "idle" | "validating" | "processing" | "succeeded" | "failed";- Restringe el estado a cinco estados conocidos, evitando combinaciones de UI inválidas.
- El texto del botón y el estilo de error cambian según el estado actual.
retryCountsolo se incrementa para tipos de error transitorios.
¿En qué se diferencian los tipos de TypeScript entre errores de Stripe del cliente y del servidor?
// Lado del cliente
import type { StripeError } from "@stripe/stripe-js";
// Lado del servidor
import type Stripe from "stripe";
// Stripe.errors.StripeError- La propiedad
error.codeen errores de tarjeta esstring | undefined. - Usa un switch con literales de string para el estrechamiento de tipos.
¿Cuándo no beneficia reintentar un api_connection_error?
- Cuando el usuario está sin conexión, reintentar seguirá fallando.
api_connection_errorpuede deberse a la red del usuario, no solo a los servidores de Stripe.- Considera mostrar un mensaje de "comprueba tu conexión" junto con la opción de reintento.
¿Cómo maneja la función safeCreateCheckout del lado del servidor tanto errores de Stripe como no relacionados con Stripe?
- Usa
instanceof Stripe.errors.StripeErrorpara identificar errores específicos de Stripe. - Los errores de Stripe se registran con su tipo y se mapean a un mensaje amigable mediante
getServerErrorMessage. - Todos los demás errores caen en una respuesta genérica de "error inesperado".