DEV Community

Cover image for How to retry a paid Google SERP request safely
Adam Yang for Serper.live

Posted on Edited on

How to retry a paid Google SERP request safely

A worker sends a paid search request. The connection drops before a reply arrives. On the next queue delivery, the worker creates a fresh UUID and sends the same body. If the first search completed, the second UUID can buy the same observation twice.

Now change one detail. The worker receives a confirmed 408 search_timeout from the API. Sending the same key can replay that failure. One retry policy has to handle both outcomes.

Keep the scheduled check and the API attempt in storage

Use a durable job ID for the scheduled observation. Persist the request body and a UUID for its current API attempt before sending it. If the worker restarts, the next worker can resume the same attempt. A later scheduled check gets its own job and attempt key, even if the query is identical.

In Serper.live, Idempotency-Key identifies an attempt. A replay can return the first attempt's result with Idempotent-Replayed: true. Sending a modified body with the same key can still return the original result. Save the key and body together. Serper.live retry contract

Outcome Next action
Reply missing or unreadable Back off and use the saved key and body
409 idempotency_in_progress Back off and use the saved key and body
Confirmed API 408, 424, or 503 Back off, persist a new attempt key, and retry the same body
409 idempotency_replay_unavailable Stop automatic retries and reconcile the attempt
Successful search Save the observation and finish the job
Other confirmed API error Record the failure for handling by the caller

One scheduled job branches on the outcome. A missing reply keeps the saved key, a confirmed 408 creates a new attempt key, and an expired replay goes to reconciliation.

The replay window is up to 15 minutes. Bound the retry budget accordingly. If the result is still unknown when the window expires, use the saved attempt details and any request ID to investigate before starting a fresh paid search. A new key starts a new search. Serper.live errors and replay behavior

Test the decision without making a search

Save this as retry.mjs and run node retry.mjs. The worker passes an HTTP status only when it can identify the response as coming from the API. A connection reset or a generic gateway reply leaves the outcome unknown. For a success, pass status 200 after decoding the result.

import assert from "node:assert/strict";

function retryAction({ status = null, code } = {}) {
  if (status === 200) return "store-result";

  if (status === null ||
      (status === 409 && code === "idempotency_in_progress")) {
    return "retry-same-key";
  }

  if (status === 409 && code === "idempotency_replay_unavailable") {
    return "reconcile";
  }

  if ([408, 424, 503].includes(status)) return "retry-new-key";
  return "record-failure";
}

assert.equal(retryAction(), "retry-same-key");
assert.equal(
  retryAction({ status: 409, code: "idempotency_in_progress" }),
  "retry-same-key",
);
assert.equal(
  retryAction({ status: 409, code: "idempotency_replay_unavailable" }),
  "reconcile",
);
for (const status of [408, 424, 503]) {
  assert.equal(retryAction({ status }), "retry-new-key");
}
assert.equal(retryAction({ status: 200 }), "store-result");
assert.equal(retryAction({ status: 422 }), "record-failure");
console.log("Retry decisions passed");
Enter fullscreen mode Exit fullscreen mode

A real worker executes the decision. Claim the job before reading its current attempt so two deliveries cannot both rotate the key. For a same-key retry, keep the body byte-for-byte and schedule the next attempt with backoff. For a new-key retry, write the new key to storage before the next POST. An attempt counter and next-run time belong in that same write.

When the worker sends the request, the persisted key goes in the header. The API allows a search to run up to 90 seconds and documents a client timeout of at least 100 seconds. A short deadline can leave the outcome ambiguous while the search is still running. Serper.live client timeout guidance

curl --include --max-time 100 https://api.serper.live/v1/search \
  -H "Authorization: Bearer $SERPER_LIVE_TOKEN" \
  -H "Idempotency-Key: $SERPER_LIVE_ATTEMPT_KEY" \
  -H "Content-Type: application/json" \
  --data-binary @attempt-body.json
Enter fullscreen mode Exit fullscreen mode

Use a stored JSON body such as {"query":"espresso scale","country":"US","language":"en","device":"mobile","limit":10}. On success, save the response and mark the job complete in one local transaction, or make the result write idempotent by job ID. That handles the other common crash window, when the worker gets the result and dies before acknowledging the queue message.

Top comments (3)

Collapse
 
aiapikey profile image
于侃 •

The parameter missing from your table is the retention window. A saved key protects you only while the provider still remembers it, and nothing in the response says when that memory ends. If your backoff outlives it, the replay silently becomes a new charge - and it looks identical to success, because a result arrives either way.

So stamp each attempt with a send time and refuse to replay a key older than the documented window. Then alert on the missing header: a replay returning without Idempotent-Replayed: true means your retry has quietly turned into a purchase. That assertion is what separates your two rows.

Also, the scheduled-check path pays full price every run. That dedupe belongs in your own cache, keyed by query and date.

Collapse
 
serper-live-dev profile image
Adam Yang Serper.live • • Edited

Good catch on the missing deadline check. The article mentions the 15-minute replay window. The worker should persist the first send time once per key, check the deadline before each resend, and reconcile any attempt that’s still unresolved when the deadline passes. A 503 billing_unavailable can also leave the original attempt unresolved, so I’d keep the same key in that case.

Serper.live stores the billing record separately from the cached response. If the request has finished and its cached response is gone, the same key returns 409 idempotency_replay_unavailable with the original requestId. A 200 without Idempotent-Replayed: true can be the first execution if the original POST never reached the API. I’d log the header and request ID, then check the ledger to confirm any charge. Retry docs.

Agreed on deduping duplicate job deliveries in the scheduler. I’d use the scheduled slot plus the full search context, including query, market, language, device, and limit. Each distinct slot is a fresh observation. Billing applies to pages with results after free pages are used. Billing docs.

Collapse
 
aiapikey profile image
于侃 •

You're right that the header alone can't discriminate, and that takes out my assertion: a 200 without Idempotent-Replayed: true is exactly what a genuine first execution looks like, so alerting on it fires on healthy traffic until you learn to ignore the alert.

The discriminator has to be cross-system. Count billing ledger entries for the key and compare against the responses you accepted as first executions - one charge per accepted response, or reconciliation has failed. The header and requestId are what let you join those two records, not what lets you conclude from either one.

I'd special-case 409 idempotency_replay_unavailable: it is charged-and-response-lost, so it must never fall into a generic backoff branch.

Last, the reconciler has to be idempotent itself, or its own retries double-count the charges it exists to find.