Back to Notes
Architecture2 phút đọcDecember 3, 2025

Error Handling & Recovery — Khi payment fail, API timeout, network drop

Payment fail, API timeout, mất mạng giữa checkout — đây là những tình huống tệ nhất trong ecommerce. Xử lý sai có thể dẫn đến double charge hoặc mất đơn hàng. Bài này đi qua từng kịch bản và cách giải quyết đúng.

Error Handling & Recovery — Khi payment fail, API timeout, network drop

Checkout là flow mà lỗi có hậu quả tài chính. Double charge vì user click Submit 2 lần. Mất đơn hàng vì API timeout. User bối rối vì error message không rõ ràng. Những vấn đề này không phải edge case — chúng xảy ra hàng ngày trên production.

Vấn đề 1: Idempotency Key — Tránh Double Charge

User click "Thanh toán" 2 lần (hoặc button không disabled kịp). Server nhận 2 request. Nếu không có idempotency, payment bị charge 2 lần.

ts
// lib/checkout/idempotency.ts
export function generateIdempotencyKey(orderId: string): string {
  // Key duy nhất per order attempt — lưu ở client
  const key = `${orderId}-${Date.now()}`;
  sessionStorage.setItem(`idempotency_${orderId}`, key);
  return key;
}
 
export function getIdempotencyKey(orderId: string): string {
  // Reuse key cũ nếu đã có (retry same request)
  const existing = sessionStorage.getItem(`idempotency_${orderId}`);
  if (existing) return existing;
  return generateIdempotencyKey(orderId);
}
tsx
// Component
function PaymentButton({ orderId }: { orderId: string }) {
  const [isSubmitting, setIsSubmitting] = useState(false);
 
  const handleSubmit = async () => {
    if (isSubmitting) return; // Guard đầu tiên
    setIsSubmitting(true);
 
    try {
      const idempotencyKey = getIdempotencyKey(orderId);
 
      await fetch("/api/payment", {
        method: "POST",
        headers: {
          "Idempotency-Key": idempotencyKey, // Server dùng key này để dedup
        },
        body: JSON.stringify({ orderId }),
      });
 
      // Sau khi thành công, xóa key cũ để tránh dùng lại
      sessionStorage.removeItem(`idempotency_${orderId}`);
    } finally {
      setIsSubmitting(false);
    }
  };
 
  return (
    <button onClick={handleSubmit} disabled={isSubmitting}>
      {isSubmitting ? "Đang xử lý..." : "Thanh toán"}
    </button>
  );
}

Vấn đề 2: Retry với Exponential Backoff

ts
// lib/utils/retry.ts
interface RetryOptions {
  maxAttempts: number;
  baseDelay: number; // ms
  maxDelay: number;  // ms
  retryableStatusCodes: number[];
  onRetry?: (attempt: number, delay: number) => void;
}
 
export async function withRetry<T>(
  fn: () => Promise<T>,
  options: RetryOptions
): Promise<T> {
  const { maxAttempts, baseDelay, maxDelay, retryableStatusCodes, onRetry } = options;
 
  for (let attempt = 1; attempt <= maxAttempts; attempt++) {
    try {
      return await fn();
    } catch (error) {
      const isLastAttempt = attempt === maxAttempts;
      const statusCode = error instanceof ApiError ? error.statusCode : 0;
      const isRetryable = retryableStatusCodes.includes(statusCode);
 
      if (isLastAttempt || !isRetryable) throw error;
 
      // Exponential backoff với jitter để tránh thundering herd
      const exponentialDelay = baseDelay * Math.pow(2, attempt - 1);
      const jitter = Math.random() * 1000;
      const delay = Math.min(exponentialDelay + jitter, maxDelay);
 
      onRetry?.(attempt, delay);
      await new Promise((resolve) => setTimeout(resolve, delay));
    }
  }
 
  throw new Error("Unreachable");
}
 
// Usage với UI feedback
async function fetchWithRetryUI<T>(fn: () => Promise<T>) {
  const [retryInfo, setRetryInfo] = useState<{ attempt: number; delay: number } | null>(null);
 
  const result = await withRetry(fn, {
    maxAttempts: 3,
    baseDelay: 1000,
    maxDelay: 10_000,
    retryableStatusCodes: [408, 429, 500, 502, 503, 504],
    onRetry: (attempt, delay) => {
      setRetryInfo({ attempt, delay });
      // "Đang thử lại lần 2/3 (sau 2s)..."
    },
  });
 
  setRetryInfo(null);
  return result;
}

Vấn đề 3: Partial Failure — Payment OK nhưng Order Fail

Đây là kịch bản nguy hiểm nhất: user bị charge nhưng không có order.

