FDE PulseViệc làm FDE đang mở 316Mới đăng 7 ngày qua 10Chủ đề nổi bật: Đào tạo kỹ năng FDE tại Đông Nam Á

Tờ báo của nghề Forward Deployed Engineer

Bách khoa

API và webhook cho FDE: viết tích hợp như thể mọi thứ sẽ được gửi hai lần

Lỗi đắt nhất của một tích hợp doanh nghiệp thường không làm sập hệ thống: nó lặng lẽ trừ tiền khách hai lần, hoặc bỏ sót một sự kiện mà không ai hay.

Ảnh kỹ sư ngồi trước laptop đang hiển thị mã nguồn, hoặc dãy cáp mạng trong phòng máy chủ, gợi không khí làm việc với hệ thống tích hợp và thanh toán.
Ảnh: Negative Space / CC0
Infographic gồm hai phần. Phần gửi đi: một đơn hàng có idempotency key sinh một lần. Lần 1 và lần 2 bị timeout, lần 3 có kết quả, cả ba lần đều gửi cùng một key. Server nhớ kết quả theo key nên chỉ trừ tiền một lần. Gặp lỗi 500 thì đánh dấu đơn là “chưa xác định” và chờ webhook. Phần nhận về: webhook đi qua bốn bước: lấy body thô, xác minh chữ ký HMAC-SHA256 với độ lệch thời gian tối đa 5 phút, khử trùng lặp theo event ID, rồi trả 2xx ngay và đẩy vào hàng đợi. Sau đó worker đọc trạng thái hiện tại để xử lý. Ghi chú: Stripe tự gửi lại webhook trong tối đa ba ngày, còn GitHub không tự gửi lại.
Khi gửi đi, mọi lần retry phải dùng cùng một key và cùng tham số. Khi nhận về, webhook phải được khử trùng lặp trước khi xử lý.

Tóm tắt nhanh

  • Sau lỗi mạng hoặc 500, bạn không biết server đã làm gì. Hãy retry với cùng idempotency key và cùng tham số, rồi đối soát qua webhook.
  • Webhook có thể đến nhiều lần và sai thứ tự. Khử trùng lặp theo event ID, đừng dựa vào thứ tự.
  • Trả 2xx thật nhanh, đẩy việc nặng vào hàng đợi, và biết nhà cung cấp nào không tự gửi lại webhook.
Chia sẻLinkedInFacebookX

Chín giờ sáng thứ Hai, kế toán của khách hàng gửi cho bạn một ảnh chụp màn hình: cùng một đơn hàng bị trừ tiền hai lần. Log cho thấy cuối tuần mạng chập chờn một lúc, request tạo thanh toán bị timeout, và job của bạn đã ngoan ngoãn retry đúng như được dạy.

Code không sai cú pháp và cũng không crash. Cái sai nằm ở giả định rằng một request chỉ đến đúng một lần, theo đúng thứ tự, và luôn có câu trả lời rõ ràng. Với một Forward Deployed Engineer, giả định ấy hỏng gần như mỗi ngày.

Palantir mô tả vai trò FDE là đưa kỹ sư vào làm việc ngay cạnh khách hàng để giải các bài toán cấp bách nhất của họ. Làm việc bên trong hệ thống của khách cũng có nghĩa là sớm muộn bạn sẽ phải chạm vào những đường ống nối hệ thống ấy với các hệ thống khác. Nếu viết đường ống ấy cho chắc, bạn được tin tưởng.

Nếu viết ẩu, bạn sẽ mất cả tuần để giải thích vì sao số liệu lệch.

Khi nhận về lỗi, bạn không biết điều gì đã xảy ra

Hãy bắt đầu bằng một sự thật khó chịu. Tài liệu xử lý lỗi của Stripe nói thẳng: khi gặp lỗi mạng, client không biết server đã nhận request hay chưa. Một response 500 cho thao tác ghi cũng phải được coi là kết quả chưa xác định, tức thao tác có thể đã chạy, cũng có thể chưa.

Vì thế retry mù là nguy hiểm, còn không retry thì có thể bỏ sót đơn hàng. Lối ra là idempotency key: một chuỗi do client tự sinh, dài tối đa 255 ký tự, và Stripe gợi ý dùng UUID V4. Bạn gắn chuỗi này vào request để server nhận ra những lần gửi lại.

