AnswerLineStart free

, Engineering · SERP API · Rank Tracking

SERP API reliability at scale: retries, idempotency, billing and completeness

A reliable SERP pipeline is one that can prove every planned request for a run ended as a stored, valid result, or knows exactly which did not and why. Retries and idempotency are the mechanisms; completeness is the measure. The pieces below use this API’s async tasks: a ledger of planned work, submission that is safe to repeat, a retry budget per failure class, billing rules you can reconcile, data-quality checks that catch “successful” empty pages, and alerts. Success rates are yours to measure on your own keyword set.

Why request success is the wrong metric

Most SERP monitoring starts with HTTP status codes. At scale, the failures that hurt are invisible to them:

  1. Never submitted. A scheduler crashed after building 40,000 of 60,000 tasks.
  2. Submitted, never received. A webhook endpoint returned 500 through its retry window during a deploy.
  3. Received, not stored. A worker threw on an unexpected field and dropped the message.
  4. Stored, not valid. The result parsed, but the organic list is empty for a keyword that always has ten results.
  5. Valid, wrong target. The task ran for the wrong market because a config change swapped country codes.

Each of these reports 100% HTTP success. A completeness metric catches all five:

completeness(run) = tasks with a valid stored result ÷ tasks planned for the run

The ledger: plan before you submit

Write the plan down before touching the API. One row per planned task:

create table serp_ledger (
  run_id        text not null,              -- e.g. '2026-09-17'
  task_key      text not null,              -- idempotency key, see below
  task_type     text not null,              -- GOOGLE, GOOGLE_NEWS, CHATGPT, ...
  payload       jsonb not null,
  task_id       uuid,                       -- set when the API admits it
  state         text not null default 'planned',
                                            -- planned | queued | completed | failed | invalid | stored
  attempts      int not null default 0,
  last_error    text,
  credits       int,
  updated_at    timestamptz not null default now(),
  primary key (run_id, task_key)
);
create index on serp_ledger (run_id, state);

The ledger turns every question about a run into a query: how many are still queued, which failed and with what, which came back invalid, what the run cost.

Idempotent submission

Build keys from meaning

An idempotency key identifies what a task is, not the attempt:

def task_key(run_id: str, surface: str, keyword_id: str, market: str, device: str, attempt: int = 0) -> str:
    base = f"{surface}:{run_id}:{keyword_id}:{market}:{device}"
    return base if attempt == 0 else f"{base}:r{attempt}"

Keys are unique across your account. Creating a task with a key that exists answers 409 RESOURCE_CONFLICT (in a batch, the item fails with RESOURCE_ALREADY_EXISTS) and creates nothing. So a scheduler that crashes halfway can resubmit the whole run; everything already admitted is refused, everything missing is created. The :r1 suffix is only for deliberate resubmission of a task that ran and failed, covered below. Idempotency keys explained goes deeper.

A useful property: a request whose every task is refused, for example for INSUFFICIENT_CREDITS, creates nothing and claims no key, so the same keys can be sent again after a top-up.

Submit in batches, record every item

POST /v1/async/task/batch takes 1 to 500 tasks. Each is validated and admitted on its own; results[] answers each by index in input order.

import os
import time
import random

import requests

API = "https://api.answerline.dev"
S = requests.Session()
S.headers["Authorization"] = f"Bearer {os.environ['ANSWERLINE_API_KEY']}"

def post_batch(chunk: list[dict]) -> dict:
    for attempt in range(6):
        try:
            resp = S.post(f"{API}/v1/async/task/batch", json=chunk, timeout=60)
        except requests.ConnectionError:
            resp = None  # safe to resend: every task carries an idempotency key
        if resp is not None and resp.status_code == 200:
            return resp.json()
        if resp is not None and resp.status_code not in (429, 500, 502, 503, 504):
            resp.raise_for_status()
        wait = int(resp.headers.get("Retry-After", 0)) if resp is not None else 0
        time.sleep(max(wait, random.uniform(0, min(60, 2 ** attempt))))
    raise RuntimeError("batch not accepted after retries")

