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.retryableis the API's own answer to "can the same request succeed later?".error.typegroups the codes, and matches the HTTP status one to one.request_ididentifies 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:
- 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.
- 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
- Error codes for the full code reference.
- Rate limits for the per-plan limits and headers.
- Screenshots in background jobs for retries inside Sidekiq, Celery and BullMQ.