Error handling and retries

A screenshot request can fail for three different reasons: your request is wrong, your account cannot make it right now, or the target page did not cooperate. Only the last two are worth retrying, and they need different waits. This guide gives you the table to decide, and retry code you can copy. It belongs to the scale and reliability guides.

The error envelope

Every error response has a JSON body with the same shape, whatever the HTTP status. To reproduce the cases below with a real key, sign up for a free account.

The shape:

{
  "error": {
    "type": "rate_limit_error",
    "code": "too_many_requests",
    "message": "Rate limit exceeded. Please retry after 42 seconds.",
    "retryable": true
  },
  "request_id": "req_abc123"
}

Three fields drive your code:

  • error.retryable is the API's own answer to "can the same request succeed later?".
  • error.type groups the codes, and matches the HTTP status one to one.
  • request_id identifies the request. Log it. Support needs it to find the request in the request logs.

Branch on type and retryable, not on message. Messages are for people and can change.

Retryable or fatal

HTTP error.type Example codes retryable What to do
400 validation_error invalid_url, invalid_parameter, invalid_preset, missing_required false Fix the request. The param field names the bad parameter.
401 authentication_error missing_credentials, invalid_api_key, invalid_signature, expired_signature, revoked_key false Fix the key or the signature. Alert a person; do not loop.
403 authorization_error insufficient_credits, feature_not_available, domain_not_allowed false Upgrade, wait for the monthly reset, or change the request. Pause the queue.
404 not_found_error not_found false The batch, cache key or resource does not exist.
422 target_error timeout, navigation_failed, selector_not_found, ssl_error, blocked true Retry once or twice with backoff. Then change wait or capture settings.
429 rate_limit_error too_many_requests, concurrent_limit true Wait Retry-After seconds, then retry.
500 internal_error internal_error true Retry with exponential backoff.

Also retry when there is no HTTP response at all: a DNS failure, a reset connection, or your own client timeout.

Two rules prevent most production incidents:

  1. Never retry a 4xx other than 422 and 429. A wrong key or a missing parameter fails the same way every time, and a retry loop on 401 can hide a leaked or revoked key for hours.
  2. Cap target-error retries low. A page that times out twice in a row will usually time out a third time. Fix it with the wait strategies guide instead.

What a failure costs

A credit is used only when a render succeeds. Error responses, including 422 target errors, do not use credits. Retries are therefore cheap in credits but not in time: a target error can take up to wait.timeout seconds (30 by default, 60 at most) before it comes back.

The opposite case matters too. If the target site returns its own "Access denied" or CAPTCHA page, the browser renders that page successfully. You get HTTP 200, an image of the challenge, and a used credit. See bot protection and CAPTCHAs for how to spot and avoid this.

Rate limits and Retry-After

Rate limits count requests per API key in one-minute windows. A 429 response carries:

HTTP/1.1 429 Too Many Requests
X-RateLimit-Limit: 60
X-RateLimit-Remaining: 0
X-RateLimit-Reset: 1792040460
Retry-After: 42

Retry-After is the number of seconds until the window resets. Sleeping for that time is both the fastest and the politest retry. Successful responses also carry X-RateLimit-Limit and X-RateLimit-Remaining, so a queue worker can slow down before it hits the limit. The limits per plan are on the rate limits page.

Reference implementation: raw HTTP

These two versions use the API's retryable flag directly, honour Retry-After, and use exponential backoff with full jitter for everything else. Jitter spreads retries out, so a hundred workers that failed at the same moment do not all retry at the same moment.

Tested on 2026-10-15 with Node 22.18 and with Python 3.14 plus requests 2.32.3, against a local mock that returns the error envelope above (429 with Retry-After, 500, 422, 400, 401 and 503 cases). Set RS_BASE_URL only for such a mock; it defaults to the real API.

// screenshot-with-retry.mjs  (Node 22, no dependencies)
const API = process.env.RS_BASE_URL ?? 'https://api.renderscreenshot.com';
const sleep = (ms) => new Promise((r) => setTimeout(r, ms));

export class ScreenshotError extends Error {
  constructor(status, body) {
    super(body?.error?.message ?? `HTTP ${status}`);
    this.status = status;
    this.code = body?.error?.code;
    this.type = body?.error?.type;
    this.retryable = body?.error?.retryable ?? (status === 429 || status >= 500);
    this.requestId = body?.request_id;
  }
}