def submit(db, run_id: str, rows: list[dict]) -> None:
    for i in range(0, len(rows), 500):
        chunk = rows[i:i + 500]
        body = post_batch([
            {"taskType": r["task_type"], "payload": r["payload"], "idempotencyKey": r["task_key"],
             "webhook": {"url": "https://collector.example.com/hooks/serp"}}
            for r in chunk
        ])
        for item in body["results"]:
            row = chunk[item["index"]]
            if item["success"]:
                db.mark_queued(run_id, row["task_key"], item["task"]["id"], item["credits"]["creditsToCharge"])
            elif item["error"]["code"] == "RESOURCE_ALREADY_EXISTS":
                db.mark_exists(run_id, row["task_key"])       # admitted on an earlier attempt
            else:
                db.mark_failed(run_id, row["task_key"], item["error"]["code"])

Two things to handle deliberately:

A retry budget per failure class

Retrying everything is how pipelines melt down during an upstream incident. Classify, then set a budget per class.

Failure Where it shows Retry? How
429 RATE_LIMIT_EXCEEDED HTTP Yes Backoff with jitter; clears within a second
429 CONCURRENT_LIMIT_EXCEEDED HTTP, synchronous calls Yes Backoff; clears when a running job finishes. Prefer async tasks, which wait for a slot instead
429 QUEUE_LIMIT_EXCEEDED HTTP, whole submission Yes, later Wait for queue capacity; use details
429 SERVICE_OVERLOADED HTTP Yes Wait at least Retry-After seconds
5xx or network error on task creation HTTP Yes, with keys Resend the same keys
5xx or network error on GET/DELETE HTTP Yes Resend
500 or 502 from a synchronous monitor call HTTP No Final: the server already retried
Validation 400/422, 401, 403 HTTP No Fix the request, key or balance
Task FAILED Task body Budgeted Resubmit under :r1, :r2
Result valid but fails your quality checks Your validator Budgeted Resubmit under a new suffix

The source for the HTTP rows is the error codes table; API errors and retries has full wrappers in Python and TypeScript.

Task-level retries are budgeted

Per the async docs, transient failures are retried automatically within five minutes of a task’s first start. When a task still ends FAILED, it is not charged, and resubmitting is your decision. Set the budget per run, not per task:

Deadlines you can plan around

Every task finishes within documented bounds (async docs):

These bounds mean a run always converges: after the deadline every ledger row is either final or was never admitted. Set your run’s completeness deadline accordingly, and size submissions so your concurrency can drain them well inside it.

Collection: webhooks plus reconciliation

Receive results by webhook, and never rely on webhooks alone.

  1. Verify, store, acknowledge. Verify Webhook-Signature on the raw body, write the body to an inbox table keyed by task.id, answer 2xx. A delivery needs a 2xx within 15 seconds; otherwise it is retried, up to 10 attempts, the last about 21 to 43 minutes after the first.
  2. Deduplicate on task.id. Deliveries can repeat and arrive in any order. Webhook-Id is not signed; task.id is.
  3. Process from the inbox into the ledger and your results store.
  4. Reconcile. On a timer, select ledger rows still queued whose expected finish has passed, and fetch each with GET /v1/async/task/{taskId}. Ingest what is final.
  5. Redeliver after outages. The dashboard’s delivery log lets owners redeliver delivered or dead deliveries once an endpoint is fixed; reconciliation covers the same gap without manual steps.

Webhook receiver design builds the inbox and worker; sync, async or webhooks explains when polling is simpler.

Billing on failure: reconcile it

This API’s rules (credits docs):

Other vendors bill differently, some charging for specific 4xx responses; cheapest SERP API lists several policies with sources. When you run more than one provider, store the charge per task so a failure-heavy day shows up as a cost anomaly, not just a quality one.

A daily reconciliation query:

select run_id,
       count(*) filter (where state = 'stored')                as stored,
       count(*) filter (where state = 'failed')                as failed,
       sum(credits) filter (where state = 'stored')            as credits_charged,
       sum(credits) filter (where state <> 'stored')           as credits_on_non_stored
from serp_ledger
group by run_id
order by run_id desc
limit 14;

credits_on_non_stored should be zero with this API’s rules once rows record creditsCharged from the result body. If it is not, you are recording reservations as charges, or dropping results after paying for them (the invalid state).

GET /v1/credits returns remaining, perCycle and cycleResetsAt; compare the balance before and after a run with the ledger’s sum.

Data-quality checks: “successful” is not “valid”

