ScreenshotNeo

BlogHow-to

How to Retry Failed Screenshot API Requests Without Duplicate Charges

Retry screenshot API failures safely: classify errors, honor Retry-After, use bounded backoff, and handle timeouts without assuming the capture failed.

By the ScreenshotNeo team4 October 202610 min read

Short answer: classify the error before retrying, follow a valid Retry-After header, and use bounded exponential backoff with jitter for documented transient failures. If the screenshot API supports idempotency, reuse the same key and equivalent request for every attempt at one logical capture. Treat a timeout after submission as an unknown outcome: the server may have completed and billed the capture even though your client never received the response.

There is no universal guarantee that retries are free. Check the endpoint’s current rules for idempotency, retryable errors, quota accounting, and request or job status before promising that a retry cannot create another charge.

1. Decide whether this request is safe to retry

Use the provider’s structured error code as well as the HTTP status. Status codes below are common signals, not a contract shared by every screenshot API.

Response or outcome What to do
400 or other validation error Fix the URL, required parameters, selector, or output format. Repeating unchanged input will not fix it.
401 authentication error Check the API key, account access, and request authentication. Retry only after correcting the cause.
402 or documented quota exhaustion Inspect usage, plan, or reset timing. Repeating the request does not restore quota.
429 rate limit Wait at least the valid Retry-After value if supplied. Reduce concurrency and retry only within a finite budget allowed by the provider.
Documented temporary 5xx, busy, or render failure A small bounded retry may help. Confirm how the provider accounts for failed renders and reserved quota.
Timeout or connection loss after sending The result is unknown. Reconcile by request ID, job status, usage records, or provider support if available. If idempotency is supported, retry with the same key and payload.

Provider behavior varies. For example, Screenshot API documents 502 render_failed and 503 busy, and says those errors release the reserved unit. ScreenshotEngine documents different status semantics and warns that a client timeout can happen after a successful capture, so a retry may count as another successful request. Use your provider’s current reference rather than assuming these examples apply to your endpoint.

2. Set up one logical capture and its idempotency key

  1. Create and persist an operation ID before sending the capture request.
  2. If the endpoint documents idempotency, create one key for that operation and persist it.
  3. Reuse that exact key and equivalent parameters on every retry of the same operation.
  4. Use a new key only when the user asks for a new capture.
  5. Retain the key until the operation reaches a known terminal state, subject to the provider’s key scope and retention window.

Idempotency is endpoint-specific. Verify whether the endpoint accepts a key, how long it remembers it, which parameters must match, and whether a replay returns the original result. Do not assume that an API supports idempotency because another API does. Shopify’s general API documentation explains the duplicate-recognition concept, but each API defines its own mechanics. The cited api-screenshot.com documentation describes a feature-gated development preview and says its production routes currently return 503; that preview should not be treated as a generally available production contract.

3. Use bounded backoff, jitter, and a total time limit

For retryable transient errors without server timing guidance, use exponential delays plus random jitter. Cap attempts and total elapsed time. For 429, honor a valid Retry-After value rather than retrying sooner. A delay expressed as an HTTP date should be parsed as a date; a numeric value is a delay in seconds. If the value is missing or invalid, use the provider’s documented fallback policy.

Keep retries bounded by both attempts and a deadline. A common generic shape is a short initial delay that grows exponentially, capped at a few seconds, with random jitter; choose actual limits based on the endpoint’s latency, workload, and retry guidance. Do not copy an example retry count from one vendor as a universal standard.

Also check for retries already performed by an SDK, HTTP client, proxy, or queue. Layered retries multiply attempts. Record the logical operation ID, idempotency key (or a safe hash), provider request ID, attempt number, status and error code, elapsed time, and final outcome. Never log API secrets.

4. Runnable Python example

This generic example retries only explicitly selected transient responses, honors a valid Retry-After, applies bounded exponential backoff with jitter, and stops at a total deadline. Set the URL and authentication to match your provider. It deliberately does not invent an idempotency header: add the documented header name and persisted key only if your endpoint supports it.

import email.utils
import os
import random
import time
from datetime import datetime, timezone

import requests

API_URL = os.environ["SCREENSHOT_API_URL"]
API_KEY = os.environ["SCREENSHOT_API_KEY"]
TARGET_URL = "https://example.com"
# Load this from durable storage for the logical operation. Reuse it on retries.
OPERATION_ID = os.environ["CAPTURE_OPERATION_ID"]
MAX_ATTEMPTS = 4
TOTAL_DEADLINE_SECONDS = 45
BASE_DELAY_SECONDS = 0.5
MAX_DELAY_SECONDS = 8

# Confirm retryable codes and billing rules in your provider's documentation.
RETRYABLE_STATUSES = {429, 500, 502, 503, 504}


