SPB Git forge

spb/fetcha

Public
11commits 1branches 0releases
1.5 MBsize
maindefault branch
16 days agolast push
TypeScript 97.5% SQL 1.4% Python 0.8%
13.8 KB · 246 lines tsx
Raw Blame History
1import type { Metadata } from "next";2import { ERROR_CODES, ERROR_HTTP_STATUS, ERROR_MESSAGES, type ErrorCode } from "@fetcha/core";3import { CodeBlock } from "@/components/ui/code-block";4import { DocPage } from "@/components/docs/doc-page";5import { A, Code, H2, H3, Li, P, Strong, Table, TBody, Td, Th, THead, Tr, Ul } from "@/components/docs/prose";6import { Callout } from "@/components/docs/callout";7import { ParamTable } from "@/components/docs/param-table";8import { ResponseExample } from "@/components/docs/response-example";9import { CodeTabs } from "@/components/docs/code-tabs";1011export const metadata: Metadata = {12  title: "Errors",13  description: "Error envelope, every error code with its HTTP status and meaning, validation details, Retry-After, request IDs and error handling in the SDKs.",14};1516/** Extra guidance per code, beyond the canonical message. */17const NOTES: Record<ErrorCode, { retry: "no" | "after-fix" | "backoff" | "later"; note: string }> = {18  INVALID_API_KEY: { retry: "after-fix", note: "Missing header, malformed token, wrong prefix, revoked or expired key. Check the message for the exact reason." },19  EMAIL_NOT_VERIFIED: { retry: "after-fix", note: "Verify the organization owner's email address in the dashboard, then retry." },20  RATE_LIMITED: { retry: "backoff", note: "A sliding-window limit was hit. Honour the Retry-After header (seconds); details.retry_after_ms has the precise value." },21  CONCURRENCY_LIMIT: { retry: "backoff", note: "Too many in-flight requests for the organization. details.limit is your plan's concurrency. Queue and retry when a request finishes." },22  INVALID_REQUEST: { retry: "after-fix", note: "Schema violation, unknown field, malformed JSON, wrong Content-Type or body over 4 MB. details.issues lists each problem." },23  URL_NOT_ALLOWED: { retry: "no", note: "Non-http scheme, credentials in URL, or a host that is local, private, link-local, metadata or internal — on the initial URL or a redirect hop." },24  TARGET_TIMEOUT: { retry: "later", note: "The request budget (timeout) was exhausted across all attempts. Raise timeout within your plan cap, or retry later." },25  TARGET_BLOCKED: { retry: "later", note: "Recorded in the request log when every attempt was blocked. On the API this surfaces as a 200 with success:false and the last blocked page." },26  TARGET_UNAVAILABLE: { retry: "later", note: "DNS resolution failed, no address records, or the connection was refused on every attempt." },27  PROVIDER_UNAVAILABLE: { retry: "backoff", note: "No route is available for an auto request (all circuits open or nothing configured for the geography). Transient; retry with backoff." },28  NETWORK_UNAVAILABLE: { retry: "after-fix", note: "Explicit class not in your plan, not live yet, or without a sticky-capable route (sessions). Use auto or residential." },29  BROWSER_UNAVAILABLE: { retry: "later", note: "Browser pool disabled or unavailable. Retry later, or send the request without browser: true." },30  BROWSER_TIMEOUT: { retry: "later", note: "The page did not settle in the managed browser within the request timeout. Raise timeout, relax wait_for / wait_until, or retry." },31  RESPONSE_TOO_LARGE: { retry: "after-fix", note: "Body exceeded max_response_bytes or the 20 MB platform cap. Not retried across routes." },32  TOO_MANY_REDIRECTS: { retry: "after-fix", note: "More than max_redirects hops. Not retried across routes." },33  INSUFFICIENT_CREDITS: { retry: "after-fix", note: "Reserved for prepaid balances. Not emitted today." },34  USAGE_LIMIT_REACHED: { retry: "after-fix", note: "Monthly plan quota, project request limit, or a hard spending limit (organization or project). details tells which; see Rate limits." },35  SESSION_NOT_FOUND: { retry: "after-fix", note: "Unknown session id, or a session that belongs to another project." },36  SESSION_EXPIRED: { retry: "after-fix", note: "Session past expires_at or closed. Create a new one." },37  NOT_FOUND: { retry: "no", note: "Unknown route or resource. The message names the route." },38  FORBIDDEN: { retry: "after-fix", note: "Key lacks the required scope, project archived, or organization/account suspended." },39  INTERNAL_ERROR: { retry: "backoff", note: "Unexpected failure inside Fetcha. Retry with backoff; if it persists, send us the request_id." },40  CRAWL_NOT_FOUND: { retry: "after-fix", note: "GET/DELETE /v1/crawl/:id with an id that does not exist or belongs to another project." },41  CRAWL_LIMIT_REACHED: { retry: "later", note: "Too many crawl jobs running for the organization (5 concurrent). Wait for one to finish or cancel it." },42};4344const RETRY_LABEL: Record<(typeof NOTES)[ErrorCode]["retry"], string> = {45  no: "Do not retry",46  "after-fix": "Fix, then retry",47  backoff: "Retry with backoff",48  later: "Retry later",49};5051const GROUPS: Array<{ title: string; codes: ErrorCode[] }> = [52  { title: "Authentication and authorization", codes: ["INVALID_API_KEY", "EMAIL_NOT_VERIFIED", "FORBIDDEN"] },53  { title: "Request validation", codes: ["INVALID_REQUEST", "URL_NOT_ALLOWED", "NETWORK_UNAVAILABLE", "BROWSER_UNAVAILABLE", "NOT_FOUND"] },54  { title: "Limits and quotas", codes: ["RATE_LIMITED", "CONCURRENCY_LIMIT", "USAGE_LIMIT_REACHED", "INSUFFICIENT_CREDITS", "CRAWL_LIMIT_REACHED"] },55  { title: "Target and network", codes: ["TARGET_TIMEOUT", "TARGET_BLOCKED", "TARGET_UNAVAILABLE", "PROVIDER_UNAVAILABLE", "RESPONSE_TOO_LARGE", "TOO_MANY_REDIRECTS", "BROWSER_TIMEOUT"] },56  { title: "Sessions and crawls", codes: ["SESSION_NOT_FOUND", "SESSION_EXPIRED", "CRAWL_NOT_FOUND"] },57  { title: "Server", codes: ["INTERNAL_ERROR"] },58];5960const JS_HANDLING = `import { Fetcha, FetchaError } from "@fetcha/sdk";6162const fetcha = new Fetcha({ apiKey: process.env.FETCHA_API_KEY! });6364try {65  const r = await fetcha.fetch({ url: "https://example.com", country: "CA" });66  if (!r.success) {67    // Target answered but with an error or a block page: r.status, r.metadata.attempts68    console.warn("target status", r.status, "after", r.metadata.attempts, "attempts");69  }70} catch (e) {71  if (e instanceof FetchaError) {72    // e.code, e.status (HTTP), e.message, e.requestId73    switch (e.code) {74      case "RATE_LIMITED":75      case "CONCURRENCY_LIMIT":76      case "PROVIDER_UNAVAILABLE":77        // back off and retry78        break;79      case "INVALID_REQUEST":80        // fix the request; details are in e.message81        break;82      default:83        console.error(\`\${e.code} (\${e.status}) \${e.message} — request \${e.requestId}\`);84    }85  } else {86    throw e; // network failure reaching Fetcha, JSON parse error, abort87  }88}`;8990const PY_HANDLING = `from fetcha import Fetcha, FetchaError9192client = Fetcha(api_key=os.environ["FETCHA_API_KEY"])9394try:95    r = client.fetch("https://example.com", country="CA")96    if not r.success:97        # Target answered but with an error or a block page98        print("target status", r.status, "after", r.metadata["attempts"], "attempts")99except FetchaError as e:100    # e.code, e.status (HTTP), e.message, e.request_id101    if e.code in ("RATE_LIMITED", "CONCURRENCY_LIMIT", "PROVIDER_UNAVAILABLE"):102        ...  # back off and retry103    elif e.code == "INVALID_REQUEST":104        ...  # fix the request105    else:106        print(f"{e.code} ({e.status}) {e.message} — request {e.request_id}")`;107108export default function ErrorsPage() {109  const all: ErrorCode[] = [...ERROR_CODES];110  return (111    <DocPage path="/docs/errors" eyebrow="Reliability" title="Errors" description="Fetcha uses conventional HTTP status codes and a single JSON envelope for every error. Each error carries a stable machine-readable code and the request ID." status="Stable">112      <H2>Error envelope</H2>113      <P>114        Every non-2xx response from the API has this shape. <Code>code</Code> is stable and meant for programmatic handling; <Code>message</Code> is human-readable and may change;{" "}115        <Code>details</Code> is present only for some codes.116      </P>117      <ResponseExample118        status={429}119        statusText="Too Many Requests"120        body={{ error: { code: "RATE_LIMITED", message: "Too many requests. Slow down or upgrade your plan.", request_id: "req_4e5f6g7h8i9j0k1l", details: { retry_after_ms: 640 } } }}121      />122      <ParamTable123        showDefault={false}124        rows={[125          { name: "error.code", type: "string", description: <>One of the {all.length} codes below.</> },126          { name: "error.message", type: "string", description: <>Explanation for humans. Often more specific than the default message (for example which scope is missing or which plan lacks a network).</> },127          { name: "error.request_id", type: "string | null", description: <>The request identifier, also in the <code>X-Fetcha-Request-ID</code> header.</> },128          { name: "error.details", type: "object", description: <>Optional structured context: <code>issues</code> for validation, <code>retry_after_ms</code> for rate limits, <code>limit</code> for concurrency, <code>limit</code>/<code>used</code> or <code>limit_usd</code>/<code>spent_usd</code> for quotas.</> },129        ]}130      />131      <Callout variant="info" title="A 200 is not always a success">132        <Code>/v1/fetch</Code> returns <Code>200</Code> whenever Fetcha obtained a response from the target, including a 404, a 500 or a block page. Check <Code>success</Code> and <Code>status</Code>{" "}133        in the body. Error envelopes are used only when Fetcha itself could not complete the request. See <A href="/docs/fetch#blocked-targets">Blocked targets</A>.134      </Callout>135136      <H2>Error codes</H2>137      <P>The status, code and default message come straight from the API&apos;s error catalogue. The last two columns are guidance.</P>138      {GROUPS.map((g) => (139        <div key={g.title}>140          <H3>{g.title}</H3>141          <Table dense>142            <THead>143              <Tr>144                <Th>Status</Th>145                <Th>Code</Th>146                <Th>Default message and notes</Th>147                <Th>Retry</Th>148              </Tr>149            </THead>150            <TBody>151              {g.codes.map((code) => (152                <Tr key={code}>153                  <Td mono className="whitespace-nowrap">154                    {ERROR_HTTP_STATUS[code]}155                  </Td>156                  <Td mono className="whitespace-nowrap">157                    {code}158                  </Td>159                  <Td>160                    <div className="text-fg">{ERROR_MESSAGES[code]}</div>161                    <div className="mt-0.5 text-[12.5px] text-fg-muted">{NOTES[code].note}</div>162                  </Td>163                  <Td className="whitespace-nowrap text-[12.5px]">{RETRY_LABEL[NOTES[code].retry]}</Td>164                </Tr>165              ))}166            </TBody>167          </Table>168        </div>169      ))}170171      <H2>Validation details</H2>172      <P>173        <Code>INVALID_REQUEST</Code> raised by schema validation includes <Code>details.issues</Code>, an array with one entry per problem. <Code>path</Code> is the dotted path of the offending field174        (empty for unknown top-level keys), <Code>message</Code> explains the constraint.175      </P>176      <ResponseExample177        status={400}178        statusText="Bad Request"179        body={{180          error: {181            code: "INVALID_REQUEST",182            message: "The request body is invalid.",183            request_id: "req_9c1d2e3f4a5b6c7d",184            details: {185              issues: [186                { path: "url", message: "String must contain at least 1 character(s)" },187                { path: "country", message: "String must contain exactly 2 character(s)" },188                { path: "max_redirects", message: "Number must be less than or equal to 20" },189                { path: "", message: "Unrecognized key(s) in object: 'proxy'" },190              ],191            },192          },193        }}194      />195      <P>Other request-level failures reuse the same code with a specific message and no <Code>issues</Code>:</P>196      <Ul>197        <Li>198          <Code>Malformed JSON body.</Code>199        </Li>200        <Li>201          <Code>Send a JSON body with Content-Type: application/json.</Code>202        </Li>203        <Li>204          <Code>Request body too large.</Code> (HTTP 413)205        </Li>206      </Ul>207208      <H2>Retry-After</H2>209      <P>210        <Code>RATE_LIMITED</Code> responses include a <Code>Retry-After</Code> header in whole seconds (rounded up from <Code>details.retry_after_ms</Code>). Honour it. <Code>CONCURRENCY_LIMIT</Code>{" "}211        does not carry the header because the right moment to retry is when one of your own in-flight requests completes.212      </P>213      <CodeBlock214        lang="text"215        code={`HTTP/1.1 429 Too Many Requests216Retry-After: 1217X-Fetcha-Request-ID: req_4e5f6g7h8i9j0k1l218Content-Type: application/json; charset=utf-8`}219      />220221      <H2>Request IDs and support</H2>222      <P>223        Every response has an <Code>X-Fetcha-Request-ID</Code>; error envelopes repeat it in <Code>error.request_id</Code>. Log it alongside your own correlation id. When you contact{" "}224        <A href="mailto:support@fetcha.co">support@fetcha.co</A>, include the request id, the approximate time (UTC) and the target domain: with the id we can see every attempt, the route used, the225        outcome and the timing, without you sharing any content.226      </P>227      <P>228        <Strong>Retention:</Strong> request metadata is kept for the retention period of your plan (3 to 365 days). Response bodies are not stored, and sensitive request headers are redacted before229        logging.230      </P>231232      <H2>Handling errors in the SDKs</H2>233      <P>234        Both SDKs raise a <Code>FetchaError</Code> for any non-2xx response, exposing <Code>code</Code>, <Code>status</Code>, <Code>message</Code> and the request id. A fetch that reached the target235        never throws: inspect <Code>success</Code> instead. See <A href="/docs/sdks">SDKs</A>.236      </P>237      <CodeTabs238        tabs={[239          { label: "JavaScript", lang: "typescript", code: JS_HANDLING },240          { label: "Python", lang: "python", code: PY_HANDLING },241        ]}242      />243    </DocPage>244  );245}246