Cơ chế của Stripe khá đơn giản. Họ lưu status code và body của request đầu tiên ứng với mỗi key, kể cả khi request đó thất bại, rồi trả lại đúng kết quả ấy cho mọi lần retry. Nếu bạn dùng lại một key nhưng đổi tham số, lớp idempotency sẽ báo lỗi để chặn việc dùng nhầm.

Có ba chi tiết người mới hay bỏ qua. Key có thể bị xoá sau 24 giờ, nên đừng coi nó là bản ghi vĩnh viễn. Mọi POST đều nhận key, còn gửi key cho GET và DELETE thì không có tác dụng gì vì hai phương thức này vốn đã idempotent.

Và lời khuyên của Stripe sau lỗi mạng là retry với cùng key, cùng tham số, cho tới khi nhận được kết quả từ server.

Một job tạo thanh toán viết lại cho đúng

Quay lại sự cố sáng thứ Hai. Thay đổi quan trọng nhất là key phải được sinh một lần khi tạo đơn và lưu cùng đơn, không sinh lại trong mỗi lần retry. Nếu sinh lại, mỗi lần retry trông như một giao dịch mới và idempotency mất tác dụng.

def create_payment(order):
    key = order.idempotency_key          # UUID v4, sinh khi tạo đơn, lưu trong DB
    payload = {
        "amount": order.amount,
        "currency": order.currency,
        "metadata[order_id]": order.id,  # để đối soát về sau
    }
    return send_with_retry(payload, key, order)

Vòng retry tách riêng, và mọi lần gửi đều dùng đúng key ấy:

def send_with_retry(payload, key, order):
    for attempt in range(6):
        try:
            r = requests.post(PAYMENTS_URL, data=payload,
                              headers={"Idempotency-Key": key}, timeout=10)
        except (requests.ConnectionError, requests.Timeout):
            time.sleep(2 ** attempt)     # retry cùng key
            continue
        if r.status_code == 429:
            time.sleep(retry_after(r, attempt))
            continue
        if r.status_code >= 500:
            mark_indeterminate(order)    # chờ webhook/đối soát
            return None
        return r
    mark_indeterminate(order)
    return None

Nhánh 429 đáng để ý. Giới hạn tốc độ là chuyện thường ngày khi tích hợp, và theo MDN, response 429 có thể kèm header Retry-After cho biết cần chờ bao lâu trước khi gửi request mới. Server đã cho con số thì cứ chờ đúng như vậy, đừng tự đoán:

def retry_after(r, attempt):
    wait = r.headers.get("Retry-After")
    return int(wait) if wait and wait.isdigit() else 2 ** attempt

Nhánh 500 thì không retry mù. Đơn được đánh dấu “chưa xác định”, và sự thật sẽ đến từ phía bên kia: webhook, cộng với metadata order_id để khớp giao dịch với đơn. Đó cũng là cách Stripe khuyên xử lý: đối soát qua webhook và metadata.

Webhook: đến muộn, đến hai lần, đến sai thứ tự

Chiều ngược lại khó hơn, vì bạn không kiểm soát thời điểm người khác gọi mình. Stripe nói rõ một endpoint có thể thỉnh thoảng nhận cùng một event nhiều lần, và event có thể đến không theo thứ tự. Crossmint ghi trong tài liệu rằng webhook của họ đảm bảo giao “ít nhất một lần”, nghĩa là phía nhận buộc phải idempotent.

Một handler tốt làm bốn việc theo đúng trình tự sau.

@app.post("/webhooks/payments")
def receive():
    raw = request.get_data()                       # body thô, chưa parse
    sig = request.headers.get("Stripe-Signature")
    try:
        event = stripe.Webhook.construct_event(raw, sig, WEBHOOK_SECRET)
    except Exception:
        return "", 400                             # chữ ký sai hoặc quá cũ
    if not processed_events.insert_if_absent(event["id"]):
        return "", 200                             # đã thấy rồi: bỏ qua
    queue.enqueue(handle_event, event["id"])
    return "", 200                                 # trả 2xx ngay

Bước đầu tiên là lấy body thô. Stripe ký mọi webhook bằng HMAC-SHA256 qua header Stripe-Signature, và việc xác minh cần đúng body gốc. Nếu framework đã parse rồi serialize lại, chỉ một khoảng trắng thay đổi cũng đủ làm chữ ký không khớp, và bạn sẽ mất nửa ngày nghi ngờ secret.