def retry_after_seconds(value):
    if not value:
        return None
    try:
        return max(0.0, float(value))
    except ValueError:
        try:
            retry_at = email.utils.parsedate_to_datetime(value)
            if retry_at.tzinfo is None:
                retry_at = retry_at.replace(tzinfo=timezone.utc)
            return max(0.0, (retry_at - datetime.now(timezone.utc)).total_seconds())
        except (TypeError, ValueError, OverflowError):
            return None


def capture():
    started = time.monotonic()
    for attempt in range(1, MAX_ATTEMPTS + 1):
        remaining = TOTAL_DEADLINE_SECONDS - (time.monotonic() - started)
        if remaining <= 0:
            raise TimeoutError(f"Operation {OPERATION_ID} exceeded its retry deadline")
        try:
            response = requests.get(
                API_URL,
                params={"access_key": API_KEY, "url": TARGET_URL},
                # Keep each attempt inside the remaining operation deadline.
                timeout=min(20, remaining),
            )
        except (requests.Timeout, requests.ConnectionError):
            # A timeout after submission is ambiguous. Retry only if your
            # endpoint supports idempotency or you accept possible duplicate work.
            if attempt == MAX_ATTEMPTS:
                raise
            delay = min(MAX_DELAY_SECONDS, BASE_DELAY_SECONDS * (2 ** (attempt - 1)))
            delay = random.uniform(delay / 2, delay)
        else:
            if response.ok:
                # Validate content type and body before treating this as an image.
                content_type = response.headers.get("Content-Type", "")
                if not content_type.startswith("image/"):
                    raise RuntimeError(
                        f"Unexpected success content type: {content_type}; inspect provider response"
                    )
                with open("shot.webp", "wb") as output:
                    output.write(response.content)
                return

            # Do not retry auth, validation, or quota errors by default.
            if response.status_code not in RETRYABLE_STATUSES or attempt == MAX_ATTEMPTS:
                response.raise_for_status()

            server_delay = retry_after_seconds(response.headers.get("Retry-After"))
            if response.status_code == 429 and server_delay is not None:
                delay = server_delay
            else:
                delay = min(MAX_DELAY_SECONDS, BASE_DELAY_SECONDS * (2 ** (attempt - 1)))
                delay = random.uniform(delay / 2, delay)

        remaining = TOTAL_DEADLINE_SECONDS - (time.monotonic() - started)
        if delay >= remaining:
            raise TimeoutError(f"No retry fits within deadline for operation {OPERATION_ID}")
        time.sleep(delay)


capture()

Important: this example has no idempotency protection unless you add the provider’s documented key mechanism. On an ambiguous timeout, the retry can duplicate a completed capture. Persisting an operation ID in your application helps reconciliation, but by itself it does not make the remote API idempotent.

5. cURL, Node.js, and request handling notes

A plain cURL command makes one request; it cannot safely implement provider-specific idempotency without knowing the supported header or parameter. This example writes the response to a file. Inspect the status and response headers in your integration before deciding to retry.

curl --fail-with-body -G "https://api.example.com/screenshot" \
  -H "Authorization: Bearer $SCREENSHOT_API_KEY" \
  -H "Idempotency-Key: $CAPTURE_OPERATION_ID" \
  --data-urlencode "url=https://example.com" \
  --max-time 30 \
  -D response-headers.txt \
  -o shot.png

Replace the endpoint, authentication, and idempotency header with the provider’s documented contract. Remove the idempotency header if unsupported; sending an ignored header provides no protection. --fail-with-body and -D help preserve error evidence for inspection, while --max-time bounds the client wait. A bounded client timeout still leaves the remote outcome ambiguous.

Here is a Node.js request with an explicit timeout and the same operation key. It is a single attempt; put it inside a retry controller only after classifying the provider’s responses.

const controller = new AbortController();
const timer = setTimeout(() => controller.abort(), 30_000);
const operationKey = process.env.CAPTURE_OPERATION_ID;

try {
  const params = new URLSearchParams({
    access_key: process.env.SCREENSHOT_API_KEY,
    url: 'https://example.com',
  });
  const response = await fetch(
    `https://api.example.com/screenshot?${params}`,
    {
      signal: controller.signal,
      headers: {
        // Keep only if this exact endpoint documents this header.
        'Idempotency-Key': operationKey,
      },
    },
  );
  if (!response.ok) {
    const body = await response.text();
    throw new Error(`Screenshot API ${response.status}: ${body}`);
  }
  const bytes = Buffer.from(await response.arrayBuffer());
  await import('node:fs/promises').then(fs => fs.writeFile('shot.png', bytes));
} finally {
  clearTimeout(timer);
}

For production use, validate the response content type and provider error body, and persist enough information to reconcile interrupted operations. Do not put secrets in query strings if the provider supports safer header authentication; query strings can appear in logs.

