API & tự động hóa

Chờ một email trong code: polling thay cho webhook

Một webhook báo cho bạn biết khi thư đến. Không có nó, bạn phải tự hỏi — và chính vòng lặp hỏi ấy là nơi các bài kiểm thử end-to-end trở nên chập chờn, các agent bị treo, và các script vướng vào giới hạn tốc độ. Đây là những gì vòng lặp đó cần làm đúng: một thời hạn thay cho một con số lần thử, một khoảng chờ biết nới rộng dần, một quy tắc để quyết định đâu là thư của bạn, và nơi duy nhất mà việc chờ đợi có thể được làm sẵn thay cho bạn.

  • Trung cấp
  • 29 phút đọc
Một phong bì xanh dương lơ lửng bên cạnh một đồng hồ bấm giờ màu xám có mũi tên tròn xanh dương xoay vòng quanh nó

Đẩy và kéo, và mỗi bên tốn của bạn những gì

Chỉ có hai cách để code của bạn biết một thư đã đến. Hoặc phía kia báo cho bạn, hoặc bạn tự hỏi. Mọi thứ khác — một thư viện client có waitFor bên trong, một SDK “stream” cả hộp thư, một test helper chặn luồng lại — đều chỉ là một trong hai cách đó nhưng giấu đi phần máy móc bên dưới, và biết mình đang cầm cái nào là điều đáng biết trước khi phải gỡ lỗi nó.

Bốn cách sắp xếp này gần như phủ hết mọi lựa chọn đang có.

Webhook
Dịch vụ gửi một yêu cầu HTTP đến một địa chỉ do bạn sở hữu, mỗi khi có thư đến. Đây là cách chờ rẻ nhất có thể — bạn không làm gì cả cho đến khi có việc để làm — và cái giá phải trả là một địa chỉ trên internet công khai, một listener luôn sẵn sàng đúng vào lúc thư đến, một khóa bí mật dùng chung để chứng minh yêu cầu đến từ họ, và câu trả lời của riêng bạn cho việc điều gì xảy ra khi listener của bạn không có ở đó.
Long polling
Bạn gửi yêu cầu và máy chủ giữ nó mở cho đến khi thư đến hoặc hết thời gian chờ. Nó không cần gì từ bạn ngoài một kết nối đi ra, và nó tốn của máy chủ một worker cho mỗi người đang chờ — đó là lý do mọi dịch vụ cung cấp kiểu này đều giới hạn cả độ dài của lượt chờ và số người chờ cùng lúc.
Polling thông thường
Bạn hỏi, lặp đi lặp lại, và mỗi yêu cầu được trả lời ngay với bất cứ thứ gì đang có. Đây là cách sắp xếp duy nhất hoạt động được từ một laptop nằm sau router, từ một CI runner không có đường vào, và từ một agent chạy trong sandbox của người khác — và đây chính là toàn bộ chủ đề của hướng dẫn này.
Một giao thức hộp thư
IMAP có IDLE, về bản chất là một long polling đội lốt khác: kết nối được giữ mở và máy chủ báo thư mới ngay trên đó. Đây thực sự mang tính đẩy, và nó cần một hộp thư có thông tin đăng nhập, một client có thể giữ một socket mở và tự kết nối lại khi bị rớt, cùng một máy chủ tôn trọng lệnh đó — nghĩa là rất nhiều bộ máy chỉ để phục vụ một công việc cần đúng một thư.
Dịch vụ thưgửi yêu cầu đóListener của bạnURL công khai, một khóagọi đến địa chỉ bạnCode của bạngửi yêu cầu đóHộp thưtrả lời bằng thư đang cóhỏi, mỗi giây một lầnMột webhook cần một địa chỉ trên internet công khai. Một lượt polling chỉ cần một vòng lặp, và không cần gì khác.
Hai hình dạng, và thứ thực sự quyết định giữa chúng. Một cần một địa chỉ trên internet công khai; cái còn lại chỉ cần khả năng gửi một yêu cầu đi ra — và đó là điều duy nhất trong hai thứ mà một test runner luôn có sẵn.

Đặt cạnh nhau, sự lựa chọn hóa ra ít liên quan đến sự tinh tế hơn là liên quan đến điều mỗi cách đòi hỏi ở cái máy mà code của bạn đang chạy trên đó.

