TypeScript / JavaScript

Submit, poll, and retrieve from a trusted server-side JavaScript runtime.

Run this complete async scrape example with Bun. Node.js also works when configured with global fetch, Web Crypto, and TypeScript support. Set RUNHARVESTER_API_KEY in the process environment; the base URL defaults to https://api.runharvester.com/api/v1/data.

// Async scrape example. Requires Bun; Node.js with Web Crypto and fetch also works.
// Export RUNHARVESTER_API_KEY before running. Keep the printed key and body for a manual retry.
const API_BASE_URL_ENV = "RUNHARVESTER_API_BASE_URL";
const API_KEY_ENV = "RUNHARVESTER_API_KEY";
const IDEMPOTENCY_KEY_ENV = "RUNHARVESTER_IDEMPOTENCY_KEY";
const REQUEST_TIMEOUT_ENV = "RUNHARVESTER_REQUEST_TIMEOUT_MS";
const POLL_INTERVAL_ENV = "RUNHARVESTER_POLL_INTERVAL_MS";
const DEFAULT_API_BASE_URL = "https://api.runharvester.com/api/v1/data";
const DEFAULT_REQUEST_TIMEOUT_MS = 120000;
const DEFAULT_POLL_INTERVAL_MS = 1000;
const HTTP_TIMEOUT_MS = 15000;
const CREATE_ATTEMPT_LIMIT = 2;
const RETRY_DELAY_MS = 250;
const RETRYABLE_STATUS_CODES = new Set([502, 503, 504]);
const ACTIVE_STATUSES = new Set(["pending", "running", "solving_captcha"]);

const apiBaseUrl = (process.env[API_BASE_URL_ENV] ?? DEFAULT_API_BASE_URL).replace(/\/+$/, "");
const apiKey = process.env[API_KEY_ENV];
if (!apiKey) throw new Error("Set RUNHARVESTER_API_KEY before running this async request example.");

const idempotencyKey = process.env[IDEMPOTENCY_KEY_ENV] ?? crypto.randomUUID();
const requestBody = JSON.stringify({ url: "https://example.com", mode: "auto" });
const headers = {
  Accept: "application/json",
  "X-API-Key": apiKey,
  "Content-Type": "application/json",
  "Idempotency-Key": idempotencyKey,
};
console.log("Idempotency-Key:", idempotencyKey);

const requestTimeoutMs = Number(process.env[REQUEST_TIMEOUT_ENV] ?? DEFAULT_REQUEST_TIMEOUT_MS);
const pollIntervalMs = Number(process.env[POLL_INTERVAL_ENV] ?? DEFAULT_POLL_INTERVAL_MS);
if (!Number.isFinite(requestTimeoutMs) || requestTimeoutMs <= 0 || !Number.isFinite(pollIntervalMs) || pollIntervalMs <= 0) {
  throw new Error("Timeout and poll interval must be positive numbers in milliseconds.");
}
const deadline = Date.now() + requestTimeoutMs;

const delay = (milliseconds: number) => new Promise((resolve) => setTimeout(resolve, milliseconds));
const request = (url: string, init: RequestInit = {}) => {
  const remainingMs = deadline - Date.now();
  if (remainingMs <= 0) throw new Error("Request exceeded its deadline.");
  return fetch(url, {
    ...init,
    signal: AbortSignal.timeout(Math.max(1, Math.min(HTTP_TIMEOUT_MS, remainingMs))),
  });
};
const readJson = async (response: Response) => {
  const text = await response.text();
  let payload: unknown;
  try {
    payload = JSON.parse(text);
  } catch {
    payload = text;
  }
  if (!response.ok) throw new Error("HTTP " + response.status + ": " + JSON.stringify(payload));
  return payload;
};

let jobId = "";
for (let attempt = 1; attempt <= CREATE_ATTEMPT_LIMIT; attempt += 1) {
  let response: Response;
  try {
    response = await request(apiBaseUrl + "/requests", { method: "POST", headers, body: requestBody });
  } catch (error) {
    if (attempt === CREATE_ATTEMPT_LIMIT) throw error;
    await delay(Math.min(RETRY_DELAY_MS, Math.max(0, deadline - Date.now())));
    continue;
  }
  if (RETRYABLE_STATUS_CODES.has(response.status) && attempt < CREATE_ATTEMPT_LIMIT) {
    await response.body?.cancel();
    await delay(Math.min(RETRY_DELAY_MS, Math.max(0, deadline - Date.now())));
    continue;
  }
  const job = await readJson(response) as { id?: string };
  if (response.status !== 202 || !job.id) throw new Error("Create response did not include an accepted request id.");
  jobId = job.id;
  break;
}
if (!jobId) throw new Error("Could not create request.");

while (true) {
  const statusResponse = await request(apiBaseUrl + "/requests/" + encodeURIComponent(jobId), {
    headers: { Accept: "application/json", "X-API-Key": apiKey },
  });
  const statusPayload = await readJson(statusResponse) as { status?: string; error?: unknown; user_error?: unknown };
  const status = statusPayload.status;
  if (status === "completed") break;
  if (status === "failed" || status === "cancelled") {
    const error = statusPayload.error ?? statusPayload.user_error ?? "";
    throw new Error("Request ended with status " + status + ": " + JSON.stringify(error));
  }
  if (!status || !ACTIVE_STATUSES.has(status)) throw new Error("Unknown request status: " + String(status));
  const remainingMs = deadline - Date.now();
  if (remainingMs <= 0) throw new Error("Request exceeded its deadline.");
  await delay(Math.min(pollIntervalMs, remainingMs));
}

const resultResponse = await request(apiBaseUrl + "/requests/" + encodeURIComponent(jobId) + "/result", {
  headers: { Accept: "application/json", "X-API-Key": apiKey },
});
const result = await readJson(resultResponse) as { data?: unknown };
console.log(JSON.stringify(result, null, 2));

Keep this code on a trusted server; a browser client would expose the API key. For retry classification, see Retries and idempotency; for browser and CORS boundaries, see Security and privacy.

Last updated on