TypeScript 97.5%
SQL 1.4%
Python 0.8%
1import type { Metadata } from "next";2import { PLAN_LIMITS } 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 { Endpoint } from "@/components/docs/endpoint";8import { CodeTabs } from "@/components/docs/code-tabs";9import { ResponseExample } from "@/components/docs/response-example";10import { apiTabs } from "@/components/docs/snippets";1112export const metadata: Metadata = {13 title: "Rate Limits",14 description: "Concurrency, sliding-window rate limits, RATE_LIMITED vs CONCURRENCY_LIMIT, optional spending limits, USAGE_LIMIT_REACHED and response headers on the single unlimited plan.",15};1617const L = PLAN_LIMITS.unlimited;18const fmt = (n: number) => n.toLocaleString("en-US");1920export default function RateLimitsPage() {21 const perOrgMinute = Math.max(60, L.concurrency * 60);22 const perKeySecond = Math.max(10, L.concurrency * 2);23 return (24 <DocPage25 path="/docs/rate-limits"26 eyebrow="Reliability"27 title="Rate limits"28 description="Fetcha is a private platform with a single plan: there is no monthly request quota. Two operational limits remain — how many requests run at once and how fast you can submit them — plus spending limits you can set yourself."29 status="Stable"30 >31 <Callout variant="info" title="One plan, no meters">32 Every organization has the same limits, listed below and returned by <Code>GET /v1/usage</Code>. There is nothing to upgrade: hitting a limit means smoothing your traffic, not paying more.33 </Callout>3435 <H2>Concurrency</H2>36 <P>37 Concurrency is the number of fetch requests in flight at the same time, counted per <Strong>organization</Strong> (all projects and keys together). It is the limit that matters most in38 practice: a fetch takes a second or more, so sustained throughput is roughly concurrency divided by average latency.39 </P>40 <Table>41 <THead>42 <Tr>43 <Th>Limit</Th>44 <Th>Value</Th>45 <Th>Scope</Th>46 </Tr>47 </THead>48 <TBody>49 <Tr>50 <Td className="font-medium text-fg">Concurrent requests</Td>51 <Td mono>{fmt(L.concurrency)}</Td>52 <Td>Organization</Td>53 </Tr>54 <Tr>55 <Td className="font-medium text-fg">Concurrent browser renders</Td>56 <Td mono>{fmt(L.browser_concurrency)}</Td>57 <Td>Organization (subset of the above)</Td>58 </Tr>59 <Tr>60 <Td className="font-medium text-fg">Concurrent crawl jobs</Td>61 <Td mono>{fmt(L.crawl_concurrent_jobs)}</Td>62 <Td>Organization</Td>63 </Tr>64 <Tr>65 <Td className="font-medium text-fg">Monthly requests</Td>66 <Td mono>Unlimited</Td>67 <Td>—</Td>68 </Tr>69 <Tr>70 <Td className="font-medium text-fg">Log retention</Td>71 <Td mono>{L.retention_days} days</Td>72 <Td>Organization</Td>73 </Tr>74 </TBody>75 </Table>76 <P>77 When a request would exceed the limit it is refused immediately with <Code>429 CONCURRENCY_LIMIT</Code>; nothing is queued on Fetcha's side. A slot is released as soon as a request78 completes (successfully or not). As a safety net, a slot that is somehow never released expires after 150 seconds.79 </P>80 <ResponseExample status={429} statusText="Too Many Requests" body={{ error: { code: "CONCURRENCY_LIMIT", message: `Your organization allows ${L.concurrency} concurrent requests.`, request_id: "req_1l2m3n4o5p6q7r8s", details: { limit: L.concurrency } } }} />8182 <H2>Request rate</H2>83 <P>84 Independently of concurrency, submissions are metered with sliding windows. They are set well above what the concurrency limit lets you sustain, so they only bite on bursts or runaway85 loops. Three windows are checked on every fetch:86 </P>87 <Table>88 <THead>89 <Tr>90 <Th>Window</Th>91 <Th>Scope</Th>92 <Th>Rule</Th>93 <Th>Value</Th>94 </Tr>95 </THead>96 <TBody>97 <Tr>98 <Td>60 seconds</Td>99 <Td>Organization (sustained)</Td>100 <Td>101 <Code>concurrency × 60</Code> per minute102 </Td>103 <Td mono>{fmt(perOrgMinute)} / min</Td>104 </Tr>105 <Tr>106 <Td>1 second</Td>107 <Td>API key (burst)</Td>108 <Td>109 <Code>concurrency × 2</Code> per second110 </Td>111 <Td mono>{fmt(perKeySecond)} / s</Td>112 </Tr>113 <Tr>114 <Td>1 second</Td>115 <Td>Client IP (abuse)</Td>116 <Td>Fixed</Td>117 <Td mono>200 / s</Td>118 </Tr>119 </TBody>120 </Table>121 <P>122 Exceeding any window returns <Code>429 RATE_LIMITED</Code> with a <Code>Retry-After</Code> header (seconds, rounded up) and <Code>details.retry_after_ms</Code>.123 </P>124 <ResponseExample status={429} statusText="Too Many Requests" body={{ error: { code: "RATE_LIMITED", message: "Too many requests. Slow down and retry after the indicated delay.", request_id: "req_4e5f6g7h8i9j0k1l", details: { retry_after_ms: 640 } } }} />125126 <H3>RATE_LIMITED vs CONCURRENCY_LIMIT</H3>127 <Ul>128 <Li>129 <Code>RATE_LIMITED</Code>: you submitted too many requests in a short window. Wait <Code>Retry-After</Code> seconds, then continue. Smooth your submission rate.130 </Li>131 <Li>132 <Code>CONCURRENCY_LIMIT</Code>: too many requests are running right now. Do not sleep a fixed time; retry when one of your in-flight requests finishes. Use a semaphore or worker pool sized133 to {fmt(L.concurrency)} or less (see the <A href="/docs/examples#concurrency-within-plan-limits">concurrency example</A>).134 </Li>135 </Ul>136137 <H2>No monthly quota</H2>138 <P>139 There is no cap on the number of requests per month and no overage. Usage is still counted per organization and per project (<Strong>every request that reaches the execution pipeline140 counts</Strong>, including those that end blocked or failed) so that the dashboard, <Code>GET /v1/usage</Code> and your optional spending limits have accurate figures. Requests refused141 earlier (authentication, validation, rate limits, session errors) are not counted.142 </P>143144 <H2>Spending and project limits</H2>145 <P>146 You can cap usage yourself in the dashboard. These are guard-rails against runaway jobs, not billing: spend is an internal cost estimate and nothing is invoiced. All of them produce the same{" "}147 <Code>USAGE_LIMIT_REACHED</Code> code with a specific message and details:148 </P>149 <Table dense>150 <THead>151 <Tr>152 <Th>Limit</Th>153 <Th>Scope</Th>154 <Th>Effect when reached</Th>155 <Th>details</Th>156 </Tr>157 </THead>158 <TBody>159 <Tr>160 <Td>Monthly request limit</Td>161 <Td>Project</Td>162 <Td>Fetches in that project are refused</Td>163 <Td mono>{`{ limit, used }`}</Td>164 </Tr>165 <Tr>166 <Td>Hard spending limit (USD estimate)</Td>167 <Td>Organization</Td>168 <Td>All fetches are refused</Td>169 <Td mono>{`{ limit_usd, spent_usd }`}</Td>170 </Tr>171 <Tr>172 <Td>Hard spending limit (USD estimate)</Td>173 <Td>Project</Td>174 <Td>Fetches in that project are refused</Td>175 <Td mono>{`{ limit_usd, spent_usd }`}</Td>176 </Tr>177 <Tr>178 <Td>Soft spending limit (USD estimate)</Td>179 <Td>Organization or project</Td>180 <Td>Alert only; traffic continues</Td>181 <Td>—</Td>182 </Tr>183 </TBody>184 </Table>185 <ResponseExample186 status={402}187 statusText="Payment Required"188 title="402 · project request limit reached (set by you)"189 body={{ error: { code: "USAGE_LIMIT_REACHED", message: "Monthly request limit of 50,000 reached for this project.", request_id: "req_8s9t0u1v2w3x4y5z", details: { limit: 50000, used: 50000 } } }}190 />191 <Callout variant="info" title="Spend is an estimate">192 Spend is Fetcha's internal cost estimate of your usage, accumulated over the calendar month (UTC). It is what <Code>GET /v1/usage</Code> reports as <Code>spend_usd</Code>. Usage counters193 are cached for up to 20 seconds, so enforcement of a limit you set can lag a few requests behind.194 </Callout>195196 <H2>Checking your usage</H2>197 <Endpoint method="GET" path="/v1/usage" scope="usage:read" status="Live" />198 <CodeTabs tabs={apiTabs({ method: "GET", path: "/v1/usage" }, ["curl", "javascript", "python"])} />199 <ResponseExample200 status={200}201 body={{202 period_start: "2026-09-01T00:00:00.000Z",203 plan: { id: "unlimited", label: "Unlimited", monthly_requests: null, concurrency: L.concurrency },204 organization: { requests: 12840, spend_usd: 3.2115 },205 project: { requests: 9911, spend_usd: 2.4402, successful_requests: 9640, success_rate: 97.3, bandwidth_bytes: 3892214411, latency_p50_ms: 1180, latency_p95_ms: 4210 },206 remaining_requests: null,207 }}208 />209 <Ul>210 <Li>211 <Code>organization</Code> covers every project; <Code>project</Code> is the key's project only.212 </Li>213 <Li>214 <Code>plan.monthly_requests</Code> and <Code>remaining_requests</Code> are <Code>null</Code>: there is no quota. A project-level request limit you configured is reported in the215 dashboard, not here.216 </Li>217 <Li>218 <Code>success_rate</Code> is a percentage with one decimal, or <Code>null</Code> when the project made no requests this month.219 </Li>220 </Ul>221222 <H2>Response headers</H2>223 <P>Fetcha currently sets these headers on every API response. There are no <Code>X-RateLimit-*</Code> counters; use <Code>GET /v1/usage</Code> for month-to-date figures.</P>224 <CodeBlock225 lang="text"226 code={`X-Fetcha-Request-ID: req_… # always; quote it to support227X-Fetcha-Version: 0.2.0 # API version228Cache-Control: no-store # responses are never cacheable229Retry-After: <seconds> # only on 429 RATE_LIMITED`}230 />231 </DocPage>232 );233}234