Nó cần gì từ bạnWebhookKiểu polling
Một địa chỉ để code của bạn có thể được liên hệ tớiCó: một URL công khai kèm chứng chỉ, định tuyến được từ internet.Không. Một yêu cầu gửi đi là toàn bộ yêu cầu cần có.
Một khóa bí mật cần giữ và xoay định kỳCó: một khóa ký, nếu không một người lạ có thể gửi một thư giả đến bạn.Không. Không có gì cần xác minh, vì không có gì tự đến khi chưa được hỏi tới.
Một thứ gì đó phải đang chạy đúng lúc thư đếnCó — và khi nó sập, việc bạn có nhận được thư hay không phụ thuộc vào chính sách thử lại của người gửi, chứ không phải quyết định của bạn.Không. Không có gì bị bỏ lỡ khi bạn không nhìn vào: dù sao hộp thư cũng giữ nó trong 5 ngày.
Các yêu cầu được gửi khi không có thư nàoKhông một yêu cầu nào cả. Đó chính là điểm hấp dẫn duy nhất.Một yêu cầu cho mỗi khoảng chờ — cái giá thật sự, và cũng là chủ đề của phần còn lại trong hướng dẫn này.

Vòng lặp mà ai cũng viết trước tiên

Nó chỉ có bốn dòng, nó chạy được ngay trong ngày được viết ra, và mọi vấn đề của nó xuất hiện sau đó, ở một nơi khác: trong một pipeline vào ba giờ sáng, trong một agent đã “suy nghĩ” suốt mười một phút, trong một hộp thư trả lời 429 cho một đồng nghiệp vì vòng lặp của bạn đang chiếm hết hạn mức.

vòng lặp để bắt đầu từ đó
import time
import requests

while True:
    r = requests.get("https://grabmail.io/api/v1/mailbox",
                     params={"address": "signup-42@grabmail.io"})
    if r.json()["messages"]:
        break
    time.sleep(1)

Nó sai ở năm điểm, và chỉ điểm đầu tiên là rõ ràng.

Nó không bao giờ bỏ cuộc
Không có thời hạn nào cả, nên khi thư thực sự không đến — biểu mẫu đã từ chối địa chỉ, hàng đợi của người gửi bị kẹt, ai đó gõ sai tên miền — vòng lặp này không thất bại. Nó treo luôn. Một job bị treo còn tệ hơn một job thất bại, vì log kết thúc mà không bao giờ nói lý do.
Nó đếm số lần thử và gọi đó là số giây
Ngay cả khi có giới hạn số lượt thử, ba mươi lần thử ở mức “một giây” cũng không bao giờ là ba mươi giây: mỗi lượt còn tốn thêm thời gian cho một yêu cầu, và một yêu cầu mất 400 ms sẽ biến ba mươi giây của bạn thành bốn mươi hai giây. Thêm một lần thử lại nữa là phép tính không còn là phép tính đơn giản nữa.
Mọi runner đều hỏi đúng cùng một nhịp
Khởi chạy hai mươi job từ cùng một pipeline, chúng sẽ polling cùng nhịp với nhau, vì tất cả đều bắt đầu cách nhau vài milli giây và tất cả đều ngủ đúng một giây như nhau. Đỉnh điểm là hai mươi lần mức trung bình, và chính cái đỉnh điểm đó là thứ bị từ chối.
Nó lấy thư mới nhất, không phải thư của bạn
Mục đầu tiên trong danh sách là bất cứ thứ gì đang ở trên cùng hộp thư đó, mà trên một địa chỉ công khai có thể là thư của người khác, và trên một địa chỉ dùng lại thì là thư của tuần trước. Một vòng lặp thoát ra ngay khi gặp thư đầu tiên sẽ vô tư thoát ra trước cả khi thư nó đang chờ kịp đến.
Nó coi mọi câu trả lời đều là thành công
Đọc danh sách thư ra từ một 429 hay một 404 sẽ gây lỗi ở đâu đó cách xa ba khung gọi so với bất cứ thứ gì giải thích được nó, còn đọc ra từ một 500 có khi chẳng gây lỗi gì cả. Mã trạng thái là điều cần nhìn đầu tiên, không phải cuối cùng.

Dừng theo đồng hồ, không theo số lần

Lấy thời hạn đúng một lần, trước yêu cầu đầu tiên, từ một đồng hồ đơn điệu — loại không thể nhảy lùi lại khi máy tự sửa giờ — rồi so sánh với nó ở đầu mỗi lượt. Mọi thứ khác trong vòng lặp sau đó được tự do thay đổi mà không làm thay đổi thời gian chờ thực tế: bạn có thể nới rộng khoảng chờ, thử lại một lượt bị từ chối, hoặc thêm một bộ lọc thứ hai, và chín mươi giây vẫn cứ là chín mươi giây.

Bao lâu là đủ lâu là một câu hỏi về người gửi, không phải về bạn. Thư mà một cái máy tạo ra để trả lời một biểu mẫu thường được gửi đến trong vài giây; một hàng đợi đang tồn đọng, một bên nhận có greylisting, hay một đợt gửi theo giờ lại là một cấp độ hoàn toàn khác, và không có khoảng chờ nào bạn chọn khiến nó đến sớm hơn.