Chữ ký còn bao gồm một timestamp để chống tấn công replay, và thư viện của Stripe mặc định chấp nhận lệch tối đa 5 phút giữa timestamp đó và giờ hiện tại. Hệ quả thực tế là nếu đồng hồ server của khách lệch nhiều, mọi webhook sẽ bị từ chối dù secret hoàn toàn đúng.

Tiếp theo là khử trùng lặp theo event ID, rồi trả 2xx trước khi làm việc nặng. Stripe khuyên xử lý event qua hàng đợi bất đồng bộ để chịu được những đợt tăng đột biến. Trong worker, đừng tin rằng event “đã thanh toán” luôn đến sau event “đã tạo”. An toàn hơn là đọc trạng thái hiện tại của đối tượng rồi mới cập nhật đơn.

Mỗi nhà cung cấp hứa một kiểu, và bạn phải đọc kỹ lời hứa

Đây là chỗ FDE kiếm được lòng tin: đọc tài liệu của từng bên đủ kỹ để biết khi hệ thống của mình sập thì ai chịu trách nhiệm gửi lại.

Stripe GitHub
Thời gian phải phản hồi Trả 2xx trước khi xử lý logic nặng Phải trả 2XX trong 10 giây
Khi giao thất bại Live mode tự gửi lại đến ba ngày, với exponential backoff Không tự động gửi lại
Việc của bạn sau sự cố Chịu được event cũ dồn về cùng lúc Tự redeliver các webhook bị lỡ khi server hoạt động lại

Khác biệt này quyết định kiến trúc. Với Stripe, một sự cố hai tiếng thường tự lành, miễn handler chịu được lượng event dồn về. Với GitHub, nếu không có script hay quy trình redeliver, dữ liệu bị lỡ sẽ mất hẳn, và runbook bàn giao cho khách phải ghi rõ bước đó.

Những lỗi gặp đi gặp lại

Lỗi phổ biến nhất là sinh idempotency key mới trong vòng retry, khiến cơ chế bảo vệ vô dụng. Gần đó là dùng lại key cũ nhưng đổi tham số, chẳng hạn sửa số tiền, rồi ngạc nhiên vì bị báo lỗi. Muốn đổi tham số thì đó là một thao tác mới và cần key mới.

Lỗi thứ hai là coi 500 như “thất bại chắc chắn” rồi tạo lại giao dịch bằng key khác. Lỗi thứ ba là làm toàn bộ logic nghiệp vụ ngay trong handler webhook: ghi DB, gọi ERP, gửi email. Một lần ERP chậm là quá timeout, nhà cung cấp coi như giao thất bại, và vòng gửi lại bắt đầu.

Lỗi cuối cùng ít người nói tới: không có đường đối soát. Dù code tốt đến đâu, bạn vẫn cần một job định kỳ so trạng thái hai bên qua metadata, vì thế nào cũng có ngày một event bị lỡ.

Đưa kỹ năng này vào CV

Khi đọc JD của các vị trí FDE hay solutions engineer, hãy để ý những cụm như “integrations”, “webhooks”, “customer systems”, “data pipelines”. Gặp những cụm đó, bạn nên chuẩn bị sẵn câu chuyện về idempotency và webhook. Hãy cho thấy bạn từng xử lý lỗi thật, không chỉ từng gọi API.

Vì vậy, thay vì viết “Tích hợp Stripe”, hãy viết cụ thể: thêm idempotency key và đối soát qua webhook để loại bỏ giao dịch trùng; chuyển webhook handler sang xử lý qua hàng đợi với khử trùng lặp theo event ID. Nếu được hỏi trong phỏng vấn, kể lại sự cố, nguyên nhân và cách bạn chứng minh nó không tái diễn.

Bài tập tuần này: lấy một tích hợp bạn đang chạy, rút dây mạng giữa chừng một POST, rồi gửi lại cùng một webhook ba lần. Nếu dữ liệu vẫn đúng sau cả hai thử nghiệm, bạn đã làm được phần việc mà khách hàng sẽ không bao giờ thấy, và đó chính là phần quyết định họ có tin bạn hay không.

7 nguồn
Đọc tiếp trên lộ trình · Chặng 2: Kỹ thuật rộngNhà tuyển dụng FDE tìm gì: code là ngưỡng, giao tiếp với khách hàng là thước đoCode là điều kiện bắt buộc để làm FDE, nhưng khi ứng viên đã qua ngưỡng đó, khả năng giao tiếp và thuyết phục khách hàng mới là thứ phân định ai được chọn.