ThatAIguyA world of ideas

THE CONSCIOUSNESS ATLAS
Read. Experiment. Follow the evidence.

ConsciousnessLearn · Explore · Joseph Anady

Janady13/crawler-signal-reference / framework-http-rate-control-headers.md

Source identity, revision, and recovered contents.

GitHub sourceSource text recovered

Source identity

FieldRecorded value
Source typeGitHub source
Original recordJanady13/crawler-signal-reference / framework-http-rate-control-headers.md
DateNot specified in source
RepositoryJanady13/crawler-signal-reference
Revision5a13904b5b5d638761f3ffef01c51ed3a01fea87
Branchmain
Path or archive memberframework-http-rate-control-headers.md
Original SHA-256bd413f52ef9f9b4adbafa56de6758a9981873d5d624bf0aa7b2bbf5346dc01e3
Git blob SHA5ff5ea59d82f484a401cf1fa8f5935f6e6a56b59
Reading-copy SHA-256bd413f52ef9f9b4adbafa56de6758a9981873d5d624bf0aa7b2bbf5346dc01e3

Original source ↗

Recovered source transcript

Download complete source text ↓2,287 lines · 82.8 KB
# framework-http-rate-control-headers.md Comprehensive reference for the HTTP response headers that communicate rate limit state and backoff guidance to clients: the standardized `Retry-After` (RFC 9110) and the rate limit family covering both the widely deployed de facto convention `X-RateLimit-Limit`, `X-RateLimit-Remaining`, `X-RateLimit-Reset` and the IETF standardization track `RateLimit-Limit`, `RateLimit-Remaining`, `RateLimit-Reset`, `RateLimit-Policy`. Includes the status code companions `429 Too Many Requests` and `503 Service Unavailable` that activate Retry-After, plus the full nginx `limit_req` and `limit_conn` configuration that produces them. Built for Bubbles (Debian, Nginx 1.26+, self hosted origin at 169.155.162.118, no Cloudflare or third party CDN in front). Companion to framework-http-caching-headers.md, framework-http-content-headers.md, framework-http-seo-headers.md, framework-http-security-headers.md, framework-http-performance-headers.md, framework-http-cors-headers.md, UNIVERSAL-RANKING-FRAMEWORK.md, and SEO BUILD REFERENCE v2.4. Audience: humans configuring nginx rate limiting, AI assistants generating or repairing rate limit config, API engineers shaping client backoff behavior, SEO operators ensuring Googlebot and AI crawlers are not accidentally throttled, and anyone troubleshooting "429s on legitimate users", "Googlebot dropped my pages", "client retry storm after maintenance", or "rate limit headers missing on API responses" anomalies on a self hosted stack. --- ## TABLE OF CONTENTS 1. Definition 2. Why It Matters 3. What This Covers 4. The Rate Control Mental Model (read this first) 5. Retry-After (the standardized backoff signal) 6. X-RateLimit-Limit, X-RateLimit-Remaining, X-RateLimit-Reset (the de facto convention) 7. The IETF RateLimit-* Standardization (the future) 8. Status Code Pairing (429 vs 503 vs 3xx) 9. Nginx Rate Limiting Configuration (limit_req and limit_conn) 10. The Crawler Protection Rule (never rate limit Googlebot, ClaudeBot, GPTBot) 11. Client Backoff Patterns (exponential, jitter, honoring Retry-After) 12. How These Headers Interact 13. Asset Class And Use Case Recipes 14. Bubbles Nginx Reference Block (paste ready) 15. Audit Checklist (50+ items) 16. Common Pitfalls 17. Diagnostic Commands 18. Cross-References --- ## 1. DEFINITION Rate control headers communicate two things to clients: (a) how much quota remains right now, so well behaved clients can self throttle, and (b) when to come back after being rejected, so retry storms do not amplify the problem they were meant to solve. The headers split into three families: * **The backoff signal**: `Retry-After`. Standardized in RFC 9110. Sent with `429 Too Many Requests`, `503 Service Unavailable`, or `3xx` redirect responses. Tells the client (including search engine crawlers) exactly when it is safe to try again. * **The rate limit announcement (de facto)**: `X-RateLimit-Limit`, `X-RateLimit-Remaining`, `X-RateLimit-Reset`. Widely deployed (GitHub, Twitter, Stripe, most public APIs) but never standardized. Sent on every response to let clients see their current quota state. * **The rate limit announcement (standards track)**: `RateLimit-Limit`, `RateLimit-Remaining`, `RateLimit-Reset`, `RateLimit-Policy`. IETF draft (draft-ietf-httpapi-ratelimit-headers, in late stages). Replaces the X- convention with un prefixed names. Modern APIs (GitLab, CircleCI, OKX, others) already emit both forms during the transition. `Retry-After` is the only one with a hard standard. The X- and RateLimit-* families are conventions; clients have to know what your specific API uses. The standardization track aims to fix this so any RFC compliant client can read any server's rate limit state without API specific code. --- ## 2. WHY IT MATTERS Six independent pressures push correct rate control headers from "operational hygiene" to "required infrastructure" in 2025 and forward. **Search engines drop pages after 2 days of 429 or 503.** Google's official documentation states that returning 503 or 429 for more than 2 days will cause Google to drop those URLs from the index. The Retry-After header is what tells Googlebot "this is temporary, come back when X". Without it, Googlebot guesses, and after 48 hours of guessing wrong it stops trying. A Bubbles client site that accidentally rate limits Googlebot for a long weekend can lose its rankings. **AI crawlers honor Retry-After.** ClaudeBot, GPTBot, PerplexityBot, and other LLM crawlers respect Retry-After (when they respect anything). For sites Joseph has optimized for AI discoverability, throttling these crawlers without proper Retry-After defeats the optimization investment. The same 2-day rule applies in spirit: persistent throttling teaches crawlers to skip the site. **Retry storms amplify the problem.** Without Retry-After, clients retry on their own schedule. After a server briefly returns 503 during maintenance, every client retries within seconds, the next batch of 503s triggers the next round of retries, and the server stays down because it cannot drain the queue faster than retries arrive. Retry-After with a sensible value lets the storm spread out over minutes instead of seconds. **Rate limit headers prevent self imposed denial.** Without X-RateLimit-* or RateLimit-* headers, well behaved clients have no way to throttle themselves. They must blindly send requests and react to 429s. A client that emits Server-Sent Events or polls frequently will hit limits constantly. With headers exposing remaining quota, the same client smooths its rate and never gets throttled. **Nginx defaults are wrong for APIs.** nginx's `limit_req` and `limit_conn` modules return `503 Service Unavailable` by default. For a REST API, this is misleading: 503 implies the whole service is down, when really only this client exceeded its quota. The correct status is `429 Too Many Requests`. Fixing this is a one line config change (`limit_req_status 429`) that most deployments miss. **Per IP rate limiting breaks legitimate clients sharing IPs.** Office networks, corporate VPNs, mobile carriers, and IPv6 with carrier grade NAT all put many users behind a single IP. Per IP rate limits configured for "individual user" levels block entire organizations. The fix is either a generous burst, per token rate limits when authenticated, or both. **Cost of getting it wrong.** Misconfigured rate control produces silent ranking damage, real user pain, and runaway retry storms. Real examples: * nginx rate limiting set to 10r/s with no burst, no Googlebot whitelist. Googlebot crawls the site, hits the limit, gets 503 for two days. Google drops 5,800 URLs from the index. Ranking recovery takes weeks. * API returns 429 without Retry-After. Client library backs off exponentially starting at 1 second, doubling each retry. Hits the API 6 times in 90 seconds, all rate limited. With Retry-After: 60, would have hit once and succeeded. * Office of 200 users behind one NAT IP. Rate limit at 30 requests per minute per IP. The first 30 users to load the morning dashboard succeed; the rest see 429s. Support tickets flood in. * nginx returns 503 for rate limited API responses. Frontend monitoring alerts on "service unavailable" rate. On call engineer paged at 2 AM for what is actually expected client behavior. * Maintenance window planned 30 minutes. Site returns 503 with no Retry-After. Browsers and CDN caches cache the 503. Even after maintenance ends, repeat visitors get cached 503s for hours. All preventable with the rules below. --- ## 3. WHAT THIS COVERS Each of the primary headers gets the same six part treatment: 1. **What it does**: the canonical RFC or IETF draft definition plus the practical implication. 2. **Syntax and values**: every legal format, what it means, when it is wrong. 3. **How to build it on Bubbles**: paste ready nginx config plus FastAPI sidecar code where the upstream is the natural emitter. 4. **How to verify it**: curl commands that trigger rate limits and read responses. 5. **How to troubleshoot**: the four or five failure modes seen in the field and how to recognize each. 6. **How to fix common breakage**: ordered repair steps. The status code companions (429 and 503) get a dedicated section because they activate Retry-After. The nginx `limit_req` / `limit_conn` configuration gets its own dedicated section because it produces these headers and is where most operators spend their debugging time. The crawler protection rule gets its own section because it is the single most ranking impactful decision in this framework. --- ## 4. THE RATE CONTROL MENTAL MODEL (READ THIS FIRST) Every cross origin or API request runs through the rate control decision tree. Internalize it and every header decision becomes obvious. ``` Request arrives at nginx | v ==================== IDENTIFICATION ==================== | v Who is the client? * IP based: $binary_remote_addr * Token based: $http_authorization (or extracted) * Session based: $cookie_session * Endpoint based: $request_uri | v Is the client on the whitelist? * Googlebot (verified by reverse DNS) * ClaudeBot, GPTBot, PerplexityBot * Bubbles admin IPs * Local Tailscale network | YES (whitelisted) | v Bypass rate limit, process normally | | NO (subject to rate limits) | v ==================== RATE LIMIT CHECK ==================== | v Has this client exceeded the per second rate? | | NO YES | | | v | Is there burst capacity? | | | | YES NO | | | | v v | Allow (queued) Reject with 429 (or 503) | or process Set Retry-After: <seconds> | immediately Set RateLimit-Reset | (with nodelay) Log at warn level | | v | ==================== ANNOUNCE RATE LIMIT STATE ================= | | v | Decorate response with rate limit headers X-RateLimit-Limit: 100 (current quota) X-RateLimit-Remaining: 47 (what is left) X-RateLimit-Reset: 1742658600 (when it resets) AND modern equivalents: RateLimit-Limit: 100 RateLimit-Remaining: 47 RateLimit-Reset: 60 RateLimit-Policy: 100;w=60 | v ==================== CLIENT REACTION ==================== | v Well behaved client: Reads X-RateLimit-Remaining Slows down or pauses before hitting zero On 429, reads Retry-After and waits exactly that long | v Misbehaving client (or no headers): Retries immediately or on hardcoded interval May get throttled into a longer cooldown May trigger fail2ban or equivalent ``` Five rules govern the system: 1. **Set Retry-After on every 429 and 503.** It tells crawlers it is temporary and tells well behaved clients exactly when to come back. Missing it is the difference between graceful recovery and a retry storm. 2. **Use 429 for rate limiting, 503 for actual outage.** They are different things. Conflating them confuses both crawlers and monitoring. 3. **Whitelist verified crawlers.** Googlebot, ClaudeBot, GPTBot are not enemies of your bandwidth budget; they are how your content gets discovered. 4. **Announce limits proactively.** Sending RateLimit-* headers on every response (not just 429) lets clients self throttle and never hit the limit. 5. **Migration: send both X-RateLimit-* and RateLimit-*.** The X- convention is what existing clients understand. The un prefixed form is the standardization track. Send both during the transition. A correctly configured rate control stack throttles abusive clients without harming legitimate ones, communicates exact backoff windows to crawlers (preserving search ranking), and never produces unnecessary retry storms during maintenance. --- ## 5. RETRY-AFTER (THE STANDARDIZED BACKOFF SIGNAL) ### 5.1 What It Does `Retry-After` tells the client how long to wait before retrying the request. Standardized in RFC 9110. Sent with three classes of responses: * `503 Service Unavailable`: the service is temporarily down. Retry-After indicates expected duration of unavailability. * `429 Too Many Requests`: the client exceeded a rate limit. Retry-After indicates when the limit window resets. * `3xx` redirect: scheduled redirect with a minimum delay before following. ``` Retry-After: 120 Retry-After: Wed, 21 Oct 2026 07:28:00 GMT ``` Crawlers including Googlebot honor this header. Without it, crawlers guess at when to retry; with it, they wait precisely the indicated duration. ### 5.2 The Two Formats | Format | Example | When to use | |---|---|---| | `<delta-seconds>` | `Retry-After: 60` | Relative wait. Most common. Use for rate limits, short maintenance | | `<HTTP-date>` | `Retry-After: Wed, 21 Oct 2026 07:28:00 GMT` | Absolute time. Use for scheduled maintenance with known end time | Delta seconds is recommended for most cases. It avoids clock skew issues between client and server. HTTP date is appropriate when the resume time is independent of the request time (a known maintenance window). ### 5.3 The 2 Day Rule For Search Engines Per Google's official documentation: returning 503 or 429 for more than 2 days will cause Google to drop those URLs from the index. The mechanism: * Days 1 to 2: Googlebot treats 503/429 as temporary, honors Retry-After, retries. * Day 2+: Google starts treating sustained 5xx/429 as a permanent signal that the content is gone. * Days later: URLs drop from the index. Recovery: when the site returns 200, Googlebot recrawls. Recovery time depends on crawl budget; popular sites recrawl in hours, smaller sites can take weeks. **The implication for Bubbles operators:** never rate limit Googlebot. Any maintenance window longer than 48 hours requires a different strategy than 503 (consider a static maintenance page returning 200 with a notice). ### 5.4 How To Build It On Bubbles **Static 503 maintenance page with Retry-After:** ```nginx server { server_name example.com; location / { # During maintenance, return 503 with Retry-After return 503; } error_page 503 /maintenance.html; location = /maintenance.html { root /var/www/sites/example.com; internal; add_header Retry-After "3600" always; # 1 hour add_header Cache-Control "no-store" always; # do not cache the 503 } } ``` **Programmatic Retry-After from FastAPI sidecar:** ```python from fastapi import FastAPI, Response, status from fastapi.responses import JSONResponse import time app = FastAPI() # Simple in memory rate limiter (production should use Redis or similar) client_buckets = {} @app.middleware("http") async def rate_limit(request, call_next): client_ip = request.client.host now = time.time() bucket = client_buckets.setdefault(client_ip, {"count": 0, "reset": now + 60}) if now > bucket["reset"]: bucket["count"] = 0 bucket["reset"] = now + 60 if bucket["count"] >= 100: retry_after = int(bucket["reset"] - now) return JSONResponse( status_code=429, content={"error": "rate limit exceeded"}, headers={ "Retry-After": str(retry_after), "X-RateLimit-Limit": "100", "X-RateLimit-Remaining": "0", "X-RateLimit-Reset": str(int(bucket["reset"])), "RateLimit-Limit": "100", "RateLimit-Remaining": "0", "RateLimit-Reset": str(retry_after), "RateLimit-Policy": "100;w=60", } ) bucket["count"] += 1 response = await call_next(request) response.headers["X-RateLimit-Limit"] = "100" response.headers["X-RateLimit-Remaining"] = str(100 - bucket["count"]) response.headers["X-RateLimit-Reset"] = str(int(bucket["reset"])) response.headers["RateLimit-Limit"] = "100" response.headers["RateLimit-Remaining"] = str(100 - bucket["count"]) response.headers["RateLimit-Reset"] = str(int(bucket["reset"] - now)) response.headers["RateLimit-Policy"] = "100;w=60" return response ``` **Scheduled maintenance with absolute time:** ```nginx server { location / { return 503; } error_page 503 /maintenance.html; location = /maintenance.html { root /var/www/sites/example.com; internal; # Maintenance ends at a specific time add_header Retry-After "Sat, 24 May 2026 22:00:00 GMT" always; add_header Cache-Control "no-store" always; } } ``` ### 5.5 How To Verify ```bash # 1. Confirm Retry-After is sent on 429 curl -sI -X POST https://api.example.com/endpoint \ -H "Authorization: Bearer test" \ --data '...' | grep -iE "^(HTTP|retry-after)" # After hitting the rate limit: # HTTP/2 429 # retry-after: 60 # 2. Confirm Retry-After on 503 maintenance curl -sI https://example.com/ | grep -iE "^(HTTP|retry-after)" # HTTP/2 503 # retry-after: 3600 # 3. Test that Googlebot sees Retry-After curl -sI -A "Mozilla/5.0 (compatible; Googlebot/2.1; +http://www.google.com/bot.html)" \ https://example.com/ | grep -i retry-after # 4. Trigger rate limit deliberately to test response shape for i in $(seq 1 200); do curl -so /dev/null -w "%{http_code}\n" https://api.example.com/endpoint done | sort | uniq -c # Expect: most 200, then 429s appear once burst is exhausted ``` ### 5.6 Troubleshooting **Symptom: Googlebot drops pages despite returning 503 with Retry-After.** 1. The 503 persisted longer than 2 days. Google treats sustained 5xx as permanent removal. 2. The Retry-After value was unrealistic (e.g., far future date). Googlebot ignores extreme values. 3. The 503 was returned to too many URLs simultaneously (server wide rather than scoped). Google interprets as site outage. Fix: keep maintenance windows under 48 hours; use Retry-After values that match actual maintenance duration; for longer downtime, serve a 200 maintenance page instead. **Symptom: 503s being cached by browsers despite Retry-After.** The 503 response did not include `Cache-Control: no-store`. Browsers may cache 503s if not told otherwise. Fix: always pair 503 with `Cache-Control: no-store, no-cache, must-revalidate`. **Symptom: Clients retry immediately after 429 instead of waiting.** 1. Client library does not honor Retry-After. 2. The header value is invalid (non integer for delta seconds). 3. The value is 0 or negative. Fix: ensure value is a positive integer of seconds, or a valid HTTP date. Update client to honor the header. **Symptom: Retry-After date format rejected by some clients.** Some clients are strict about RFC date format. The format is the same as the Date header: `Wed, 21 Oct 2026 07:28:00 GMT`. Note: no day name extensions, GMT timezone literal, comma after day name. **Symptom: Browser displays a "service unavailable" error page during expected rate limiting.** The client is a browser (not a script) and got a 429. The browser shows its own error page. This is correct behavior; the rate limit should not apply to browsers loading HTML pages, only to API endpoints or to clients clearly exceeding sensible thresholds. ### 5.7 How To Fix Common Breakage **Case: Need to take site down for maintenance without losing Google rankings.** Two options: Option 1 (maintenance under 24 hours): serve 503 with Retry-After: ```nginx return 503; error_page 503 /maintenance.html; location = /maintenance.html { internal; add_header Retry-After "3600" always; add_header Cache-Control "no-store" always; add_header X-Robots-Tag "noindex" always; root /var/www/sites/example.com; } ``` Option 2 (maintenance over 24 hours): serve 200 with a static "we are working on it" page that includes the same content URLs in a sitemap. Google keeps the URLs indexed because they return 200. **Case: API client library does not respect Retry-After.** Wrap the client in a retry layer that does: ```python import requests import time def request_with_backoff(method, url, **kwargs): for attempt in range(5): response = requests.request(method, url, **kwargs) if response.status_code == 429: retry_after = int(response.headers.get("Retry-After", "60")) time.sleep(retry_after) continue return response return response ``` --- ## 6. X-RATELIMIT-LIMIT, X-RATELIMIT-REMAINING, X-RATELIMIT-RESET (THE DE FACTO CONVENTION) ### 6.1 What They Do The X-RateLimit-* family announces the rate limit state to the client on every response. Not formally standardized but widely deployed since the early 2010s (GitHub, Twitter, Stripe, Shopify, Reddit, etc). ``` X-RateLimit-Limit: 100 X-RateLimit-Remaining: 47 X-RateLimit-Reset: 1742658600 ``` A client can read these on a successful response and decide to slow down before hitting the limit. On a 429 response, the same headers tell the client how to plan the next attempt. ### 6.2 The Three Headers | Header | Value | Meaning | |---|---|---| | `X-RateLimit-Limit` | Integer | Total quota for the current window | | `X-RateLimit-Remaining` | Integer | Requests remaining in the current window | | `X-RateLimit-Reset` | Integer | When the window resets. Two common formats: Unix timestamp or seconds until reset | The format of `X-RateLimit-Reset` is the single most inconsistent thing in this family. Two formats are common: * **Unix epoch seconds**: `X-RateLimit-Reset: 1742658600` (GitHub, Reddit, Stripe) * **Seconds until reset**: `X-RateLimit-Reset: 60` (Twitter formerly, some others) The IETF standardization track resolves this by mandating "seconds until reset" (a delta). The X- convention is ambiguous; document which form your API uses. **Bubbles convention:** use Unix epoch seconds for X-RateLimit-Reset (matches the most prominent deployments) and seconds until reset for the un prefixed RateLimit-Reset (matches the standardization track). ### 6.3 How To Build It On Bubbles X-RateLimit-* headers must be set by the application layer because they depend on application state (how many requests this client has made). nginx can do basic IP based rate limiting but cannot count requests per token; that requires the upstream. **FastAPI sidecar emitting full rate limit headers:** ```python from fastapi import FastAPI, Request, Response from fastapi.responses import JSONResponse import time import asyncio import redis.asyncio as redis app = FastAPI() r = redis.from_url("redis://127.0.0.1:6379") WINDOW_SECONDS = 60 LIMIT = 100 async def check_rate_limit(client_id: str): """Returns (remaining, reset_epoch, allowed).""" now = int(time.time()) window_start = now - (now % WINDOW_SECONDS) reset_epoch = window_start + WINDOW_SECONDS key = f"rl:{client_id}:{window_start}" pipe = r.pipeline() pipe.incr(key) pipe.expire(key, WINDOW_SECONDS + 5) count, _ = await pipe.execute() remaining = max(0, LIMIT - count) allowed = count <= LIMIT return remaining, reset_epoch, allowed @app.middleware("http") async def rate_limit(request: Request, call_next): # Identify client (token preferred, fall back to IP) auth = request.headers.get("authorization", "") if auth.startswith("Bearer "): client_id = f"token:{auth[7:32]}" else: client_id = f"ip:{request.client.host}" remaining, reset_epoch, allowed = await check_rate_limit(client_id) now = int(time.time()) seconds_until_reset = reset_epoch - now if not allowed: return JSONResponse( status_code=429, content={"error": "rate limit exceeded"}, headers={ "Retry-After": str(seconds_until_reset), # X- convention "X-RateLimit-Limit": str(LIMIT), "X-RateLimit-Remaining": "0", "X-RateLimit-Reset": str(reset_epoch), # IETF standardization track "RateLimit-Limit": str(LIMIT), "RateLimit-Remaining": "0", "RateLimit-Reset": str(seconds_until_reset), "RateLimit-Policy": f"{LIMIT};w={WINDOW_SECONDS}", } ) response = await call_next(request) # X- convention response.headers["X-RateLimit-Limit"] = str(LIMIT) response.headers["X-RateLimit-Remaining"] = str(remaining) response.headers["X-RateLimit-Reset"] = str(reset_epoch) # IETF standardization track response.headers["RateLimit-Limit"] = str(LIMIT) response.headers["RateLimit-Remaining"] = str(remaining) response.headers["RateLimit-Reset"] = str(seconds_until_reset) response.headers["RateLimit-Policy"] = f"{LIMIT};w={WINDOW_SECONDS}" return response ``` **Required CORS exposure** (from framework-http-cors-headers.md): ```nginx add_header Access-Control-Expose-Headers "X-RateLimit-Limit, X-RateLimit-Remaining, X-RateLimit-Reset, RateLimit-Limit, RateLimit-Remaining, RateLimit-Reset, RateLimit-Policy, Retry-After" always; ``` Without exposing them, cross origin JavaScript cannot read these headers and the announcement is wasted. ### 6.4 How To Verify ```bash # 1. Confirm headers are present on normal responses curl -sI https://api.example.com/endpoint \ -H "Authorization: Bearer test" | grep -iE "^(x-ratelimit|ratelimit|retry-after)" # Expected: # x-ratelimit-limit: 100 # x-ratelimit-remaining: 99 # x-ratelimit-reset: 1742658660 # ratelimit-limit: 100 # ratelimit-remaining: 99 # ratelimit-reset: 60 # ratelimit-policy: 100;w=60 # 2. Verify decrement on subsequent requests for i in 1 2 3; do REMAINING=$(curl -sI https://api.example.com/endpoint \ -H "Authorization: Bearer test" \ | grep -i "x-ratelimit-remaining:" | grep -oE "[0-9]+") echo "Request $i: $REMAINING remaining" done # 3. Verify CORS exposure works # Browser console on a different origin: # fetch("https://api.example.com/endpoint", {headers: {"Authorization": "Bearer test"}}) # .then(r => console.log("Remaining:", r.headers.get("X-RateLimit-Remaining"))) # Should print a number, not null ``` ### 6.5 Troubleshooting **Symptom: Headers absent from cross origin requests despite being set.** The headers are not in `Access-Control-Expose-Headers`. Browser hides them from JavaScript. Fix: add to Expose-Headers list (Section 6.3 nginx config). **Symptom: X-RateLimit-Reset value confusion (Unix epoch vs delta).** Clients written for GitHub style API expect Unix epoch; clients written for Twitter style expect delta. Fix: document the format in API docs. For new APIs, send both forms (X-RateLimit-Reset as epoch, RateLimit-Reset as delta) for clarity. **Symptom: Different clients see different remaining counts.** The rate limit is per IP and they share an IP. Or the rate limit is per token and they share a token. Or the counter is incorrect. Fix: verify the identification logic (IP vs token vs session) is what you intend. For multi tenant SaaS, per token rate limits are the norm. **Symptom: Counter resets unexpectedly.** The application restarted (counter lived in memory). The Redis backing store evicted the key. The window calculation is off. Fix: use persistent storage for counters; configure Redis with appropriate maxmemory and policy. ### 6.6 How To Fix Common Breakage **Case: Migrating from in memory counter to Redis.** ```python # Before: in memory (lost on restart) counters = {} counters[client_id] = counters.get(client_id, 0) + 1 # After: Redis with TTL (survives restarts) import redis.asyncio as redis r = redis.from_url("redis://127.0.0.1:6379") await r.incr(f"rl:{client_id}:{window_start}") await r.expire(f"rl:{client_id}:{window_start}", WINDOW_SECONDS + 5) ``` **Case: Heterogeneous rate limits per endpoint.** Some endpoints are cheap (search), others expensive (export). Configure per endpoint limits: ```python LIMITS = { "/api/search": (1000, 60), # 1000 per minute "/api/export": (10, 3600), # 10 per hour "/api/upload": (50, 60), # 50 per minute } def get_limit(path): for prefix, limit in LIMITS.items(): if path.startswith(prefix): return limit return (100, 60) # default ``` --- ## 7. THE IETF RATELIMIT-* STANDARDIZATION (THE FUTURE) ### 7.1 Why The Standardization Matters `X-RateLimit-*` was never standardized. Every major API implements it differently: * Reset format: Unix epoch (GitHub, Stripe) vs delta seconds (Twitter). * Window interpretation: sliding (some) vs fixed (others). * What "Limit" means: per second vs per minute vs per hour. * What "Remaining" means: current request count vs throughput percentage. The IETF draft `draft-ietf-httpapi-ratelimit-headers` (in late drafts as of 2026) standardizes the format. The headers drop the `X-` prefix and define exact semantics. Modern APIs (GitLab, CircleCI, OKX) already emit both during the transition. ### 7.2 The Standardized Headers ``` RateLimit-Limit: 100 RateLimit-Remaining: 47 RateLimit-Reset: 60 RateLimit-Policy: 100;w=60 ``` | Header | Format | Definition | |---|---|---| | `RateLimit-Limit` | Integer | The expiring limit for the current window | | `RateLimit-Remaining` | Integer | Quantity of requests remaining in the current window | | `RateLimit-Reset` | Integer | Number of seconds until the limit resets (delta only, not epoch) | | `RateLimit-Policy` | Quoted policy | The policy in detail: `<limit>;w=<window_seconds>` | ### 7.3 The Policy Header `RateLimit-Policy` describes the policy being applied. Useful when multiple windows are in effect or for documenting the policy without resorting to API docs. ``` RateLimit-Policy: 100;w=60 RateLimit-Policy: 100;w=60, 1000;w=3600 RateLimit-Policy: "api-default";q=100;w=60 ``` The format is `<quota>;w=<window>` with optional policy name in quotes. Multiple policies can be listed. ### 7.4 The Single Combined RateLimit Header (Draft 10+) The latest IETF draft direction is a single combined `RateLimit` header replacing the three: ``` RateLimit: "default";r=999;t=60;pk=:Ym9vbWVy: ``` Where: * `r=` is remaining. * `t=` is reset time (delta seconds). * `pk=` is partition key (which quota bucket). This format is not yet widely deployed. For Bubbles, stick with the three header form (RateLimit-Limit, RateLimit-Remaining, RateLimit-Reset) plus RateLimit-Policy as the migration target. The X-RateLimit-* family is the present, the three RateLimit-* headers are the standardization track, the single combined header is the future once the RFC publishes. ### 7.5 How To Build It On Bubbles See Section 6.3 FastAPI sidecar example which already emits both X- and un prefixed forms. The key principle: **send both forms during the migration**. Clients written for the X- convention still work; clients written for the standard will work in the future. The header overhead is small. ### 7.6 Migration Glide Path For new APIs in 2026: 1. **Now**: emit both `X-RateLimit-*` and `RateLimit-*` forms. Document both. Use whichever clients prefer. 2. **2027 and beyond**: continue emitting both. Most ecosystems still use X-. 3. **When the RFC publishes**: stop emitting X- in new versions; keep emitting on legacy endpoints. The transition will take years. The cost of emitting both is one extra round of header lines. The benefit is forward compatibility without breaking existing clients. The cost benefit math says: emit both. --- ## 8. STATUS CODE PAIRING (429 VS 503 VS 3XX) The status code that accompanies Retry-After matters because clients react to status code first, header second. ### 8.1 429 Too Many Requests Use for: a specific client exceeded their rate quota. The service is operating normally; this specific request was rejected. ``` HTTP/1.1 429 Too Many Requests Content-Type: application/json Retry-After: 60 X-RateLimit-Limit: 100 X-RateLimit-Remaining: 0 X-RateLimit-Reset: 1742658660 {"error": "rate_limit_exceeded", "message": "Try again in 60 seconds"} ``` Defined in RFC 6585. Specifically intended for rate limiting scenarios. Crawlers including Googlebot treat 429 as "throttle me, not a problem with the site". **Critical:** nginx default for `limit_req` and `limit_conn` is 503, not 429. Configure with `limit_req_status 429` to return the correct status. ### 8.2 503 Service Unavailable Use for: the entire service is temporarily unavailable. Maintenance, overload, dependency outage. ``` HTTP/1.1 503 Service Unavailable Content-Type: text/html Retry-After: 3600 Cache-Control: no-store, no-cache, must-revalidate <html>...<body>We are doing scheduled maintenance...</body></html> ``` Defined in RFC 9110. Indicates a transient service wide problem. Crawlers treat 503 as "site is down, come back later". The 2-day rule applies: persistent 503 leads to index drops. ### 8.3 The Difference In One Sentence 429: "This specific client hit their quota; other clients are fine". 503: "This whole service is broken or down; everyone is affected". Use the right one for the right scenario. Conflating them confuses monitoring, crawlers, and clients. ### 8.4 3xx Redirects With Retry-After Less commonly used. A 3xx response with Retry-After tells the client to wait before following the redirect. ``` HTTP/1.1 301 Moved Permanently Location: https://new.example.com/page Retry-After: 60 ``` Useful for staged redirect rollouts where the destination might be cold. Most clients ignore Retry-After on 3xx; the redirect is followed immediately. ### 8.5 How To Verify Status Code Pairing ```bash # Test that rate limit produces 429 (not 503) for i in $(seq 1 200); do STATUS=$(curl -so /dev/null -w "%{http_code}" \ -H "Authorization: Bearer test" \ https://api.example.com/endpoint) if [ "$STATUS" != "200" ]; then echo "Request $i: $STATUS" break fi done # Expected: eventually "Request N: 429" # Wrong: "Request N: 503" (means limit_req_status not set) # Test that maintenance page produces 503 curl -sI https://example.com/ # during scheduled maintenance # Expected: HTTP/2 503 with Retry-After ``` --- ## 9. NGINX RATE LIMITING CONFIGURATION (LIMIT_REQ AND LIMIT_CONN) nginx has two rate limiting modules: * **`limit_req`**: requests per second per key. The most common. * **`limit_conn`**: simultaneous connections per key. Both produce 503 by default; both should be configured for 429 in API contexts. ### 9.1 The Leaky Bucket Model `limit_req` implements a leaky bucket. Imagine a bucket that fills with incoming requests. The bucket leaks at the configured rate. If the bucket overflows, additional requests are rejected (or queued). Configuration: ```nginx http { # Zone definition: 10MB shared memory, rate 10 requests per second limit_req_zone $binary_remote_addr zone=api_limit:10m rate=10r/s; } server { location /api/ { # Apply zone, allow burst of 20 above the rate limit_req zone=api_limit burst=20 nodelay; limit_req_status 429; } } ``` What this configuration does: * `rate=10r/s`: the bucket leaks 10 requests per second. * `burst=20`: the bucket can hold 20 requests above the steady rate before rejecting. * `nodelay`: process burst requests immediately (no queuing). If the bucket is full, reject. Without `nodelay`, burst requests are queued and processed at the leaked rate (spacing them out). With `nodelay`, burst requests are processed immediately as long as they fit in the bucket. ### 9.2 The Two Stage Pattern (nginx 1.15.7+) The `delay` parameter (introduced 1.15.7) gives a middle ground: some burst is processed immediately, the rest is delayed: ```nginx location /api/ { limit_req zone=api_limit burst=20 delay=10; } ``` This means: * First 10 burst requests: processed immediately (no delay). * Next 10 burst requests: queued and spaced at the rate. * Beyond burst 20: rejected. Useful for accommodating typical web browser request patterns (initial page load with concurrent asset fetches) while still throttling sustained abuse. ### 9.3 The Three Common Patterns **Pattern 1: API endpoint (strict, return 429):** ```nginx http { limit_req_zone $binary_remote_addr zone=api:10m rate=30r/s; limit_req_status 429; } location /api/ { limit_req zone=api burst=60 nodelay; } ``` **Pattern 2: Login endpoint (very strict, prevent brute force):** ```nginx http { limit_req_zone $binary_remote_addr zone=login:10m rate=1r/s; limit_req_status 429; } location /api/login { limit_req zone=login burst=5 nodelay; } ``` **Pattern 3: General site (lenient, accommodate browsers):** ```nginx http { limit_req_zone $binary_remote_addr zone=general:10m rate=100r/s; limit_req_status 429; } location / { limit_req zone=general burst=200 delay=100; } ``` ### 9.4 The Connection Limit Module `limit_conn` controls simultaneous connections rather than request rate. Useful against download abuse (someone with 100 parallel connections downloading a large file): ```nginx http { limit_conn_zone $binary_remote_addr zone=conn_per_ip:10m; limit_conn_status 429; } location /downloads/ { limit_conn conn_per_ip 5; } ``` Limit: any single IP may have at most 5 simultaneous connections to `/downloads/`. ### 9.5 Whitelisting Trusted IPs And Crawlers Use a `geo` plus `map` combination to skip rate limiting for trusted sources: ```nginx http { geo $rate_limit_skip { default 1; # Bubbles operator IPs 100.90.0.0/16 0; # Tailscale subnet 127.0.0.1 0; ::1 0; # Office VPN range # 198.51.100.0/24 0; } map $rate_limit_skip $rate_limit_key { 0 ""; # whitelisted: empty key skips rate limit 1 $binary_remote_addr; # rate limited: use IP as key } limit_req_zone $rate_limit_key zone=api:10m rate=30r/s; limit_req_status 429; } ``` When `$rate_limit_skip` is 0 (whitelisted), `$rate_limit_key` is empty, and nginx skips rate limiting entirely for that request. ### 9.6 Setting Retry-After On limit_req Rejections nginx does not automatically set `Retry-After` on `limit_req` rejections. Use a custom error page: ```nginx http { limit_req_zone $binary_remote_addr zone=api:10m rate=30r/s; limit_req_status 429; } server { location /api/ { limit_req zone=api burst=60 nodelay; proxy_pass http://127.0.0.1:9090; } error_page 429 = @rate_limited; location @rate_limited { internal; add_header Retry-After "60" always; add_header Content-Type "application/json" always; return 429 '{"error": "rate_limit_exceeded", "retry_after": 60}'; } } ``` The `@rate_limited` named location returns a JSON error body with Retry-After set. For HTML responses, swap the JSON for an HTML body. ### 9.7 Logging Rate Limit Events Default nginx logs rate limit events at `error` level, which is noisy. Configure `warn` or `notice`: ```nginx http { limit_req_zone $binary_remote_addr zone=api:10m rate=30r/s; limit_req_status 429; limit_req_log_level warn; } ``` And ensure the error log level is at least `warn`: ```nginx error_log /var/log/nginx/error.log warn; ``` Now rate limit events appear in the error log at warn level but not the noisier info events. ### 9.8 The Dry Run Mode nginx 1.17.1+ supports `limit_req_dry_run on`: log what would have been rate limited without actually rejecting. Useful for tuning limits before enforcing: ```nginx location /api/ { limit_req zone=api burst=60 nodelay; limit_req_dry_run on; # log but do not reject } ``` Run with dry_run for a week, observe the logs, adjust limits, then disable dry_run. ### 9.9 The Default Zone Sizing `zone=name:10m` means 10 MB of shared memory. nginx documentation states 1 MB holds approximately 16,000 IP addresses. 10 MB holds about 160,000. For most Bubbles sites this is generous. For very high traffic sites, increase to 50m or 100m. ### 9.10 The 503 To 429 Conversion (One Line Fix) The single most common configuration mistake is leaving the default 503. The fix: ```nginx http { # Change default 503 to 429 for all limit_req and limit_conn rejections limit_req_status 429; limit_conn_status 429; } ``` This single pair of lines should be in `nginx.conf` for every Bubbles deployment that serves APIs. --- ## 10. THE CRAWLER PROTECTION RULE (NEVER RATE LIMIT GOOGLEBOT, CLAUDEBOT, GPTBOT) The single most ranking critical decision in this framework: **do not rate limit verified search and AI crawlers**. The cost of getting it wrong is index drops, ranking loss, and AI invisibility. ### 10.1 The Crawlers To Protect Bubbles client sites depend on these crawlers for organic and AI visibility: * **Googlebot** (search.google.com): the dominant search crawler. * **Bingbot** (bing.com): the second search engine, also feeds DuckDuckGo and ChatGPT search. * **ClaudeBot, Claude-Web** (anthropic.com): Anthropic's crawlers. * **GPTBot, OAI-SearchBot** (openai.com): OpenAI's crawlers. * **PerplexityBot, Perplexity-User** (perplexity.ai): Perplexity. * **AmazonBot, Amazonbot** (amazon.com): for Alexa and product search. * **Applebot** (apple.com): for Spotlight and Siri. * **YandexBot, Baiduspider**: for international SEO (less relevant for NWA/SWMO clients). ### 10.2 Verifying Crawler Identity (User Agent Is Not Enough) User Agent strings are trivially spoofed. Real verification uses reverse DNS plus forward DNS: ```bash # 1. Resolve the request's IP back to a hostname host 66.249.66.1 # Expected for Googlebot: crawl-66-249-66-1.googlebot.com # 2. Resolve that hostname forward to an IP host crawl-66-249-66-1.googlebot.com # Expected: 66.249.66.1 (matches original) # If both match AND the hostname is in *.googlebot.com or *.google.com, it is genuine Googlebot ``` For ClaudeBot, Anthropic publishes their IP ranges. For GPTBot, OpenAI publishes IP ranges. For Bingbot, the verification is similar to Googlebot (reverse DNS to msn.com or search.msn.com). ### 10.3 The nginx Pattern: User Agent Plus IP Whitelist For Bubbles deployments, the practical approach combines user agent matching (for AI crawlers without easy reverse DNS verification) with a known good IP list: ```nginx http { # Detect crawlers by user agent (basic but useful) map $http_user_agent $is_crawler { default 0; ~*googlebot 1; ~*bingbot 1; ~*claudebot 1; ~*claude-web 1; ~*gptbot 1; ~*oai-searchbot 1; ~*chatgpt-user 1; ~*perplexitybot 1; ~*perplexity-user 1; ~*amazonbot 1; ~*applebot 1; ~*duckduckbot 1; ~*facebookexternalhit 1; ~*twitterbot 1; ~*linkedinbot 1; } # Optionally verify crawler IPs as additional check geo $is_crawler_ip { default 0; 66.249.64.0/19 1; # Googlebot range (one of several) # Add Bing, Claude, OpenAI ranges as needed } # Skip rate limiting if either user agent OR IP matches map "$is_crawler$is_crawler_ip" $rate_limit_skip { "00" 0; # not a crawler, apply rate limit default 1; # any crawler signal, skip rate limit } map $rate_limit_skip $rate_limit_key { 1 ""; # crawler: empty key skips rate limit 0 $binary_remote_addr; # normal: use IP } limit_req_zone $rate_limit_key zone=api:10m rate=30r/s; limit_req_status 429; } ``` This is permissive (allows anyone claiming to be a crawler to bypass rate limits via spoofed UA), but the risk is minimal for non sensitive endpoints. For sensitive endpoints (login, payment, admin), require strict IP verification. ### 10.4 The Bubbles Conservative Pattern: Whitelist UA Match Plus Generous Limits For all Bubbles sites, the recommended pattern is permissive UA matching combined with generous overall limits: ```nginx # Skip rate limiting for any UA matching a known crawler map $http_user_agent $rate_limit_skip { default 0; ~*googlebot 1; ~*bingbot 1; ~*claudebot 1; ~*gptbot 1; ~*oai-searchbot 1; ~*perplexitybot 1; ~*applebot 1; ~*amazonbot 1; } map $rate_limit_skip $rate_limit_key { 1 ""; 0 $binary_remote_addr; } # Generous limits for everyone else limit_req_zone $rate_limit_key zone=site:10m rate=10r/s; limit_req_status 429; server { location / { limit_req zone=site burst=50 delay=20; } } ``` If a malicious client spoofs a crawler UA to bypass rate limiting, the worst case is they get faster access to public site content (no real harm). For locations that DO need to apply to all (login, admin, payment), use a separate zone without UA whitelist. ### 10.5 The 503 Trap During Maintenance If maintenance returns 503 for everyone (including Googlebot), the 2-day rule applies. The mitigation: ```nginx # During maintenance: serve 503 to humans, 200 to crawlers (with maintenance notice) map $http_user_agent $maintenance_response { default "503"; ~*googlebot "200"; # Googlebot sees 200 with notice ~*bingbot "200"; ~*claudebot "200"; ~*gptbot "200"; } server { if ($maintenance_response = "503") { return 503; } # Crawlers fall through to normal handling (or a maintenance notice page) } ``` This is unconventional and some SEO purists object. The alternative is to keep maintenance under 24 hours so the 2-day rule does not trigger, which is the cleaner approach. ### 10.6 How To Verify Crawler Bypass ```bash # Simulate Googlebot user agent curl -sI -A "Mozilla/5.0 (compatible; Googlebot/2.1; +http://www.google.com/bot.html)" \ https://example.com/ | head -3 # Try to trigger rate limit as Googlebot (should NOT see 429) for i in $(seq 1 200); do STATUS=$(curl -so /dev/null -w "%{http_code}" \ -A "Mozilla/5.0 (compatible; Googlebot/2.1)" \ https://example.com/) if [ "$STATUS" != "200" ]; then echo "Googlebot got: $STATUS at request $i (BAD)" break fi done echo "Googlebot completed all requests at 200 (GOOD)" # Same test with a regular UA (should eventually 429) for i in $(seq 1 200); do STATUS=$(curl -so /dev/null -w "%{http_code}" \ -A "Mozilla/5.0 (BadBot/1.0)" \ https://example.com/) if [ "$STATUS" != "200" ]; then echo "BadBot got: $STATUS at request $i (correctly throttled)" break fi done ``` --- ## 11. CLIENT BACKOFF PATTERNS (EXPONENTIAL, JITTER, HONORING RETRY-AFTER) The server only emits headers; the client must read and honor them. Documenting client side patterns is part of API design. ### 11.1 The Basic Retry Pattern ```python import requests import time def request_with_retry(method, url, max_attempts=5, **kwargs): """Honor Retry-After; fall back to exponential backoff.""" for attempt in range(max_attempts): response = requests.request(method, url, **kwargs) if response.status_code in (429, 503): retry_after = response.headers.get("Retry-After") if retry_after: # Honor server's directive try: delay = int(retry_after) except ValueError: # HTTP date format from email.utils import parsedate_to_datetime from datetime import datetime, timezone target = parsedate_to_datetime(retry_after) delay = max(0, (target - datetime.now(timezone.utc)).total_seconds()) else: # Fall back to exponential backoff delay = 2 ** attempt time.sleep(delay) continue return response return response ``` ### 11.2 Adding Jitter If many clients hit a 429 simultaneously and all read the same Retry-After, they all retry at the same time, triggering another wave of 429s. Adding random jitter spreads them out: ```python import random def jittered_delay(retry_after_seconds): """Add up to 25% random delay to spread retries.""" jitter = random.uniform(0, 0.25) * retry_after_seconds return retry_after_seconds + jitter ``` ### 11.3 Proactive Self Throttling Using RateLimit-Remaining Better than reactive retry on 429: read RateLimit-Remaining and slow down before hitting zero: ```python def request_with_self_throttle(method, url, **kwargs): """Self throttle based on remaining quota.""" response = requests.request(method, url, **kwargs) remaining = int(response.headers.get("X-RateLimit-Remaining", "999")) limit = int(response.headers.get("X-RateLimit-Limit", "1")) reset = response.headers.get("X-RateLimit-Reset") # Slow down if running low if remaining < limit * 0.1: # under 10% # Calculate seconds until reset if reset: try: # Unix epoch format seconds_until_reset = int(reset) - int(time.time()) except (ValueError, OverflowError): seconds_until_reset = 60 # Spread remaining quota over time to reset if remaining > 0: delay = max(1, seconds_until_reset / remaining) time.sleep(delay) return response ``` ### 11.4 Honoring The Standardized Format For clients written for the IETF standardization track: ```python def get_retry_delay(response): """Get retry delay from any of the rate limit header forms.""" # Try Retry-After first (standardized) retry_after = response.headers.get("Retry-After") if retry_after: try: return int(retry_after) except ValueError: pass # date format, parse separately # Try RateLimit-Reset (IETF standard, delta seconds) reset = response.headers.get("RateLimit-Reset") if reset: return int(reset) # Fall back to X-RateLimit-Reset (could be epoch) reset = response.headers.get("X-RateLimit-Reset") if reset: delta = int(reset) - int(time.time()) if delta > 0: return delta return int(reset) # might already be delta # No header, fall back to default return 60 ``` --- ## 12. HOW THESE HEADERS INTERACT Several specific interactions matter. ### 12.1 Retry-After And RateLimit-Reset Both On The Same Response For a 429 response, both are appropriate: ``` HTTP/1.1 429 Too Many Requests Retry-After: 60 RateLimit-Limit: 100 RateLimit-Remaining: 0 RateLimit-Reset: 60 RateLimit-Policy: 100;w=60 ``` Retry-After is the standardized signal; RateLimit-Reset is the standardized rate limit detail. Both communicate "60 seconds until you can retry" in compatible ways. Smart clients prefer Retry-After (it is the RFC). ### 12.2 Vary: Authorization For Token Based Limits If the rate limit varies per authorization token (which it usually does in multi tenant APIs), the response varies based on the Authorization header. Add Vary: ```nginx add_header Vary "Authorization" always; ``` Without this, intermediate caches may serve cached rate limit data approved for token A to a request bearing token B. ### 12.3 CORS Expose-Headers Requirement For cross origin requests to read the rate limit headers from JavaScript, they must be in Access-Control-Expose-Headers (see framework-http-cors-headers.md): ```nginx add_header Access-Control-Expose-Headers "X-RateLimit-Limit, X-RateLimit-Remaining, X-RateLimit-Reset, RateLimit-Limit, RateLimit-Remaining, RateLimit-Reset, RateLimit-Policy, Retry-After" always; ``` Without exposure, browser JavaScript sees null for these headers even though the network panel shows them. ### 12.4 Cache-Control On 429 And 503 Responses Always pair with `Cache-Control: no-store` to prevent intermediate caches from serving stale errors: ```nginx location @rate_limited { add_header Retry-After "60" always; add_header Cache-Control "no-store, no-cache, must-revalidate" always; return 429 '...'; } ``` A cached 429 served to a different client (one who has not hit their limit) creates a confusing failure. A cached 503 served after maintenance ends extends the outage. ### 12.5 X-Robots-Tag On Rate Limited Responses Add `X-Robots-Tag: noindex` to 429 and 503 responses so search engines do not accidentally index the error page: ```nginx location @rate_limited { add_header Retry-After "60" always; add_header X-Robots-Tag "noindex, nofollow" always; return 429 '...'; } ``` This pairs with framework-http-seo-headers.md. ### 12.6 The limit_req And FastAPI Coordination If both nginx and FastAPI implement rate limiting, they must agree on policy or one will mask the other: * **nginx layer**: per IP, blunt instrument, fast (no application traversal). * **FastAPI layer**: per token, fine grained, more expensive (runs in application). Recommendation: nginx for crude DDoS protection (very generous limits, block obvious abuse). FastAPI for per token / per user limits announced via headers. They serve different purposes and do not conflict. --- ## 13. ASSET CLASS AND USE CASE RECIPES Paste ready blocks per scenario. ### 13.1 Standard Bubbles API rate limit (per IP, return 429) ```nginx # In http block of nginx.conf map $http_user_agent $rate_limit_skip { default 0; ~*googlebot 1; ~*bingbot 1; ~*claudebot 1; ~*gptbot 1; ~*oai-searchbot 1; ~*perplexitybot 1; } map $rate_limit_skip $rate_limit_key { 1 ""; 0 $binary_remote_addr; } limit_req_zone $rate_limit_key zone=api:10m rate=30r/s; limit_req_status 429; limit_req_log_level warn; ``` ```nginx # In server block location /api/ { limit_req zone=api burst=60 nodelay; proxy_pass http://127.0.0.1:9090; } error_page 429 = @rate_limited; location @rate_limited { internal; add_header Retry-After "60" always; add_header Content-Type "application/json" always; add_header Cache-Control "no-store" always; add_header X-Robots-Tag "noindex" always; return 429 '{"error": "rate_limit_exceeded", "retry_after": 60}'; } ``` ### 13.2 Login endpoint (strict, prevent brute force) ```nginx # In http block limit_req_zone $binary_remote_addr zone=login:10m rate=1r/s; limit_req_status 429; ``` ```nginx # In server block location /api/login { limit_req zone=login burst=5 nodelay; proxy_pass http://127.0.0.1:9090; } location /api/password-reset { limit_req zone=login burst=3 nodelay; proxy_pass http://127.0.0.1:9090; } ``` Plus paired with fail2ban for IPs that persistently hit the login limit: ```ini # /etc/fail2ban/jail.d/nginx-rate-limit.conf [nginx-rate-limit] enabled = true filter = nginx-rate-limit action = iptables-multiport[name=NginxRateLimit, port="http,https"] logpath = /var/log/nginx/error.log maxretry = 10 findtime = 600 bantime = 3600 ``` ### 13.3 Static site general protection (lenient, accommodate browsers) ```nginx limit_req_zone $rate_limit_key zone=site:10m rate=20r/s; limit_req_status 429; ``` ```nginx server { location / { limit_req zone=site burst=50 delay=25; try_files $uri $uri/ $uri.html =404; } error_page 429 = @rate_limited_html; location @rate_limited_html { internal; add_header Retry-After "30" always; add_header Cache-Control "no-store" always; return 429 '<html><body><h1>Slow down!</h1><p>Too many requests. Try again in 30 seconds.</p></body></html>'; } } ``` ### 13.4 Download protection (limit_conn for parallel downloads) ```nginx limit_conn_zone $binary_remote_addr zone=download_conn:10m; limit_conn_status 429; ``` ```nginx location /downloads/ { limit_conn download_conn 3; limit_rate 1m; # 1 MB/s per connection cap as a bonus } error_page 429 = @too_many_conns; location @too_many_conns { internal; add_header Retry-After "60" always; return 429 '{"error": "too_many_connections", "max": 3}'; } ``` ### 13.5 Scheduled maintenance with Retry-After and Googlebot 200 fallback ```nginx map $http_user_agent $maintenance_response { default "503"; ~*googlebot "200"; ~*bingbot "200"; ~*claudebot "200"; ~*gptbot "200"; ~*perplexitybot "200";

Search equations, builds, manuscript text, and recovered sources.