Bạn đang chờ điều gìMột thời hạn thành thậtNên làm gì khi thời hạn đó trôi qua
Một thư đăng ký hoặc xác minh, trong một bài kiểm thử60 đến 120 giâyCho bài kiểm thử thất bại và in ra địa chỉ đó. Chín trên mười lần hộp thư trống là vì biểu mẫu đã từ chối địa chỉ, và địa chỉ đó là điều đầu tiên bất kỳ ai đọc log cũng cần thấy.
Một thư đặt lại mật khẩu mà một người vừa yêu cầu30 đến 60 giâyHãy báo cho họ rằng thư chưa đến và đề nghị gửi lại. Đừng cứ xoay vòng phía sau một màn hình im lặng: dù sao họ cũng sẽ xin một thư khác, và giờ lại có hai mã cùng tồn tại.
Một agent tự mình hoàn tất việc đăng kýHai hoặc ba lượt chờ phía máy chủ, tức 50 đến 75 giâyHãy nói rõ điều đó trong câu trả lời. “Không có thư xác nhận sau một phút” là một kết quả mà agent có thể hành động dựa vào; một lệnh gọi công cụ không bao giờ trả về thì không.
Một bản tin, một biên nhận, bất cứ thứ gì được gửi theo lôVài phút — hoặc đừng chờ luônHãy polling theo một lịch cố định thay vào đó, và để tiến trình kết thúc. Thứ gì đó nằm trên một socket suốt mười phút là thứ sẽ bị giết bởi một proxy, một runner, hoặc một giới hạn container.

Thời hạn cũng là nơi thành thật nhất để đặt thông báo lỗi của bạn. “Không có gì khớp với ‘Xác nhận email của bạn’ đến signup-42@grabmail.io trong vòng 90 giây” nêu tên địa chỉ, bộ lọc và hạn mức — ba trong bốn điều cần để hiểu chuyện gì đã xảy ra. Điều thứ tư — thứ đã đến — cũng đáng để in ra: một danh sách các chủ đề mà vòng lặp đã thấy và loại bỏ biến “nó chập chờn thôi” thành “chủ đề thư đã đổi” chỉ trong một lần đọc.

Nên hỏi bao lâu một lần, và khi nào nên nới rộng

Mức sàn là bất cứ gì dịch vụ cho phép, và ở đây đó là một yêu cầu mỗi giây, cho mỗi địa chỉ. Đó không phải là một lời khuyên nên tránh — polling mỗi giây một lần chính là cách dùng được nhắm tới, không có hạn mức theo ngày, không có hạn mức theo tháng và không có tín dụng cho việc dồn yêu cầu để quản lý — nhưng đó là một mức sàn, và một vòng lặp hỏi hai lần trong cùng một giây sẽ nhận 429 cho lần thứ hai, thay vì một câu trả lời nhanh hơn.

Khoảng chờ cố định
Một giây, mỗi lần, cho đến khi hết thời hạn. Hoàn toàn phù hợp cho một lượt chờ sẽ kết thúc trong mười giây, và là lựa chọn mặc định đúng đắn cho một bài test đơn lẻ trên một runner đơn lẻ. Lỗi duy nhất của nó là nó vẫn tiếp tục hỏi với cùng một tốc độ rất lâu sau khi đã rõ ràng là thư sẽ không đến.
Khoảng chờ nới rộng dần
Một giây trong lúc thư có thể vẫn đang trên đường đến, rồi nhân đôi dần — hai, bốn, tám — cho tới một mức trần. Nó tốn thêm một chút độ trễ cho một thư đến muộn, và tiết kiệm được hầu hết số yêu cầu trong một lượt chờ mà đằng nào cũng sẽ thất bại. Hãy đặt mức trần cho nó: một khoảng chờ cứ nhân đôi mà không có mức trần sẽ ngủ suốt nửa sau của một thời hạn hai phút.
Jitter, chỉ cộng thêm, không bao giờ bị trừ đi
Hãy giãn các lượt hỏi ra bằng một phần ngẫu nhiên, để hai mươi runner không còn hỏi đúng cùng một nhịp. Công thức thường gặp — một giá trị ngẫu nhiên giữa không và khoảng chờ — lại sai ở đây, vì một nửa phạm vi đó rơi xuống dưới mức sàn một giây. Hãy cộng phần ngẫu nhiên đó lên trên thay vào đó: khoảng chờ là một mức tối thiểu, và jitter chỉ có thể khiến một lượt hỏi diễn ra muộn hơn, không bao giờ sớm hơn.
Một lượt tạm dừng không phải do bạn chọn
Khi câu trả lời là một 429, khoảng chờ sẽ là bất cứ gì Retry-After nói, và lượt bị từ chối đó không được tính là một lần thử. Nếu tính nó là một lần thử, một vòng lặp đang bị hãm tốc độ sẽ dùng hết cả thời hạn của nó để thu thập những lời từ chối mà chưa từng đọc được hộp thư.

