Cases / #41 · 2026-10-13 · Easy
A scrape broke: find out why from its session recording
A scraper dies with a timeout. Its session was recorded anyway: fetch the video by session id and see the page it failed on, with no tracing set up in advance.
Run on production on 2026-10-13: ✓ Node.js ✓ Python ✓ Java ✓ C# ✓ Go
The problem
A scraper that ran fine for weeks fails with "Timeout 5000ms exceeded" and nothing else. Was the selector wrong, did the site change, was there a captcha, a cookie banner, an error page? Playwright tracing would tell you — if you had turned it on before the run. cdpfleet records sessions without any setup: headful sessions are recorded from their display by default, so the video of the failed run already exists. Here a scraper breaks on purpose, and we fetch, check and delete its recording through the API.
What we used, and why
| What | Why |
|---|---|
"headless": false | Headful, the default. Headful sessions are recorded from the browser's display; headless ones only with cdp or live_view. |
viewport: null | The page fills the whole display instead of a 1280×720 window on it — so it fills the video too (Python no_viewport=True, Java setViewportSize(null), C# ViewportSize.NoViewport, Go NoViewport). |
| quotes.toscrape.com | A public practice site with ten listing pages. The scraper starts on page 8 and assumes every page has a "Next" link — page 10, the last one, doesn't. |
GET /v1/me/recordings | Lists the account's recordings with their session_id, duration, size and resolution. A recording appears once the node has uploaded it, after the session ended. |
GET /v1/me/recordings/{id}/url | A short-lived private link to the MP4. DELETE /v1/me/recordings/{id} deletes it. |
"record": false | A second session launched with recording off, to show it gets no video. |
How it works
- Launch a session with
"record": false, load the site, close it. - Launch the scraper's session (headful, recorded) and keep its
sessionId. From page 8, collect the quotes and follow the "Next" link — 5 seconds to find it, then load its page — until the link can't be found; note the time since launch, the URL and what the page has. - Close the browser, which ends the session. Poll
GET /v1/me/recordingsuntil a recording with thatsession_idappears. - Get its link, download the MP4, compare its duration with the moment of failure, then delete it.
- Wait another minute and check that the
record: falsesession never got a recording.
The code
The same program in five languages (also on GitHub, with the raw output). Set these environment variables first:
CDPFLEET_API_KEY— your API key (dashboard → API keys)PROXY_URL— your proxy, e.g.http://user:[email protected]:8000
// npm install [email protected]
// env: CDPFLEET_API_KEY, PROXY_URL
import { chromium } from 'playwright';
const KEY = process.env.CDPFLEET_API_KEY;
const SITE = 'https://quotes.toscrape.com';
const NEXT = 'li.next a'; // the scraper assumes every listing page has a "Next" link
async function api(method, path) {
const res = await fetch(`https://cdpfleet.com${path}`, { method, headers: { 'x-api-key': KEY } });
return { status: res.status, body: await res.json().catch(() => ({})) };
}
// Headful (the default) sessions are recorded from the browser's display.
async function launch(extra = {}) {
const res = await fetch('https://starter.cdpfleet.com/chromium/session', {
method: 'POST',
headers: { 'x-api-key': KEY, 'content-type': 'application/json' },
body: JSON.stringify({ proxy: process.env.PROXY_URL, headless: false, ...extra }),
});
if (!res.ok) throw new Error(`launch: ${res.status} ${await res.text()}`);
const { sessionId, wsUrl } = await res.json();
const started = Date.now();
const browser = await chromium.connect(wsUrl, { headers: { 'x-api-key': KEY } });
// viewport: null — the page fills the whole display, so it fills the recording too.
const page = await (await browser.newContext({ viewport: null })).newPage();
return { sessionId, started, browser, page };
}
// Proxies drop a tunnel now and then: up to two retries.
async function go(page, url) {
for (let attempt = 1; ; attempt++) {
try {
return await page.goto(url, { timeout: 60000 });
} catch (err) {
if (attempt === 3) throw err;
await new Promise((r) => setTimeout(r, 2000));
}
}
}
// The recording appears once the node has uploaded it, after the session ended.
async function waitForRecording(sessionId, maxSeconds) {
const t = Date.now();
while ((Date.now() - t) / 1000 < maxSeconds) {
const rec = (await api('GET', '/v1/me/recordings')).body.recordings?.find((r) => r.session_id === sessionId);
if (rec) return { rec, waited_s: Math.round((Date.now() - t) / 1000) };
await new Promise((r) => setTimeout(r, 5000));
}
return { rec: null, waited_s: Math.round((Date.now() - t) / 1000) };
}
const out = { site: SITE, scrape: {}, recording: {}, unrecorded: {} };
// A session launched with record: false, for comparison: it must never get a recording.
const quiet = await launch({ record: false });
try {
await go(quiet.page, SITE);
} finally {
await quiet.browser.close();
}
out.unrecorded.session_id = quiet.sessionId;
// The scrape: pages 8, 9, 10 — follow "Next" on each. Page 10 is the last one and has none.
const s = await launch();
out.session_id = s.sessionId;
const quotes = [];
try {
await go(s.page, `${SITE}/page/8/`);
for (let pageNo = 8; ; pageNo++) {
quotes.push(...await s.page.locator('.quote .text').allTextContents());
out.scrape.last_page = pageNo;
let href;
try {
href = await s.page.locator(NEXT).getAttribute('href', { timeout: 5000 }); // 5 s to find the link
} catch (err) {
out.scrape.failed_at_s = Math.round((Date.now() - s.started) / 100) / 10;
out.scrape.error = `${err.name === 'TimeoutError' ? 'timeout' : err.name} waiting for ${NEXT}`;
break;
}
await go(s.page, new URL(href, SITE).href); // page loads keep their own timeout and retries
}
out.scrape.quotes = quotes.length;
// What the video shows at that moment: the last page — a "Previous" link and no "Next".
out.scrape.url_at_failure = s.page.url();
out.scrape.next_links = await s.page.locator('li.next').count();
out.scrape.previous_links = await s.page.locator('li.previous').count();
} finally {
await s.browser.close(); // ends the session; the node uploads the video afterwards
}
out.session_seconds = Math.round((Date.now() - s.started) / 100) / 10;
// Find the recording, check it covers the failure, download it, delete it.
const { rec, waited_s: waited } = await waitForRecording(s.sessionId, 600);
if (rec) {
const link = (await api('GET', `/v1/me/recordings/${rec.id}/url`)).body;
const video = await fetch(link.url);
const bytes = Buffer.from(await video.arrayBuffer());
out.recording = {
appeared_after_s: waited,
duration_seconds: rec.duration_seconds,
width: rec.width,
height: rec.height,
bytes: rec.bytes,
downloaded_bytes: bytes.length,
content_type: video.headers.get('content-type'),
mp4: bytes.subarray(4, 8).toString() === 'ftyp',
link_expires_in_s: link.expires_in_s,
};
out.failure_within_recording = out.scrape.failed_at_s <= rec.duration_seconds + 1;
out.recording.deleted = (await api('DELETE', `/v1/me/recordings/${rec.id}`)).status === 200;
}
// By now the scrape's video has been uploaded; give the unrecorded session another minute.
await new Promise((r) => setTimeout(r, 60000));
const list = (await api('GET', '/v1/me/recordings')).body.recordings || [];
out.unrecorded.recording_found = list.some((r) => r.session_id === quiet.sessionId);
console.log(JSON.stringify(out, null, 2));
# pip install playwright==1.60.0 requests
# env: CDPFLEET_API_KEY, PROXY_URL
import json
import os
import time
from urllib.parse import urljoin
import requests
from playwright.sync_api import Error as PlaywrightError, TimeoutError as PlaywrightTimeout, sync_playwright
KEY = os.environ["CDPFLEET_API_KEY"]
SITE = "https://quotes.toscrape.com"
NEXT = "li.next a" # the scraper assumes every listing page has a "Next" link
def api(method, path):
res = requests.request(method, f"https://cdpfleet.com{path}", headers={"x-api-key": KEY}, timeout=30)
try:
body = res.json()
except ValueError:
body = {}
return res.status_code, body
# Headful (the default) sessions are recorded from the browser's display.
def launch(p, **extra):
res = requests.post(
"https://starter.cdpfleet.com/chromium/session",
headers={"x-api-key": KEY},
json={"proxy": os.environ["PROXY_URL"], "headless": False, **extra},
timeout=60,
)
if not res.ok:
raise RuntimeError(f"launch: {res.status_code} {res.text}")
body = res.json()
started = time.time()
browser = p.chromium.connect(body["wsUrl"], headers={"x-api-key": KEY})
# no_viewport: the page fills the whole display, so it fills the recording too.
page = browser.new_context(no_viewport=True).new_page()
return body["sessionId"], started, browser, page
# Proxies drop a tunnel now and then: up to two retries.
def go(page, url):
for attempt in range(1, 4):
try:
return page.goto(url, timeout=60000)
except PlaywrightError:
if attempt == 3:
raise
time.sleep(2)
# The recording appears once the node has uploaded it, after the session ended.
def wait_for_recording(session_id, max_seconds):
t = time.time()
while time.time() - t < max_seconds:
recs = api("GET", "/v1/me/recordings")[1].get("recordings") or []
rec = next((r for r in recs if r["session_id"] == session_id), None)
if rec:
return rec, round(time.time() - t)
time.sleep(5)
return None, round(time.time() - t)
out = {"site": SITE, "scrape": {}, "recording": {}, "unrecorded": {}}
with sync_playwright() as p:
# A session launched with record: false, for comparison: it must never get a recording.
quiet_id, _, quiet_browser, quiet_page = launch(p, record=False)
try:
go(quiet_page, SITE)
finally:
quiet_browser.close()
out["unrecorded"]["session_id"] = quiet_id
# The scrape: pages 8, 9, 10 — follow "Next" on each. Page 10 is the last one and has none.
session_id, started, browser, page = launch(p)
out["session_id"] = session_id
quotes = []
try:
go(page, f"{SITE}/page/8/")
page_no = 8
while True:
quotes += page.locator(".quote .text").all_text_contents()
out["scrape"]["last_page"] = page_no
try:
href = page.locator(NEXT).get_attribute("href", timeout=5000) # 5 s to find the link
except PlaywrightError as err:
out["scrape"]["failed_at_s"] = round(time.time() - started, 1)
kind = "timeout" if isinstance(err, PlaywrightTimeout) else type(err).__name__
out["scrape"]["error"] = f"{kind} waiting for {NEXT}"
break
go(page, urljoin(SITE, href)) # page loads keep their own timeout and retries
page_no += 1
out["scrape"]["quotes"] = len(quotes)
# What the video shows at that moment: the last page — a "Previous" link and no "Next".
out["scrape"]["url_at_failure"] = page.url
out["scrape"]["next_links"] = page.locator("li.next").count()
out["scrape"]["previous_links"] = page.locator("li.previous").count()
finally:
browser.close() # ends the session; the node uploads the video afterwards
out["session_seconds"] = round(time.time() - started, 1)
# Find the recording, check it covers the failure, download it, delete it.
rec, waited = wait_for_recording(session_id, 600)
if rec:
link = api("GET", f"/v1/me/recordings/{rec['id']}/url")[1]
video = requests.get(link["url"], timeout=60)
out["recording"] = {
"appeared_after_s": waited,
"duration_seconds": rec["duration_seconds"],
"width": rec["width"],
"height": rec["height"],
"bytes": rec["bytes"],
"downloaded_bytes": len(video.content),
"content_type": video.headers.get("content-type"),
"mp4": video.content[4:8] == b"ftyp",
"link_expires_in_s": link["expires_in_s"],
}
out["failure_within_recording"] = out["scrape"]["failed_at_s"] <= rec["duration_seconds"] + 1
out["recording"]["deleted"] = api("DELETE", f"/v1/me/recordings/{rec['id']}")[0] == 200
# By now the scrape's video has been uploaded; give the unrecorded session another minute.
time.sleep(60)
recs = api("GET", "/v1/me/recordings")[1].get("recordings") or []
out["unrecorded"]["recording_found"] = any(r["session_id"] == quiet_id for r in recs)
print(json.dumps(out, indent=2))
// Maven: com.microsoft.playwright:playwright:1.60.0, com.google.code.gson:gson:2.11.0
// Run with PLAYWRIGHT_SKIP_BROWSER_DOWNLOAD=1. env: CDPFLEET_API_KEY, PROXY_URL
import com.google.gson.*;
import com.microsoft.playwright.*;
import java.net.URI;
import java.net.http.*;
import java.util.*;
public class Main {
static final String KEY = System.getenv("CDPFLEET_API_KEY");
static final String SITE = "https://quotes.toscrape.com";
static final String NEXT = "li.next a"; // the scraper assumes every listing page has a "Next" link
static final HttpClient HTTP = HttpClient.newHttpClient();
static double since(long startedMs) { return Math.round((System.currentTimeMillis() - startedMs) / 100.0) / 10.0; }
static HttpResponse<String> api(String method, String path) throws Exception {
return HTTP.send(HttpRequest.newBuilder(URI.create("https://cdpfleet.com" + path)).header("x-api-key", KEY)
.method(method, HttpRequest.BodyPublishers.noBody()).build(), HttpResponse.BodyHandlers.ofString());
}
static JsonArray recordings() throws Exception {
JsonElement list = JsonParser.parseString(api("GET", "/v1/me/recordings").body()).getAsJsonObject().get("recordings");
return list == null || list.isJsonNull() ? new JsonArray() : list.getAsJsonArray();
}
record Session(String id, long started, Browser browser, Page page) {}
// Headful (the default) sessions are recorded from the browser's display.
static Session launch(Playwright playwright, Boolean record) throws Exception {
JsonObject req = new JsonObject();
req.addProperty("proxy", System.getenv("PROXY_URL"));
req.addProperty("headless", false);
if (record != null) req.addProperty("record", record);
HttpResponse<String> res = HTTP.send(HttpRequest.newBuilder(URI.create("https://starter.cdpfleet.com/chromium/session"))
.header("x-api-key", KEY).header("content-type", "application/json")
.POST(HttpRequest.BodyPublishers.ofString(req.toString())).build(), HttpResponse.BodyHandlers.ofString());
if (res.statusCode() != 200) throw new RuntimeException("launch: " + res.statusCode() + " " + res.body());
JsonObject body = JsonParser.parseString(res.body()).getAsJsonObject();
long started = System.currentTimeMillis();
Browser browser = playwright.chromium().connect(body.get("wsUrl").getAsString(),
new BrowserType.ConnectOptions().setHeaders(Map.of("x-api-key", KEY)));
// setViewportSize(null): the page fills the whole display, so it fills the recording too.
Page page = browser.newContext(new Browser.NewContextOptions().setViewportSize(null)).newPage();
return new Session(body.get("sessionId").getAsString(), started, browser, page);
}
// Proxies drop a tunnel now and then: up to two retries.
static void go(Page page, String url) throws Exception {
for (int attempt = 1; ; attempt++) {
try {
page.navigate(url, new Page.NavigateOptions().setTimeout(60000));
return;
} catch (PlaywrightException e) {
if (attempt == 3) throw e;
Thread.sleep(2000);
}
}
}
public static void main(String[] args) throws Exception {
JsonObject out = new JsonObject();
out.addProperty("site", SITE);
JsonObject scrape = new JsonObject(), recording = new JsonObject(), unrecorded = new JsonObject();
out.add("scrape", scrape);
out.add("recording", recording);
out.add("unrecorded", unrecorded);
String sessionId;
try (Playwright playwright = Playwright.create()) {
// A session launched with record: false, for comparison: it must never get a recording.
Session quiet = launch(playwright, false);
try {
go(quiet.page(), SITE);
} finally {
quiet.browser().close();
}
unrecorded.addProperty("session_id", quiet.id());
// The scrape: pages 8, 9, 10 — follow "Next" on each. Page 10 is the last one and has none.
Session s = launch(playwright, null);
sessionId = s.id();
out.addProperty("session_id", sessionId);
List<String> quotes = new ArrayList<>();
try {
go(s.page(), SITE + "/page/8/");
for (int pageNo = 8; ; pageNo++) {
quotes.addAll(s.page().locator(".quote .text").allTextContents());
scrape.addProperty("last_page", pageNo);
String href;
try {
href = s.page().locator(NEXT).getAttribute("href", new Locator.GetAttributeOptions().setTimeout(5000)); // 5 s to find the link
} catch (PlaywrightException e) {
scrape.addProperty("failed_at_s", since(s.started()));
scrape.addProperty("error", (e instanceof TimeoutError ? "timeout" : e.getClass().getSimpleName()) + " waiting for " + NEXT);
break;
}
go(s.page(), URI.create(SITE).resolve(href).toString()); // page loads keep their own timeout and retries
}
scrape.addProperty("quotes", quotes.size());
// What the video shows at that moment: the last page — a "Previous" link and no "Next".
scrape.addProperty("url_at_failure", s.page().url());
scrape.addProperty("next_links", s.page().locator("li.next").count());
scrape.addProperty("previous_links", s.page().locator("li.previous").count());
} finally {
s.browser().close(); // ends the session; the node uploads the video afterwards
}
out.addProperty("session_seconds", since(s.started()));
}
// Find the recording, check it covers the failure, download it, delete it.
long t = System.currentTimeMillis();
JsonObject rec = null;
while (rec == null && System.currentTimeMillis() - t < 600_000) {
for (JsonElement r : recordings()) {
if (r.getAsJsonObject().get("session_id").getAsString().equals(sessionId)) rec = r.getAsJsonObject();
}
if (rec == null) Thread.sleep(5000);
}
if (rec != null) {
recording.addProperty("appeared_after_s", Math.round((System.currentTimeMillis() - t) / 1000.0));
String id = rec.get("id").getAsString();
JsonObject link = JsonParser.parseString(api("GET", "/v1/me/recordings/" + id + "/url").body()).getAsJsonObject();
HttpResponse<byte[]> video = HTTP.send(HttpRequest.newBuilder(URI.create(link.get("url").getAsString())).build(),
HttpResponse.BodyHandlers.ofByteArray());
byte[] bytes = video.body();
for (String k : List.of("duration_seconds", "width", "height", "bytes")) recording.add(k, rec.get(k));
recording.addProperty("downloaded_bytes", bytes.length);
recording.addProperty("content_type", video.headers().firstValue("content-type").orElse(null));
recording.addProperty("mp4", bytes.length > 8 && new String(bytes, 4, 4).equals("ftyp"));
recording.add("link_expires_in_s", link.get("expires_in_s"));
out.addProperty("failure_within_recording", scrape.get("failed_at_s").getAsDouble() <= rec.get("duration_seconds").getAsDouble() + 1);
recording.addProperty("deleted", api("DELETE", "/v1/me/recordings/" + id).statusCode() == 200);
}
// By now the scrape's video has been uploaded; give the unrecorded session another minute.
Thread.sleep(60_000);
boolean found = false;
for (JsonElement r : recordings()) {
if (r.getAsJsonObject().get("session_id").getAsString().equals(unrecorded.get("session_id").getAsString())) found = true;
}
unrecorded.addProperty("recording_found", found);
System.out.println(new GsonBuilder().setPrettyPrinting().create().toJson(out));
}
}
// dotnet add package Microsoft.Playwright --version 1.60.0
// env: CDPFLEET_API_KEY, PROXY_URL
using System.Net.Http.Json;
using System.Text.Encodings.Web;
using System.Text.Json;
using System.Text.Json.Nodes;
using Microsoft.Playwright;
const string Site = "https://quotes.toscrape.com";
const string Next = "li.next a"; // the scraper assumes every listing page has a "Next" link
var key = Environment.GetEnvironmentVariable("CDPFLEET_API_KEY")!;
using var http = new HttpClient();
http.DefaultRequestHeaders.Add("x-api-key", key);
var output = new JsonObject { ["site"] = Site, ["scrape"] = new JsonObject(), ["recording"] = new JsonObject(), ["unrecorded"] = new JsonObject() };
var scrape = output["scrape"]!.AsObject();
var recording = output["recording"]!.AsObject();
var unrecorded = output["unrecorded"]!.AsObject();
string sessionId;
using (var playwright = await Playwright.CreateAsync())
{
// A session launched with record: false, for comparison: it must never get a recording.
var quiet = await Launch(playwright, false);
try { await Go(quiet.Page, Site); }
finally { await quiet.Browser.CloseAsync(); }
unrecorded["session_id"] = quiet.Id;
// The scrape: pages 8, 9, 10 — follow "Next" on each. Page 10 is the last one and has none.
var s = await Launch(playwright, null);
sessionId = s.Id;
output["session_id"] = sessionId;
var quotes = new List<string>();
try
{
await Go(s.Page, $"{Site}/page/8/");
for (var pageNo = 8; ; pageNo++)
{
quotes.AddRange(await s.Page.Locator(".quote .text").AllTextContentsAsync());
scrape["last_page"] = pageNo;
string? href;
try
{
href = await s.Page.Locator(Next).GetAttributeAsync("href", new() { Timeout = 5000 }); // 5 s to find the link
}
catch (Exception e) when (e is PlaywrightException or TimeoutException)
{
scrape["failed_at_s"] = Since(s.Started);
scrape["error"] = $"{(e.GetType().Name == "TimeoutException" ? "timeout" : e.GetType().Name)} waiting for {Next}";
break;
}
await Go(s.Page, new Uri(new Uri(Site), href).ToString()); // page loads keep their own timeout and retries
}
scrape["quotes"] = quotes.Count;
// What the video shows at that moment: the last page — a "Previous" link and no "Next".
scrape["url_at_failure"] = s.Page.Url;
scrape["next_links"] = await s.Page.Locator("li.next").CountAsync();
scrape["previous_links"] = await s.Page.Locator("li.previous").CountAsync();
}
finally
{
await s.Browser.CloseAsync(); // ends the session; the node uploads the video afterwards
}
output["session_seconds"] = Since(s.Started);
}
// Find the recording, check it covers the failure, download it, delete it.
var t = DateTime.UtcNow;
JsonObject? rec = null;
while (rec is null && (DateTime.UtcNow - t).TotalSeconds < 600)
{
rec = (await Recordings()).FirstOrDefault(r => (string?)r!["session_id"] == sessionId)?.AsObject();
if (rec is null) await Task.Delay(5000);
}
if (rec is not null)
{
recording["appeared_after_s"] = (long)Math.Round((DateTime.UtcNow - t).TotalSeconds);
var id = (string)rec["id"]!;
var link = (await http.GetFromJsonAsync<JsonObject>($"https://cdpfleet.com/v1/me/recordings/{id}/url"))!;
using var plain = new HttpClient(); // the presigned link must not carry our API key
using var video = await plain.GetAsync((string)link["url"]!);
var bytes = await video.Content.ReadAsByteArrayAsync();
foreach (var k in new[] { "duration_seconds", "width", "height", "bytes" }) recording[k] = rec[k]!.DeepClone();
recording["downloaded_bytes"] = bytes.Length;
recording["content_type"] = video.Content.Headers.ContentType?.MediaType;
recording["mp4"] = bytes.Length > 8 && System.Text.Encoding.ASCII.GetString(bytes, 4, 4) == "ftyp";
recording["link_expires_in_s"] = link["expires_in_s"]!.DeepClone();
output["failure_within_recording"] = (double)scrape["failed_at_s"]! <= (double)rec["duration_seconds"]! + 1;
recording["deleted"] = (await http.DeleteAsync($"https://cdpfleet.com/v1/me/recordings/{id}")).IsSuccessStatusCode;
}
// By now the scrape's video has been uploaded; give the unrecorded session another minute.
await Task.Delay(60000);
unrecorded["recording_found"] = (await Recordings()).Any(r => (string?)r!["session_id"] == (string)unrecorded["session_id"]!);
Console.WriteLine(output.ToJsonString(new JsonSerializerOptions { WriteIndented = true, Encoder = JavaScriptEncoder.UnsafeRelaxedJsonEscaping }));
async Task<JsonArray> Recordings() =>
(await http.GetFromJsonAsync<JsonObject>("https://cdpfleet.com/v1/me/recordings"))?["recordings"]?.AsArray() ?? [];
// Headful (the default) sessions are recorded from the browser's display.
async Task<Session> Launch(IPlaywright playwright, bool? record)
{
var body = new JsonObject { ["proxy"] = Environment.GetEnvironmentVariable("PROXY_URL"), ["headless"] = false };
if (record is not null) body["record"] = record;
var res = await http.PostAsJsonAsync("https://starter.cdpfleet.com/chromium/session", body);
if (!res.IsSuccessStatusCode) throw new Exception($"launch: {(int)res.StatusCode} {await res.Content.ReadAsStringAsync()}");
var launched = (await res.Content.ReadFromJsonAsync<JsonObject>())!;
var started = DateTime.UtcNow;
var browser = await playwright.Chromium.ConnectAsync((string)launched["wsUrl"]!, new() { Headers = new Dictionary<string, string> { ["x-api-key"] = key } });
// NoViewport: the page fills the whole display, so it fills the recording too.
var page = await (await browser.NewContextAsync(new() { ViewportSize = ViewportSize.NoViewport })).NewPageAsync();
return new Session((string)launched["sessionId"]!, started, browser, page);
}
static double Since(DateTime started) => Math.Round((DateTime.UtcNow - started).TotalSeconds, 1);
// Proxies drop a tunnel now and then: up to two retries.
static async Task Go(IPage page, string url)
{
for (var attempt = 1; ; attempt++)
{
try { await page.GotoAsync(url, new() { Timeout = 60000 }); return; }
catch (Exception e) when (attempt < 3 && e is PlaywrightException or TimeoutException) { await Task.Delay(2000); }
}
}
record Session(string Id, DateTime Started, IBrowser Browser, IPage Page);
// go get github.com/playwright-community/[email protected]
// Driver: build playwright-core 1.60.0 from npm and set PLAYWRIGHT_DRIVER_PATH (see /docs/quickstart).
// env: CDPFLEET_API_KEY, PROXY_URL
package main
import (
"bytes"
"encoding/json"
"errors"
"fmt"
"io"
"log"
"math"
"net/http"
"net/url"
"os"
"time"
"github.com/playwright-community/playwright-go"
)
const site = "https://quotes.toscrape.com"
const next = "li.next a" // the scraper assumes every listing page has a "Next" link
var key = os.Getenv("CDPFLEET_API_KEY")
type Scrape struct {
LastPage int `json:"last_page"`
FailedAtS float64 `json:"failed_at_s"`
Error string `json:"error"`
Quotes int `json:"quotes"`
URLAtFailure string `json:"url_at_failure"`
NextLinks int `json:"next_links"`
PreviousLinks int `json:"previous_links"`
}
type Recording struct {
AppearedAfterS int64 `json:"appeared_after_s"`
DurationSeconds *float64 `json:"duration_seconds"`
Width *int `json:"width"`
Height *int `json:"height"`
Bytes *int64 `json:"bytes"`
DownloadedBytes int `json:"downloaded_bytes"`
ContentType string `json:"content_type"`
MP4 bool `json:"mp4"`
LinkExpiresInS int `json:"link_expires_in_s"`
Deleted bool `json:"deleted"`
}
type Unrecorded struct {
SessionID string `json:"session_id"`
RecordingFound bool `json:"recording_found"`
}
type Output struct {
Site string `json:"site"`
Scrape Scrape `json:"scrape"`
Recording *Recording `json:"recording"`
Unrecorded Unrecorded `json:"unrecorded"`
SessionID string `json:"session_id"`
SessionSeconds float64 `json:"session_seconds"`
FailureWithinRecording *bool `json:"failure_within_recording,omitempty"`
}
type apiRecording struct {
ID string `json:"id"`
SessionID string `json:"session_id"`
DurationSeconds *float64 `json:"duration_seconds"`
Width *int `json:"width"`
Height *int `json:"height"`
Bytes *int64 `json:"bytes"`
}
func api(method, path string, into any) (int, error) {
req, _ := http.NewRequest(method, "https://cdpfleet.com"+path, nil)
req.Header.Set("x-api-key", key)
res, err := http.DefaultClient.Do(req)
if err != nil {
return 0, err
}
defer res.Body.Close()
if into != nil {
_ = json.NewDecoder(res.Body).Decode(into)
}
return res.StatusCode, nil
}
func recordings() []apiRecording {
var body struct {
Recordings []apiRecording `json:"recordings"`
}
_, _ = api("GET", "/v1/me/recordings", &body)
return body.Recordings
}
func since(t time.Time) float64 { return math.Round(time.Since(t).Seconds()*10) / 10 }
type session struct {
id string
started time.Time
browser playwright.Browser
page playwright.Page
}
// Headful (the default) sessions are recorded from the browser's display.
func launch(pw *playwright.Playwright, record *bool) (*session, error) {
body := map[string]any{"proxy": os.Getenv("PROXY_URL"), "headless": false}
if record != nil {
body["record"] = *record
}
payload, _ := json.Marshal(body)
req, _ := http.NewRequest("POST", "https://starter.cdpfleet.com/chromium/session", bytes.NewReader(payload))
req.Header.Set("x-api-key", key)
req.Header.Set("content-type", "application/json")
res, err := http.DefaultClient.Do(req)
if err != nil {
return nil, err
}
defer res.Body.Close()
if res.StatusCode != 200 {
text, _ := io.ReadAll(res.Body)
return nil, fmt.Errorf("launch: %d %s", res.StatusCode, text)
}
var launched struct {
SessionID string `json:"sessionId"`
WsURL string `json:"wsUrl"`
}
if err := json.NewDecoder(res.Body).Decode(&launched); err != nil {
return nil, err
}
started := time.Now()
browser, err := pw.Chromium.Connect(launched.WsURL, playwright.BrowserTypeConnectOptions{Headers: map[string]string{"x-api-key": key}})
if err != nil {
return nil, err
}
// NoViewport: the page fills the whole display, so it fills the recording too.
context, err := browser.NewContext(playwright.BrowserNewContextOptions{NoViewport: playwright.Bool(true)})
if err != nil {
browser.Close()
return nil, err
}
page, err := context.NewPage()
if err != nil {
browser.Close()
return nil, err
}
return &session{launched.SessionID, started, browser, page}, nil
}
// Proxies drop a tunnel now and then: up to two retries.
func goTo(page playwright.Page, url string) error {
var err error
for attempt := 1; attempt <= 3; attempt++ {
if _, err = page.Goto(url, playwright.PageGotoOptions{Timeout: playwright.Float(60000)}); err == nil {
return nil
}
time.Sleep(2 * time.Second)
}
return err
}
func scrapeUntilItBreaks(s *session, out *Output) error {
defer s.browser.Close() // ends the session; the node uploads the video afterwards
if err := goTo(s.page, site+"/page/8/"); err != nil {
return err
}
quotes := 0
for pageNo := 8; ; pageNo++ {
texts, err := s.page.Locator(".quote .text").AllTextContents()
if err != nil {
return err
}
quotes += len(texts)
out.Scrape.LastPage = pageNo
href, err := s.page.Locator(next).GetAttribute("href", playwright.LocatorGetAttributeOptions{Timeout: playwright.Float(5000)}) // 5 s to find the link
if err != nil {
out.Scrape.FailedAtS = since(s.started)
kind := "error"
if errors.Is(err, playwright.ErrTimeout) {
kind = "timeout"
}
out.Scrape.Error = fmt.Sprintf("%s waiting for %s", kind, next)
break
}
base, _ := url.Parse(site)
ref, err := url.Parse(href)
if err != nil {
return err
}
if err := goTo(s.page, base.ResolveReference(ref).String()); err != nil { // page loads keep their own timeout and retries
return err
}
}
out.Scrape.Quotes = quotes
// What the video shows at that moment: the last page — a "Previous" link and no "Next".
out.Scrape.URLAtFailure = s.page.URL()
out.Scrape.NextLinks, _ = s.page.Locator("li.next").Count()
out.Scrape.PreviousLinks, _ = s.page.Locator("li.previous").Count()
return nil
}
func run(out *Output) error {
pw, err := playwright.Run(&playwright.RunOptions{SkipInstallBrowsers: true})
if err != nil {
return err
}
defer pw.Stop()
// A session launched with record: false, for comparison: it must never get a recording.
quiet, err := launch(pw, playwright.Bool(false))
if err != nil {
return err
}
err = goTo(quiet.page, site)
quiet.browser.Close()
if err != nil {
return err
}
out.Unrecorded.SessionID = quiet.id
// The scrape: pages 8, 9, 10 — follow "Next" on each. Page 10 is the last one and has none.
s, err := launch(pw, nil)
if err != nil {
return err
}
out.SessionID = s.id
if err := scrapeUntilItBreaks(s, out); err != nil {
return err
}
out.SessionSeconds = since(s.started)
// Find the recording, check it covers the failure, download it, delete it.
t := time.Now()
var rec *apiRecording
for rec == nil && time.Since(t) < 10*time.Minute {
for _, r := range recordings() {
if r.SessionID == s.id {
rec = &r
break
}
}
if rec == nil {
time.Sleep(5 * time.Second)
}
}
if rec != nil {
var link struct {
URL string `json:"url"`
ExpiresInS int `json:"expires_in_s"`
}
if _, err := api("GET", "/v1/me/recordings/"+rec.ID+"/url", &link); err != nil {
return err
}
res, err := http.Get(link.URL)
if err != nil {
return err
}
video, _ := io.ReadAll(res.Body)
res.Body.Close()
out.Recording = &Recording{
AppearedAfterS: int64(math.Round(time.Since(t).Seconds())),
DurationSeconds: rec.DurationSeconds,
Width: rec.Width,
Height: rec.Height,
Bytes: rec.Bytes,
DownloadedBytes: len(video),
ContentType: res.Header.Get("content-type"),
MP4: len(video) > 8 && string(video[4:8]) == "ftyp",
LinkExpiresInS: link.ExpiresInS,
}
within := rec.DurationSeconds != nil && out.Scrape.FailedAtS <= *rec.DurationSeconds+1
out.FailureWithinRecording = &within
status, _ := api("DELETE", "/v1/me/recordings/"+rec.ID, nil)
out.Recording.Deleted = status == 200
}
// By now the scrape's video has been uploaded; give the unrecorded session another minute.
time.Sleep(60 * time.Second)
for _, r := range recordings() {
if r.SessionID == quiet.id {
out.Unrecorded.RecordingFound = true
}
}
return nil
}
func main() {
out := Output{Site: site, Recording: &Recording{}}
if err := run(&out); err != nil {
log.Fatal(err)
}
b, _ := json.MarshalIndent(out, "", " ")
fmt.Println(string(b))
}
What we got
| Measured | |
|---|---|
| Scrape | 30 quotes from pages 8–10, then: timeout waiting for li.next a |
| Failed at | 10.4 s after launch, on https://quotes.toscrape.com/page/10/ (0 "Next" links, 1 "Previous") |
| Recording appeared | 5 s after the session ended |
| Video | 11 s, 854×480, 57443 bytes, video/mp4 |
| Failure inside the video | yes |
| record: false session | no recording |
From the Node.js run on 2026-10-13. IP addresses are replaced with placeholders (203.0.113.x); equal addresses stay equal. The other languages produced the same findings.
Raw output (Node.js)
{
"site": "https://quotes.toscrape.com",
"scrape": {
"last_page": 10,
"failed_at_s": 10.4,
"error": "timeout waiting for li.next a",
"quotes": 30,
"url_at_failure": "https://quotes.toscrape.com/page/10/",
"next_links": 0,
"previous_links": 1
},
"recording": {
"appeared_after_s": 5,
"duration_seconds": 11,
"width": 854,
"height": 480,
"bytes": 57443,
"downloaded_bytes": 57443,
"content_type": "video/mp4",
"mp4": true,
"link_expires_in_s": 900,
"deleted": true
},
"unrecorded": {
"session_id": "f3cdd8f0-40cf-4290-98ad-c3bb100b306d",
"recording_found": false
},
"session_id": "392bb039-23c2-4509-81c9-1bccbde164ed",
"session_seconds": 10.5,
"failure_within_recording": true
}Takeaways
- The error alone said only "timeout waiting for
li.next a". At that moment the browser was on/page/10/with a "Previous" link and no "Next" — the last page, not a broken selector. The failure came 10.4–13.2 s after launch, the browser closed a moment later, and the videos were 11–14 s long — so their last seconds were recorded with that page on screen. - No setup: the scraper's code does nothing about recording. Its headful session was recorded by default, and in every run the video was listed under its
session_idon the second check, 5 seconds after the session ended (the first, right after closing, found nothing yet). - What you get: an MP4 at 854×480 — the free tier's quality (plans add 720p and 1080p) — of 57–62 KB for these 11–14 s, behind a private link valid for 15 minutes.
DELETE /v1/me/recordings/{id}deletes it (it answered 200 in every run). "record": falsereally is off: that session never got a recording, checked a minute after the other video had arrived.- Keep "find the element" and "load the page" on separate timeouts. Reading the "Next" link with a 5-second timeout and loading its page with the normal one means the error names what was missing; a click that also waits for a slow page load can time out mid-navigation and send you looking at the wrong page.