export async function screenshot(body, { maxAttempts = 4, baseDelayMs = 1000 } = {}) {
  for (let attempt = 1; ; attempt++) {
    let res;
    try {
      res = await fetch(`${API}/v1/screenshot`, {
        method: 'POST',
        headers: {
          Authorization: `Bearer ${process.env.RS_API_KEY}`,
          'Content-Type': 'application/json',
        },
        body: JSON.stringify(body),
        signal: AbortSignal.timeout(90_000), // longer than the 60 s render ceiling
      });
    } catch (networkError) {
      // DNS failure, reset connection or our own timeout: safe to retry.
      if (attempt >= maxAttempts) throw networkError;
      await sleep(backoff(attempt, baseDelayMs));
      continue;
    }

    if (res.ok) return Buffer.from(await res.arrayBuffer());

    const payload = await res.json().catch(() => null);
    const error = new ScreenshotError(res.status, payload);
    if (!error.retryable || attempt >= maxAttempts) throw error;

    const retryAfter = Number(res.headers.get('retry-after'));
    await sleep(retryAfter > 0 ? retryAfter * 1000 : backoff(attempt, baseDelayMs));
  }
}

// Exponential backoff with full jitter: 0..1 s, 0..2 s, 0..4 s, capped at 30 s.
function backoff(attempt, baseDelayMs) {
  return Math.random() * Math.min(30_000, baseDelayMs * 2 ** (attempt - 1));
}
# screenshot_with_retry.py  (Python 3.9+, requests)
import os
import random
import time

import requests

API = os.environ.get("RS_BASE_URL", "https://api.renderscreenshot.com")


class ScreenshotError(Exception):
    def __init__(self, status, payload):
        err = (payload or {}).get("error", {})
        super().__init__(err.get("message", f"HTTP {status}"))
        self.status = status
        self.code = err.get("code")
        self.retryable = err.get("retryable", status == 429 or status >= 500)
        self.request_id = (payload or {}).get("request_id")


def backoff(attempt, base=1.0):
    # Full jitter: 0..1 s, 0..2 s, 0..4 s, capped at 30 s
    return random.uniform(0, min(30.0, base * 2 ** (attempt - 1)))


def screenshot(body, max_attempts=4, base=1.0):
    headers = {"Authorization": f"Bearer {os.environ['RS_API_KEY']}"}
    for attempt in range(1, max_attempts + 1):
        try:
            res = requests.post(f"{API}/v1/screenshot", json=body, headers=headers, timeout=(10, 90))
        except (requests.ConnectionError, requests.Timeout):
            if attempt == max_attempts:
                raise
            time.sleep(backoff(attempt, base))
            continue

        if res.ok:
            return res.content

        try:
            payload = res.json()
        except ValueError:
            payload = None
        error = ScreenshotError(res.status_code, payload)
        if not error.retryable or attempt == max_attempts:
            raise error

        retry_after = res.headers.get("Retry-After", "")
        time.sleep(int(retry_after) if retry_after.isdigit() else backoff(attempt, base))

In the mock test, a 429 with Retry-After: 1 succeeded on the second attempt after about one second; a 500 followed by a 422 succeeded on the third attempt; 400 and 401 failed at once with no retry; and a permanent 503 gave up after four attempts.

Retry with the SDKs

The SDK errors carry the HTTP status and the Retry-After value. Decide with the status, using the same rule in every language: retry when there was no response, or the status is 408, 422, 429 or 5xx. This matches the retryable column above and also covers client-side timeouts. Each example tries three times.

Node.js

import { Client, TakeOptions, RenderScreenshotError } from 'renderscreenshot';

const client = new Client(process.env.RS_API_KEY, { timeout: 90000 });
const shouldRetry = (s) => !s || s === 408 || s === 422 || s === 429 || s >= 500;

export async function takeWithRetry(options, maxAttempts = 3) {
  for (let attempt = 1; ; attempt++) {
    try {
      return await client.take(options);
    } catch (error) {
      if (!(error instanceof RenderScreenshotError)) throw error;
      if (!shouldRetry(error.httpStatus) || attempt >= maxAttempts) throw error;
      const ms = error.retryAfter
        ? error.retryAfter * 1000
        : Math.random() * 1000 * 2 ** (attempt - 1);
      await new Promise((r) => setTimeout(r, ms));
    }
  }
}

const image = await takeWithRetry(TakeOptions.url('https://example.com'));

Python

import os
import random
import time
from renderscreenshot import Client, TakeOptions, RenderScreenshotError

client = Client(os.environ['RS_API_KEY'], timeout=90.0)


def should_retry(status):
    return not status or status in (408, 422, 429) or status >= 500


def take_with_retry(options, max_attempts=3):
    for attempt in range(1, max_attempts + 1):
        try:
            return client.take(options)
        except RenderScreenshotError as e:
            if not should_retry(e.http_status) or attempt == max_attempts:
                raise
            time.sleep(e.retry_after or random.uniform(0, 2 ** (attempt - 1)))


image = take_with_retry(TakeOptions.url('https://example.com'))

Ruby

require 'renderscreenshot'

CLIENT = RenderScreenshot.client(ENV.fetch('RS_API_KEY'), timeout: 90)

def should_retry?(status)
  status.nil? || status.zero? || [408, 422, 429].include?(status) || status >= 500
end