Mười lăm giây ở mức một giây, rồi nhân đôi dần tới mức trần tám giây, cộng thêm jitter lên trên, gói gần như mọi lượt chờ trong hướng dẫn này vào đúng sáu dòng:

chỉ riêng khoảng chờ
def delay(attempt: int) -> float:
    # One second while the message is probably still in flight, then
    # wider. Never below a second: the list endpoint allows one call
    # per second, per address, so jitter is added and never taken off.
    step = 1.0 if attempt < 15 else min(8.0, 2.0 ** (attempt - 14))
    return step + random.uniform(0.0, step / 2)

Số mũ được lệch đi để việc nới rộng chỉ bắt đầu sau đoạn cố định ban đầu, chứ không phải từ lượt hỏi đầu tiên. Không có độ lệch đó, khoảng chờ đã lên tới tám giây ngay khi một thư đăng ký chậm đến nơi, và một lượt chờ đáng lẽ chỉ mất mười hai giây lại mất tới hai mươi giây.

Không điều nào trong số này áp dụng cho yêu cầu đầu tiên. Hãy hỏi ngay, trước bất kỳ lượt ngủ nào: một thư đã có sẵn trong hộp thư từ khi vòng lặp bắt đầu — trường hợp bình thường đối với bất cứ thứ gì được kích hoạt trước khi lượt chờ bắt đầu — không nên tốn thêm một giây độ trễ nào để được nhận ra.

Đọc một lời từ chối

Mọi câu trả lời từ endpoint lấy danh sách đều là JSON, và những câu trả lời không phải một hộp thư đều dùng chung một hình dạng: một slug error, ổn định và là thứ nên dùng để rẽ nhánh, cùng một message, chỉ là văn xuôi và có thể bị đổi câu chữ bất cứ lúc nào. Một lời từ chối vì đi quá nhanh còn mang theo một header nữa:

một lời từ chối trông như thế nào
HTTP/1.1 429 Too Many Requests
Retry-After: 1
Content-Type: application/json; charset=utf-8

{"error":"rate_limited","message":"one request per second, per address"}

Retry-After tính bằng số giây nguyên, và đó là con số thật — lấy từ hạn mức thực tế còn lại của địa chỉ này, không phải từ một hằng số trong tài liệu. Ngủ đúng khoảng thời gian đó là cách vừa lịch sự nhất vừa nhanh nhất: ngủ ngắn hơn sẽ lại bị từ chối, ngủ dài hơn là cho đi thời gian một cách vô ích. Sau đây là mọi thứ một vòng lặp polling có thể gặp phải, và mỗi câu trả lời đó thực ra đang yêu cầu vòng lặp làm gì.

Điều gì trả vềNó có nghĩa là gìVòng lặp nên làm gì
200 kèm count: 0Hộp thư có tồn tại và đang trống. Đây là câu trả lời bình thường cho phần lớn thời gian của một lượt chờ.Cứ tiếp tục chờ. Đây không phải là một lỗi, và sẽ không bao giờ trở thành lỗi.
429rate_limitedQuá nhanh: một yêu cầu lấy danh sách thứ hai trong cùng một giây cho địa chỉ này, hoặc hơn 1,200 yêu cầu trong một phút từ cùng một nguồn.Ngủ đúng số giây mà Retry-After chỉ định, rồi hỏi lại. Đừng tính lời từ chối đó là một lần thử.
404unknown_domainPhần sau dấu @ không được lưu trữ ở đây. Gần như luôn luôn là do gõ sai, hoặc một tên miền có bản ghi MX chưa từng được trỏ về đây.Dừng lại. Chờ bao lâu cũng không sửa được một tên miền. Hãy in ra địa chỉ mà bạn đã được đưa.
400invalid_addressTham số address bị thiếu, dài hơn 320 ký tự, hoặc không theo dạng name@domain.Dừng lại. Đây là lỗi ở phía người gọi, và nó sẽ là lỗi giống nhau ở mọi lượt thử.
400bad_cursorGiá trị before hoàn toàn không có hình dạng của một id thư. Một id đúng định dạng nhưng đã hết hạn không phải là lỗi này: nó trả lời 200 kèm một trang trống.Dừng việc phân trang lại và bắt đầu lại từ trang đầu tiên.
404not_found, từ một thư cụ thểId đó không có trong hộp thư đó — hoặc đã từng có, nhưng từ đó đã hết hạn hoặc bị xóa.Hãy coi nó là đã mất, không phải là đến muộn. Một id bạn vừa thấy trong danh sách vài giây trước sẽ không quay lại nữa.
500storage_failedCó gì đó đã lỗi ở phía chúng tôi trong khi đọc hộp thư.Hỏi lại, nhưng hãy để thời hạn quyết định, và đừng hỏi nhanh hơn mức thông thường.