6. Provider contract and billing checklist

  • Does the specific capture endpoint support idempotency? What key format, scope, retention window, and matching-payload rules apply?
  • Which HTTP statuses and structured error codes are retryable? Are 502, 503, and render failures treated differently?
  • Does the server send Retry-After, and does it use seconds or an HTTP date?
  • Do failed renders consume quota or incur a charge? Are reserved units released?
  • How are successful captures, cache hits, and idempotency replays counted?
  • Can you query a request ID, asynchronous job ID, usage record, or status endpoint after a timeout?
  • What rate and concurrency limits apply, and are they separate from monthly quota?
  • Does your SDK or infrastructure retry automatically?

Billing differs across services. Screenshot API documents refunds for failed renders. ScreenshotEngine says failed requests do not count against its successful-capture allowance, while warning that a retry following a successful capture can count again. These are provider-specific policies, not general rules.

7. Troubleshooting

Symptom Likely cause Fix
Two successful captures after one timeout The first render completed after the client stopped waiting; the retry was a new operation. Use documented idempotency with the same key, or reconcile the first request before issuing another capture.
429 repeats immediately The client ignores Retry-After, retries concurrently, or exceeds a separate rate limit. Wait the indicated time, lower concurrency, and keep a finite retry budget.
Retries continue on invalid URL or selector The code retries every non-success response without inspecting the error. Parse status and provider error code; correct the input and stop retrying unchanged validation failures.
503 never clears The service is busy, unavailable, or the status has a provider-specific meaning. Check current provider guidance, respect any retry timing, and stop when the attempt or deadline budget is exhausted.
Quota drops despite failed calls The provider counts attempts, reserves units, or defines failure differently than expected. Check the billing contract and usage records; do not assume an HTTP failure is free.
Retry loop makes far more attempts than configured Retries are layered across the SDK, HTTP library, proxy, and queue. Choose one retry owner or account for every layer in the total attempt budget.
Retry-After parsing causes a very long sleep The server supplied a long valid delay or an HTTP date far in the future. Honor provider instructions within your operation deadline; defer the job or return control instead of sleeping beyond your service budget.
Image file contains JSON or HTML The API returned an error body or a non-image success response. Check status, content type, and structured error payload before saving bytes as an image.

8. Performance, reliability, and cost

  • Latency: retries add the delay plus another render time. Keep a total deadline aligned with the calling service’s own timeout and return an unknown or pending result when reconciliation is needed.
  • Load: jitter spreads retries from concurrent workers so they do not all hit the API together. Reduce concurrency after rate limiting instead of increasing it.
  • Reliability: idempotency protects against duplicate execution only within the provider’s documented scope and retention period. Durable operation records make reconciliation possible across process restarts.
  • Cost: count attempts and successful operations separately in your telemetry. Confirm whether failures, cache hits, replays, and successful captures are billable before estimating retry cost.
  • Security: keep keys out of logs and public URLs where possible. Log a key fingerprint or operation ID rather than the secret key itself.

9. Or skip the browser setup

For a one-call screenshot API, ScreenshotNeo returns PNG, JPEG, WebP, or PDF from a GET request. See the ScreenshotNeo API documentation for request options and response behavior.

curl -G "https://api.screenshotneo.com/v1/shot" -d access_key=YOUR_API_KEY --data-urlencode url=https://stripe.com -o shot.webp
import requests
r = requests.get("https://api.screenshotneo.com/v1/shot", params={"access_key": "YOUR_API_KEY", "url": "https://stripe.com"}, timeout=90)
open("shot.webp", "wb").write(r.content)
const q = new URLSearchParams({ access_key: 'YOUR_API_KEY', url: 'https://stripe.com' });
const res = await fetch(`https://api.screenshotneo.com/v1/shot?${q}`);

ScreenshotNeo accepts cookie and consent banners like a visitor and removes more than 60 known consent platforms, newsletter popups, and chat widgets before the shot; each cleanup step can be turned off. Bot checks, blank pages, failed loads, timeouts, and cache hits are not billed, and response headers report the page verdict and billing status. Its MCP server lets AI agents use take_screenshot, get_page_info, and capture_pdf. The free plan includes 1,000 screenshots each month with no card; paid plans start at $5 for 3,000 screenshots.

Sign up free for 1,000 screenshots a month, no card required.

10. FAQ

Does an HTTP timeout mean the screenshot failed?

No. It means the client did not receive a response in time. The server may still have completed the capture.

Should every 5xx response be retried?

No. Retry only statuses and error codes the provider documents as transient, with bounded attempts and a deadline.

Can I create a fresh idempotency key for each attempt?

No. That makes each attempt look like a separate operation. Reuse one key for retries of one logical capture.

Are failed screenshot requests always free?

No. Quota and billing treatment vary by provider and by outcome. Check the endpoint’s current terms and usage records.

What if there is no idempotency support or status endpoint?

After an ambiguous timeout, a second request can duplicate the work. Use available request IDs and usage records to investigate, or surface the outcome as unknown before deciding whether to create a new capture.