4 min read
Making a flaky webhook safe to retry
My contact form saves to a Google Sheet through Apps Script. The first real test timed out after saving the row, which is exactly when a naive retry creates duplicates.
The contact form on this site doesn't talk to a database. A Next.js server action forwards each message to a small Google Apps Script web app, and the script appends a row to a Google Sheet with a Status column I use as an inbox. No servers, no bill, and I can triage messages from my phone.
It worked perfectly against a local mock. Then I pointed it at the real script and measured:
| Call | Result |
|---|---|
| Wrong secret, 5 tries | 4 × {"ok":false} in 2–28 s, 1 × a Google 404 page |
| Form submit, 10 s timeout | timed out at 10.4 s |
| Form submit, 45 s timeout | Google 404 page after 35 s |
| Direct call, right secret | {"ok":true} in 3.1 s |
Slow is annoying. The 404 is worse, because of when it happens.
Why a plain retry is dangerous
An Apps Script web app runs doPost first and answers afterwards. By the time my server sees a timeout or that 404 page, the row may already be in the sheet and the notification email may already be sent.
So "the request failed" doesn't mean "nothing happened". If the visitor clicks Send again, or my server retries on their behalf, I get two rows and two emails for one message. The fix isn't a longer timeout. It's making a second attempt harmless.
Give every message an ID
The form creates a UUID once per message and keeps it until the message is actually delivered. A failure doesn't reset it; only "Send another message" does.
// Kept across failed attempts: a failure can happen after the row was saved.
const [submissionId, setSubmissionId] = useState(() => crypto.randomUUID())
const onSubmit = handleSubmit(async (values) => {
const result = await submitContact(values, submissionId)
if (result.ok) setSent(true)
else toast.error(
Every attempt for the same message, whether it's my server retrying or the visitor clicking again, now carries the same ID.
Dedupe on the script side, under a lock
The script stores the ID in a hidden column and skips IDs it has already seen. The check and the append have to be atomic: a retry can arrive while the first attempt is still running, and two doPost calls that both check before either appends would both append.
const lock = LockService.getScriptLock()
lock.waitLock(20000)
try {
const alreadySaved = sheet
.getRange(1, ID_COLUMN, sheet.getLastRow(), 1)
.createTextFinder(data.id)
.matchEntireCell(true)
.findNext()
if (alreadySaved)
A duplicate gets { ok: true } without a second row or a second email. If waitLock times out it throws, Google answers with an error page, and the caller treats that as retryable, which is safe now.
Retry only what's retryable
The server action sorts every attempt into three outcomes instead of two:
// true = saved, false = rejected by the script, null = no usable answer (worth retrying).
async function postToWebhook(url: string, body: string): Promise<boolean | null> {
try {
const response = await fetch(url, { method: 'POST', body, signal: AbortSignal.timeout(20_000) })
const result = await response.json().catch
false is a real answer (a wrong secret, say) and retrying won't change it. null means I don't know what happened, so one more attempt with the same ID is both useful and harmless. Two attempts of 20 seconds each keep the worst case bounded.
Test the unhappy path on purpose
Waiting for Google to misbehave isn't a test plan. I ran the real .gs file inside a Node vm with stubbed Google services behind a tiny HTTP server that could be told to save a row and then answer with a 404, and drove the form with Puppeteer:
- Both server attempts lose their response, then the visitor resubmits: three requests, one row, one email.
- "Send another message" gets a new ID and a new row.
- The first attempt loses its response and the server retry succeeds: success screen, no duplicate.
Takeaways
- A timeout tells you nothing about whether the other side acted. Plan for "maybe".
- Put the idempotency key where the user's intent starts (the form), not where the request starts (the server).
- Make check-then-write atomic on the receiving side.
- Separate "rejected" from "unknown"; retry only the unknown.