Hai trong bảy trường hợp đó có nghĩa là dừng lại, và đó cũng là hai trường hợp đáng nói to lên nhất. Một vòng lặp coi unknown_domain là “chưa tới lúc” sẽ tốn nguyên chín mươi giây để chứng minh lại điều mà dịch vụ đã nói với nó ngay trong bốn mươi milli giây đầu tiên.

Đâu là thư của bạn

Một hộp thư không phải là một hàng đợi, và thứ mới nhất trong đó không hẳn là thứ bạn đang chờ. Trên một tên miền công khai, ai đoán được địa chỉ cũng gửi được vào đó; trong một bộ kiểm thử, cùng một địa chỉ thường được dùng lại giữa các lượt chạy; và một lượt đăng ký thường gửi hai thư — một thư chào mừng và một thư xác nhận — mà chỉ một trong hai mang mã. Cách khắc phục là một mốc đánh dấu, và nó phải được lấy trước cả cái việc gây ra thư đó.

  1. Trước khi bạn gửi biểu mẫu, hãy lấy danh sách hộp thư với limit=1 và giữ lại id của thư mới nhất, hoặc không giữ gì cả nếu hộp thư trống. Id đó chính là mốc đánh dấu.
  2. Thực hiện việc đó — gửi biểu mẫu, gọi endpoint, bấm nút.
  3. Polling danh sách đó. Thư trả về theo thứ tự mới nhất trước, nên hãy đi từ trên xuống và dừng lại ngay khi gặp mốc đánh dấu: mọi thứ từ đó xuống dưới đều cũ hơn hành động của bạn và có thể bỏ qua mà không cần đọc.
  4. Lọc những gì nằm phía trên nó theo người gửi, theo chủ đề, hoặc cả hai. Một đoạn chuỗi con thường là đủ, và nên chọn phần sẽ không bị dịch sang ngôn ngữ khác — một bài kiểm thử khớp với “Xác nhận email của bạn” sẽ thất bại vào ngày tài khoản đang được kiểm thử chuyển sang một ngôn ngữ khác.
  5. Rồi, và chỉ đến lúc đó, mới mở thư ra. Danh sách chỉ mang theo một preview ngắn, không phải toàn bộ nội dung, và cái mã bạn cần tìm rất thường nằm sau đoạn preview đó. Thêm một yêu cầu nữa sẽ lấy được toàn bộ thư, và yêu cầu đó được tính vào một hạn mức riêng, lớn hơn nhiều so với hạn mức của việc lấy danh sách.

Trong một shell, hai lượt đọc đó trông như sau đây — mốc đánh dấu trước, rồi đến lượt polling:

shell
curl -sG https://grabmail.io/api/v1/mailbox \
  --data-urlencode "address=signup-42@grabmail.io" --data-urlencode "limit=1"
curl -sG https://grabmail.io/api/v1/mailbox \
  --data-urlencode "address=signup-42@grabmail.io" --data-urlencode "limit=25"

Cả hai yêu cầu đều nêu đầy đủ địa chỉ, vì ở đây địa chỉ chính là hộp thư: không có session, không có cursor được giữ lại thay bạn, và không có gì từ một lệnh gọi mà lệnh gọi tiếp theo còn nhớ được. Đây cũng là lý do một địa chỉ có thể được theo dõi an toàn từ hai nơi cùng lúc — đọc không tiêu tốn gì cả, nên hai vòng lặp cùng nhìn vào một hộp thư đều thấy được mọi thư, và không bên nào có thể lấy đi một thư ngay dưới tay bên kia.

Hành động đúng một lần

Một lượt polling bị thử lại có thể thấy cùng một thư hai lần, và đây không phải là chuyện hiếm: máy chủ trả lời, kết nối rớt trước khi phần nội dung đến được với bạn, HTTP client của bạn thử lại, và câu trả lời lần hai chứa đúng thư mà lần đầu đã mang theo rồi. Nếu điều bạn làm với một thư là bấm vào một liên kết, xác nhận một khoản thanh toán, hoặc đăng vào một channel, thì làm điều đó hai lần là một lỗi có hậu quả vượt ra ngoài tiến trình của bạn.