A COMPLETED task means the page was read. It does not mean the page is what you wanted. Validate before a row counts toward completeness.

Structural checks

def validate_google(result: dict, expect_organic: int = 5) -> list[str]:
    problems = []
    organic = result.get("organicResults", [])
    if len(organic) < expect_organic:
        problems.append(f"organic_count={len(organic)}")
    positions = [r.get("position") for r in organic]
    if positions != sorted(positions):
        problems.append("positions_not_monotonic")
    if any(not r.get("link") for r in organic):
        problems.append("missing_link")
    return problems

Set expect_organic per keyword from history rather than globally: some queries genuinely return few results.

Distribution checks per run

Single results can be legitimately odd. Runs should not be:

A sudden shift across thousands of keywords is more often a collection or parsing change than a genuine SERP change. Hold the run’s reporting and investigate before publishing movements to users. SERP feature change tracking separates feature changes from noise.

Canary keywords

Include a small fixed set of keywords with stable, well-understood SERPs in every run, in every market. If canaries change, suspect the pipeline first.

Target checks

Store the request payload next to the result and assert that what you asked for is what you planned: country, location, device, hl. Configuration drift shows up here, not in HTTP codes.

Throughput and backpressure

Rate limits lists the headers to read for throttling before you hit a limit.

What to monitor and alert on

Signal Source Alert when
Completeness at deadline Ledger Below your target for the run
Rows still queued past expected finish Ledger Growing across reconciliation passes
FAILED share and top error codes Ledger Above budget, or a new dominant code
invalid share Validator Above recent baseline
Feature-rate shifts Run distributions Large shift across many keywords
Canary changes Canary rows Any structural change
Webhook inbox lag Inbox timestamps Oldest unprocessed row ages past minutes
Queue depth GET /v1/async/status Not draining at the expected rate
Credits GET /v1/credits vs plan Remaining below the next run’s planned maximum
Latency task.latencyMs, X-Latency-Ms Distribution shifts; plan capacity from your own numbers

task.latencyMs runs from first start to final outcome and excludes queue time, so trend queue wait separately from createdAt to completion.

Send your own X-Request-Id on API calls (1 to 128 characters) so a failing request can be traced in your logs and quoted to support.

Checklist

  1. Plan every task into a ledger before submitting.
  2. Build idempotency keys from meaning; add a suffix only for deliberate resubmission.
  3. Submit in batches of up to 500 and record every item’s outcome.
  4. Classify failures and give each class a retry rule and a budget.
  5. Receive by signed webhook, dedupe on task.id, and reconcile by polling.
  6. Record creditsCharged per task and reconcile against the balance.
  7. Validate results structurally, per run and with canaries before counting them.
  8. Alert on completeness and its components, not on HTTP success.

For warehouse loading of the results, see SERP data in BigQuery; for the volume side, enterprise rank tracking. Start with the async tasks docs.

Questions

What is the right reliability metric for SERP collection?

Completeness: the share of planned keyword, market and device tasks for a run that ended with a valid stored result by the run's deadline. HTTP success rates miss tasks that were never submitted, results that never arrived, and pages that parsed but were empty.

Which errors should a SERP pipeline retry?

With this API: every 429 (nothing was admitted or charged), 5xx and network errors on task creation when every task has an idempotency key, and FAILED tasks by resubmitting under a new key. Other 4xx errors need the request fixed, and a 500 or 502 from a synchronous monitor call is final because the server already retried.

Am I charged for failed requests?

Not with this API. A request or task that fails is charged nothing and its credit reservation is released, and queued tasks you clear were never charged. Other SERP vendors bill different status codes, so read each vendor's rule.

How do idempotency keys make retries safe?

A key built from what the task means, such as keyword, market, device and run date, is unique across your account. Resubmitting after a timeout returns 409 RESOURCE_CONFLICT, or RESOURCE_ALREADY_EXISTS inside a batch, instead of creating and charging a second task.

How long can an async task take?

A task has 72 hours from creation to start and five minutes from its first start to finish. A task that never starts in time fails with QUEUE_WAIT_EXCEEDED and one that runs out of time fails with Maximum retries exceeded; neither is charged.

Try it on your own prompts

500 free credits a month, no card. One POST returns the answer, sources and citations as JSON.

Keep reading