ts
// Server-side: sử dụng database transaction + compensating transaction
export async function processCheckout(paymentIntentId: string, cartData: CartData) {
  // Bước 1: Confirm payment với Stripe
  const payment = await stripe.paymentIntents.confirm(paymentIntentId);
 
  if (payment.status !== "succeeded") {
    throw new PaymentFailedError(payment.status);
  }
 
  try {
    // Bước 2: Tạo order trong DB transaction
    const order = await db.transaction(async (tx) => {
      const newOrder = await tx.insert(orders).values({
        paymentIntentId,
        total: cartData.total,
        status: "confirmed",
      }).returning();
 
      await tx.insert(orderItems).values(
        cartData.items.map((item) => ({
          orderId: newOrder[0].id,
          productId: item.productId,
          quantity: item.quantity,
          price: item.price,
        }))
      );
 
      return newOrder[0];
    });
 
    return order;
  } catch (dbError) {
    // Payment succeeded nhưng DB failed
    // Đưa vào reconciliation queue để xử lý sau
    await reconciliationQueue.add({
      type: "PAYMENT_WITHOUT_ORDER",
      paymentIntentId,
      cartData,
      error: String(dbError),
      timestamp: new Date(),
    });
 
    // Trả về error đặc biệt để FE biết cách thông báo
    throw new OrderCreationFailedError(paymentIntentId);
  }
}
tsx
// FE xử lý partial failure
catch (error) {
  if (error instanceof OrderCreationFailedError) {
    // Đừng hiện generic error — user đã bị charge!
    toast.error(
      "Thanh toán thành công nhưng có sự cố khi tạo đơn hàng. " +
      "Chúng tôi sẽ liên hệ bạn trong vòng 30 phút. " +
      "Mã giao dịch: " + error.paymentIntentId,
      { duration: Infinity } // Không tự close
    );
  }
}

Vấn đề 4: Network Drop Mid-checkout

tsx
// hooks/useNetworkStatus.ts
export function useNetworkStatus() {
  const [isOnline, setIsOnline] = useState(
    typeof navigator !== "undefined" ? navigator.onLine : true
  );
 
  useEffect(() => {
    const handleOnline = () => setIsOnline(true);
    const handleOffline = () => setIsOnline(false);
 
    window.addEventListener("online", handleOnline);
    window.addEventListener("offline", handleOffline);
 
    return () => {
      window.removeEventListener("online", handleOnline);
      window.removeEventListener("offline", handleOffline);
    };
  }, []);
 
  return isOnline;
}
 
// Banner thông báo offline
function OfflineBanner() {
  const isOnline = useNetworkStatus();
 
  if (isOnline) return null;
 
  return (
    <div className="fixed top-0 left-0 right-0 bg-yellow-500 text-center py-2 z-50">
      ⚠ Mất kết nối mạng. Vui lòng kiểm tra kết nối trước khi thanh toán.
    </div>
  );
}
 
// Disable checkout button khi offline
<button disabled={!isOnline || isSubmitting}>
  {!isOnline ? "Mất kết nối..." : "Thanh toán"}
</button>

Vấn đề 5: Error Message Design

tsx
// Phân loại lỗi để hiển thị đúng message
type CheckoutErrorType =
  | "CARD_DECLINED"
  | "INSUFFICIENT_FUNDS"
  | "NETWORK_ERROR"
  | "SESSION_EXPIRED"
  | "STOCK_UNAVAILABLE"
  | "UNKNOWN";
 
const ERROR_MESSAGES: Record<CheckoutErrorType, {
  title: string;
  description: string;
  action?: string;
}> = {
  CARD_DECLINED: {
    title: "Thẻ bị từ chối",
    description: "Ngân hàng từ chối giao dịch. Vui lòng kiểm tra thông tin thẻ hoặc liên hệ ngân hàng.",
    action: "Thử thẻ khác",
  },
  INSUFFICIENT_FUNDS: {
    title: "Số dư không đủ",
    description: "Tài khoản không đủ số dư để thực hiện giao dịch này.",
    action: "Chọn phương thức thanh toán khác",
  },
  NETWORK_ERROR: {
    title: "Lỗi kết nối",
    description: "Không thể kết nối đến máy chủ. Đơn hàng của bạn chưa được xử lý.",
    action: "Thử lại",
  },
  STOCK_UNAVAILABLE: {
    title: "Sản phẩm đã hết hàng",
    description: "Một số sản phẩm trong giỏ hàng vừa hết hàng.",
    action: "Xem giỏ hàng",
  },
  SESSION_EXPIRED: {
    title: "Phiên đăng nhập hết hạn",
    description: "Vui lòng đăng nhập lại. Thông tin đơn hàng của bạn đã được lưu.",
    action: "Đăng nhập lại",
  },
  UNKNOWN: {
    title: "Có lỗi xảy ra",
    description: "Vui lòng thử lại hoặc liên hệ hỗ trợ nếu lỗi tiếp tục xảy ra.",
    action: "Thử lại",
  },
};
 
function CheckoutErrorDisplay({ errorType }: { errorType: CheckoutErrorType }) {
  const msg = ERROR_MESSAGES[errorType] ?? ERROR_MESSAGES.UNKNOWN;
 
  return (
    <div className="bg-red-50 border border-red-200 rounded-lg p-4">
      <h3 className="font-semibold text-red-800">{msg.title}</h3>
      <p className="text-red-600 text-sm mt-1">{msg.description}</p>
      {msg.action && (
        <button className="mt-3 text-sm text-red-700 underline">{msg.action}</button>
      )}
    </div>
  );
}

Key Takeaways

  • Idempotency key: tạo per checkout attempt, reuse khi retry, xóa sau thành công
  • Retry: exponential backoff với jitter, chỉ retry trên 5xx và 408/429
  • Partial failure: khi payment OK nhưng order fail — thông báo rõ ràng, đưa vào reconciliation queue
  • Network drop: dùng navigator.onLine + event listeners, disable submit khi offline
  • Error messages: phân loại lỗi cụ thể, cho user biết state đơn hàng và bước tiếp theo

If you found this helpful, leave a like!

Related Posts

Comments available after DB is connected.