Giữ lại các id bạn đã xử lý rồi
Một tập hợp id trong bộ nhớ là đủ cho một lượt chờ sống và chết trọn vẹn trong một hàm. Với bất cứ thứ gì phải sống sót qua một lần khởi động lại — một hộp thư được rút cạn bởi một job theo lịch, một agent xử lý dần một danh sách tồn đọng — id đó phải được ghi lại ở đâu đó cũng sống sót cùng với nó.
Xóa là một hành động idempotent
Xóa một thư trả về 200 ở lần thứ hai giống như lần đầu, nên một lượt xóa bị thử lại không bao giờ trông như một thất bại và không bao giờ cần một trường hợp đặc biệt riêng. Hãy xóa sau khi bạn đã hành động, chứ không phải trước: một lần sập nguồn giữa hai việc đó khi ấy chỉ khiến bạn tốn công đọc lại — điều có thể khôi phục — thay vì mất luôn cái thư, điều không thể khôi phục.
Id ở đây không phải là Message-ID của người gửi
Id trong API là của riêng chúng tôi: nó chỉ có nghĩa trong phạm vi một hộp thư, và ngừng tồn tại khi thư đó hết hạn. Header Message-ID là của người gửi, nó đi theo cùng thư, và đó chính là cái bạn cần nếu muốn khớp cùng một thư giữa hai hệ thống khác nhau — hướng dẫn về header có nói nó nằm ở đâu.

Không điều nào trong số này cần thiết cho một bài kiểm thử chỉ chờ một mã rồi vứt bỏ hộp thư đi. Nhưng tất cả đều cần thiết ngay khi một vòng lặp chạy mà không có ai theo dõi, vì cái thất bại mà nó ngăn chặn lại không trông như một thất bại: nó trông như công việc đã được làm đúng, hai lần.

Khi việc chờ thuộc về máy chủ

Có đúng một nơi ở đây bạn không cần tự viết vòng lặp, và nó tồn tại cho những caller không đủ khả năng viết một vòng lặp như vậy. Một AI agent phải trả giá cho mỗi lượt nó dùng để kiểm tra, nên một công cụ trả lời “chưa có gì” chín lần chính là chín lượt trả giá cho không khí. wait_for_message của MCP server thay vào đó giữ yêu cầu mở, tự polling ở phía chúng tôi, và chỉ trả lời đúng một lần — hoặc kèm thư đó, hoặc kèm một câu nói thẳng rằng nó đã chờ và không có gì đến.

một lệnh gọi, chờ tối đa 25 giây
$ curl -sX POST https://grabmail.io/mcp -H "Content-Type: application/json" -d '{
  "jsonrpc":"2.0","id":1,"method":"tools/call",
  "params":{"name":"wait_for_message","arguments":{
    "address":"signup-42@grabmail.io","subject_contains":"code",
    "timeout_seconds":25}}}'

Có bốn điều đáng biết về nó trước khi bạn dựa vào nó để xây dựng gì đó.

Nó chờ tối đa 25 giây
timeout_seconds có thể xin ít hơn, nhưng không bao giờ xin nhiều hơn được. Mức trần đó không phải tùy tiện: mỗi người chờ là một worker chỉ làm một việc là ngủ, và một yêu cầu bị giữ mở suốt vài phút là một yêu cầu sẽ chết vì proxy timeout của ai đó từ lâu trước khi nó kịp trả về.
Nó lọc ngay từ đầu vào
from_contains, subject_containssince_id chính là ba quyết định giống như phần ở trên, chỉ khác là được thực hiện ở phía máy chủ. since_id chính là mốc đánh dấu, và nó quan trọng ở đây hơn bất cứ đâu khác: thiếu nó, lệnh gọi sẽ trả về ngay lập tức với bất cứ thứ gì đang có sẵn trong hộp thư.
Một timeout là một câu trả lời, không phải một lỗi
Khi không có gì đến, nó trả về với timed_out được đặt, kèm theo thời gian nó thực sự đã chờ, và nói rõ ràng rằng gọi lại chính là cách để tiếp tục chờ. Hai hoặc ba lệnh gọi là một lượt chờ bình thường cho một thư đăng ký: đó chính là vòng lặp, chỉ còn ba lượt thay vì chín mươi.
Có 8 chỗ chờ, và không có hàng đợi
Khi tất cả các chỗ đó đều đang bận, lệnh gọi trả về ngay và nói rõ điều đó, thay vì phải xếp hàng phía sau bảy agent khác. Đây là kiểu thất bại đúng đắn: một agent được báo “đang chờ quá nhiều lượt” vẫn có thể lấy danh sách hộp thư và tiếp tục làm việc, còn một agent ngồi trong hàng đợi thì chỉ có thể ngồi chờ.

Giới hạn theo từng địa chỉ vẫn áp dụng bên trong nó — vòng lặp của chúng tôi cũng bị giới hạn tốc độ giống hệt vòng lặp của bạn, nên một lượt chờ phía máy chủ không phải là cách để lách qua mức sàn, mà chỉ là cách để không phải trả giá cho nó bằng lượt của bạn. Với một bộ kiểm thử, tất cả những điều này không đáng để bận tâm: một bài test vốn đã là một tiến trình được phép ngủ, và một vòng lặp viết bằng chính ngôn ngữ của bài test đó dễ gỡ lỗi hơn nhiều so với một vòng lặp ở xa. Hướng dẫn về MCP nói về các công cụ còn lại.