def take_with_retry(options, max_attempts: 3)
  attempt = 0
  begin
    attempt += 1
    CLIENT.take(options)
  rescue RenderScreenshot::Error => e
    raise if !should_retry?(e.http_status) || attempt >= max_attempts

    sleep(e.retry_after || rand * (2**(attempt - 1)))
    retry
  end
end

image = take_with_retry(RenderScreenshot::TakeOptions.url('https://example.com'))

PHP

use RenderScreenshot\Client;
use RenderScreenshot\TakeOptions;
use RenderScreenshot\RenderScreenshotException;

$client = new Client(getenv('RS_API_KEY'), null, 90.0);

function shouldRetry(int $status): bool
{
    return $status === 0 || in_array($status, [408, 422, 429], true) || $status >= 500;
}

function takeWithRetry(Client $client, TakeOptions $options, int $maxAttempts = 3): string
{
    for ($attempt = 1; ; $attempt++) {
        try {
            return $client->take($options);
        } catch (RenderScreenshotException $e) {
            if (!shouldRetry($e->httpStatus) || $attempt >= $maxAttempts) {
                throw $e;
            }
            $seconds = $e->retryAfter ?? (mt_rand() / mt_getrandmax()) * 2 ** ($attempt - 1);
            usleep((int) ($seconds * 1_000_000));
        }
    }
}

$image = takeWithRetry($client, TakeOptions::url('https://example.com'));

Java

import com.renderscreenshot.sdk.Client;
import com.renderscreenshot.sdk.RenderScreenshotException;
import com.renderscreenshot.sdk.TakeOptions;
import java.time.Duration;
import java.util.concurrent.ThreadLocalRandom;

public class Screenshots {
    private static final Client CLIENT = new Client(System.getenv("RS_API_KEY"),
        "https://api.renderscreenshot.com", Duration.ofSeconds(90));

    static boolean shouldRetry(int status) {
        return status == 0 || status == 408 || status == 422 || status == 429 || status >= 500;
    }

    static byte[] takeWithRetry(TakeOptions options, int maxAttempts) throws Exception {
        for (int attempt = 1; ; attempt++) {
            try {
                return CLIENT.take(options);
            } catch (RenderScreenshotException e) {
                if (!shouldRetry(e.getHttpStatus()) || attempt >= maxAttempts) throw e;
                long millis = e.getRetryAfter() != null
                    ? e.getRetryAfter() * 1000L
                    : ThreadLocalRandom.current().nextLong(1000L << (attempt - 1));
                Thread.sleep(millis);
            }
        }
    }
}

Go

func shouldRetry(status int) bool {
    return status == 0 || status == 408 || status == 422 || status == 429 || status >= 500
}

func takeWithRetry(ctx context.Context, client *rs.Client, opts *rs.TakeOptions, maxAttempts int) ([]byte, error) {
    for attempt := 1; ; attempt++ {
        image, err := client.Take(ctx, opts)
        if err == nil {
            return image, nil
        }
        var apiErr *rs.Error
        if !errors.As(err, &apiErr) || !shouldRetry(apiErr.HTTPStatus) || attempt >= maxAttempts {
            return nil, err
        }
        wait := time.Duration(rand.Int63n(int64(time.Second) << (attempt - 1)))
        if apiErr.RetryAfter > 0 {
            wait = time.Duration(apiErr.RetryAfter) * time.Second
        }
        select {
        case <-time.After(wait):
        case <-ctx.Done():
            return nil, ctx.Err()
        }
    }
}

Each client timeout above is 90 seconds. The SDK default is 30 seconds, the same as the default render timeout, so a capture that needs timeout: 45 would otherwise fail on your side while it still runs on ours.

Show useful messages to your own users

If your product takes screenshots on behalf of users, translate the error type, not the raw message:

error.type Message for your user
validation_error "That address does not look like a web page. Check the URL."
target_error "We could not load that page. It may be slow, offline or blocking automated browsers."
rate_limit_error, internal_error (after retries) "Screenshots are busy. We will try again shortly."
authentication_error, authorization_error Show a generic failure to the user and alert your own team.

Troubleshooting

Symptom Likely cause Fix
Every request returns 401 Key missing from the environment, or revoked Print the first 8 characters of the key at start-up; check the dashboard for revoked keys
Signed URLs return 401 invalid_signature Parameters changed or encoded differently after signing Sign the final query string; see publishable keys
Bursts of 429 at the top of each minute All workers start at the same time Add jitter, or read X-RateLimit-Remaining and slow down early
422 with a timeout message on one site only Page never reaches the wait condition Switch from networkidle to for_selector, or raise wait.timeout
Client times out but the dashboard shows success Client timeout shorter than the render Set the client timeout above wait.timeout
403 insufficient_credits in the middle of a batch Monthly credits used up Stop retrying; resume after the reset or upgrade on the pricing page

Related in this topic

Was this page helpful?