Toàn bộ vòng lặp, chỉ một lần

Tất cả những gì ở trên, gói trong một file: một thời hạn lấy từ đồng hồ đơn điệu, một khoảng chờ nới rộng dần với jitter chỉ cộng về một phía, Retry-After được tôn trọng và không tính là một lần thử, một mốc đánh dấu để quyết định đâu là mới, một bộ lọc theo chủ đề, và một yêu cầu thêm để lấy về thư mà phần danh sách chỉ mới cho xem preview.

một lượt chờ trụ vững
import random
import time
import requests

API     = "https://grabmail.io/api/v1"
ADDRESS = "signup-42@grabmail.io"


def delay(attempt: int) -> float:
    # One second while the message is probably still in flight, then
    # wider. Never below a second: the list endpoint allows one call
    # per second, per address, so jitter is added and never taken off.
    step = 1.0 if attempt < 15 else min(8.0, 2.0 ** (attempt - 14))
    return step + random.uniform(0.0, step / 2)


def watermark(s):
    # Read this BEFORE the form is submitted. Every id above it
    # afterwards is mail that arrived because of what you did.
    r = s.get(f"{API}/mailbox", params={"address": ADDRESS, "limit": 1})
    r.raise_for_status()
    seen = r.json()["messages"]
    return seen[0]["id"] if seen else None


def wait_for(s, subject, since, timeout=120.0):
    deadline = time.monotonic() + timeout
    attempt  = 0
    rejected = set()

    while time.monotonic() < deadline:
        r = s.get(f"{API}/mailbox", params={"address": ADDRESS, "limit": 25})

        if r.status_code == 429:
            time.sleep(int(r.headers.get("Retry-After", "1")))
            continue                      # refused, so it was not an attempt
        if r.status_code == 200:
            for m in r.json()["messages"]:        # newest first
                if m["id"] == since:
                    break                 # older than the watermark
                if subject.lower() in m["subject"].lower():
                    full = s.get(f"{API}/message/{m['id']}",
                                 params={"mailbox": ADDRESS})
                    full.raise_for_status()
                    return full.json()
                rejected.add(m["subject"])
        elif r.status_code < 500:
            raise RuntimeError(r.json().get("error", r.status_code))
        # a 5xx falls through: transient, and the deadline still governs

        time.sleep(delay(attempt))
        attempt += 1

    raise TimeoutError(
        f"nothing matching {subject!r} at {ADDRESS} in {timeout:.0f}s; "
        f"saw {sorted(rejected) or 'nothing at all'}")

Nó cố ý chỉ dài chừng năm mươi mấy dòng, gồm thư viện chuẩn và một HTTP client. Không có gì cần cài đặt, không có gì cần cấu hình và không có khóa bí mật nào trong đó cả — đó chính là điểm mấu chốt: cùng một hình dạng đó chuyển sang Node, sang một shell script, hoặc sang bất cứ thứ gì framework kiểm thử của bạn đã dùng để gửi yêu cầu, mà không cần đổi gì cả.

  1. Đọc mốc đánh dấu trước hành động gây ra thư đó, không bao giờ đọc sau.
  2. Hỏi ngay một lần, rồi mới ngủ. Không bao giờ ngủ trước.
  3. Lấy thời hạn từ một đồng hồ đơn điệu, và kiểm tra nó ở đầu mỗi lượt.
  4. Giữ khoảng chờ ở mức một giây mỗi địa chỉ hoặc cao hơn, và chỉ cộng thêm jitter theo một chiều tăng.
  5. Ngủ đúng bằng những gì Retry-After chỉ định, và không tính một lời từ chối là một lần thử.
  6. Rẽ nhánh theo slug error: unknown_domaininvalid_address có nghĩa là dừng lại, không phải chờ tiếp.
  7. Khớp theo người gửi hoặc chủ đề, và dừng việc đi qua danh sách ngay khi chạm tới mốc đánh dấu.
  8. Mở thư ra trước khi phân tích nó — danh sách chỉ mang theo một preview, không phải toàn bộ nội dung.
  9. Khi thất bại, hãy in ra địa chỉ, bộ lọc, hạn mức, và các chủ đề mà vòng lặp đã từ chối.

Chín quy tắc, và tám trong số đó tồn tại vì một thất bại mà ai đó đã từng phải dựng lại từ một log. Quy tắc không nói về thất bại là quy tắc thứ hai: hỏi một lần trước lượt ngủ đầu tiên chính là điều khiến một lượt chờ cho một thư đã đến từ trước chỉ tốn bốn milli giây thay vì một giây trọn — mà trên cả một bộ hai trăm bài kiểm thử, đó là ba phút thời gian thực mà sau này không ai phải giải thích.

Câu hỏi

GrabMail có webhook không?

Không, và đây không phải một thiếu sót đang chờ được lấp đầy. Dịch vụ này chỉ nhận thư và đưa nó ra qua HTTP mà không cần khóa: không có tài khoản nào đứng sau một địa chỉ công khai để gắn một callback vào, và không có hàng đợi nào giữ lại một lượt gửi mà endpoint của bạn đã từ chối. Nếu quy trình của bạn thực sự không thể polling, trang so sánh nêu tên các dịch vụ có cung cấp webhook.

Tôi được phép polling một địa chỉ bao lâu một lần?

Mỗi giây một lần, cho mỗi địa chỉ — và đó là cách dùng được nhắm tới, không phải là ranh giới của nó. Không có hạn mức theo ngày, không có hạn mức theo tháng, và không có tín dụng dồn yêu cầu nào cần quản lý. Hai mươi hộp thư được polling mỗi giây một lần từ một runner là cách dùng bình thường; mức trần còn lại duy nhất là 1,200 yêu cầu mỗi phút từ một nguồn duy nhất, đúng bằng hai mươi hộp thư đó, không hơn một hộp thư thứ hai mươi mốt nào cả.

Vì sao vòng lặp của tôi trả về một thư từ lượt test trước đó?

Vì nó lấy mục đầu tiên trong danh sách mà không hỏi xem thư đó đến khi nào. Một hộp thư giữ lại bất cứ thứ gì được gửi đến nó trong 5 ngày, và một địa chỉ dùng lại thì đầy thư của lượt chạy trước. Hãy đọc id mới nhất trước khi kích hoạt việc gửi thư, và bỏ qua mọi thứ từ id đó trở xuống — hoặc xóa sạch nội dung hộp thư trước khi bắt đầu bài kiểm thử, tốn một yêu cầu cho mỗi thư nhưng loại bỏ hoàn toàn sự mập mờ đó.

Một hộp thư trống có phải là một 404 không?

Không. Một hộp thư trống là 200 kèm count: 0 và một danh sách rỗng, một cách có chủ đích, để một vòng lặp polling không bao giờ phải xử lý riêng trường hợp “chưa có gì”. Một 404 từ endpoint lấy danh sách có nghĩa là tên miền đó không được lưu trữ ở đây; một 404 từ một thư cụ thể có nghĩa là id đó không có trong hộp thư đó, hoặc đã hết hạn.

Tôi nên chờ một thư xác minh bao lâu?

Sáu mươi đến một trăm hai mươi giây trong một bài kiểm thử tự động, ba mươi đến sáu mươi giây cho một người đang ngồi chờ trước màn hình. Phần lớn thư do máy tạo ra đến trong vài giây; phần đuôi dài thuộc về hàng đợi của người gửi, không phải về việc gửi đi. Nếu nó thường xuyên sát với thời hạn của bạn, một thời hạn dài hơn không phải là câu trả lời — có gì khác đang sai.

Hai tiến trình có thể polling cùng một địa chỉ đồng thời không?

Có. Đọc không tiêu tốn gì cả, nên cả hai đều thấy mọi thư và không bên nào giấu thư của bên kia. Tuy nhiên cả hai vẫn dùng chung hạn mức một-yêu-cầu-mỗi-giây của địa chỉ đó, nên hai vòng lặp cùng hỏi mỗi giây sẽ mỗi bên bị từ chối khoảng một nửa số lần: hãy cho mỗi bên hai giây một lần, hoặc để một bên polling rồi chuyển kết quả cho bên còn lại.

Tôi nên polling, hay dùng công cụ chờ qua MCP?

Hãy polling, nếu bạn đang viết một bài kiểm thử hoặc một script: một tiến trình được phép ngủ thì nên ngủ, và một vòng lặp viết bằng ngôn ngữ của riêng bạn dễ gỡ lỗi hơn một vòng lặp ở xa. Hãy dùng wait_for_message khi người gọi phải trả giá theo lượt chứ không theo giây, mà trong thực tế nghĩa là một AI agent. Nó chờ tối đa 25 giây cho mỗi lệnh gọi, lọc theo người gửi và chủ đề, và trả về một timeout đơn giản mà bạn chỉ cần gọi lại là tiếp tục chờ được.

Hãy thử ngay khi nó còn mới

Một địa chỉ chỉ mất một cú nhấp, không tài khoản và không thẻ. Mọi thứ trong hướng dẫn này đều hoạt động ngay trên đó.

Chào mừng trở lại

Hộp thư và tên miền của bạn, ở cùng một nơi.