Twitter (X) API Error Handling — Production Best Practices
Twitter (X) API error handling is one of those topics that looks trivial in a hello-world tutorial and turns into weeks of debugging when your production integration starts silently dropping data or getting your account suspended. The API's error surface is broader than most developers expect on first exposure.
This guide walks the production-grade patterns: the two error surfaces (HTTP status + body errors array), the specific status codes you'll actually encounter, retry/backoff patterns with runnable Python, and the account-safety heuristics that keep you from getting rate-throttled or suspended.
The two error surfaces — HTTP status + body errors
Most API tutorials teach 'check HTTP status, retry on 5xx'. X's API is stricter — some errors return HTTP 200 with an errors array in the JSON body, and some HTTP 403 responses actually mean 'your rate limit is fine, but the specific tweet violated policy'.
Always check both: response.status_code AND response.json().get('errors'). Body errors typically have a code field (integer) and message (string) — the code is the meaningful signal, message is human-facing.
Common body error codes (docs.x.com/x-api/fundamentals/errors reference):
- 32: could not authenticate you — bearer token invalid
- 34: page does not exist — tweet was deleted or never existed
- 50: user not found — handle was deleted / suspended / renamed
- 63: user suspended — target account is suspended by X
- 88: rate limit exceeded (also comes with HTTP 429)
- 89: invalid or expired token — refresh OAuth
- 131: internal error — treat as 5xx equivalent
- 179: not authorized to see this status (protected/blocked)
- 185: user is over daily status update limit
- 187: status is a duplicate
- 215: bad authentication data
- 226: this request looks like it might be automated (soft policy warning — see dedicated twitter-request-looks-like-it-might-be-automated-error-226 reference for account-safety mitigations)
HTTP status decision tree
This applies uniformly across twitterapi.io + X official endpoints (both mirror the X error grammar).
Runnable — production-grade error handler
A single decorator/wrapper that handles all four error classes with sane defaults:
import os, requests, time, random, json
from typing import Callable
HEADERS = {"X-API-Key": os.environ["TWITTERAPI_IO_KEY"]}
BASE = "https://api.twitterapi.io"
class TwitterAPIError(Exception):
def __init__(self, http_status: int, body_code: int | None = None, message: str = ""):
self.http_status = http_status
self.body_code = body_code
self.message = message
super().__init__(f"HTTP {http_status} / body {body_code}: {message}")
def call_with_retry(method: Callable, url: str, max_retries: int = 5, **kwargs):
"""Wraps a requests call with production error handling."""
for attempt in range(max_retries):
try:
r = method(url, **kwargs, timeout=15)
except requests.RequestException as e:
# network / timeout — retry once with short backoff
if attempt >= 2: raise
time.sleep(1 + random.uniform(0, 1))
continue
# 429 rate-limit — respect x-rate-limit-reset if present, else exponential backoff
if r.status_code == 429:
reset = r.headers.get("x-rate-limit-reset")
wait = max(int(reset) - int(time.time()), 5) if reset else (2 ** attempt) + random.uniform(0, 2)
print(f" rate-limited, sleeping {wait:.1f}s")
time.sleep(wait); continue
# 5xx — exponential backoff with jitter, circuit-break after max_retries
if 500 <= r.status_code < 600:
wait = (2 ** attempt) + random.uniform(0, 2)
print(f" 5xx {r.status_code}, sleeping {wait:.1f}s")
time.sleep(wait); continue
# 4xx (not 429) — parse body for structured error, do NOT retry
if 400 <= r.status_code < 500:
body = r.json() if r.headers.get("content-type", "").startswith("application/json") else {}
errors = body.get("errors", [])
body_code = errors[0].get("code") if errors else None
body_msg = errors[0].get("message") if errors else r.text[:200]
raise TwitterAPIError(r.status_code, body_code, body_msg)
# 200 — check for body errors
try:
body = r.json()
except ValueError:
return r # non-JSON success
if body.get("errors"):
body_code = body["errors"][0].get("code")
body_msg = body["errors"][0].get("message")
# code 88 = rate limit surfaced as 200 body error — treat as 429
if body_code == 88:
time.sleep((2 ** attempt) + random.uniform(0, 2)); continue
raise TwitterAPIError(r.status_code, body_code, body_msg)
return r
raise TwitterAPIError(0, None, f"max_retries ({max_retries}) exceeded")
# Usage
r = call_with_retry(requests.get, f"{BASE}/twitter/tweet/advanced_search",
headers=HEADERS, params={"query": "openai lang:en"})
tweets = r.json().get("tweets", [])
print(f"got {len(tweets)} tweets")The 4 retry/backoff patterns compared
Practical stack: exponential backoff + jitter as the default retry, dead-letter queue for structured 4xx errors, circuit breaker at the HTTP-client layer per-host.
Rate-limit specifics per docs.x.com
Response headers to inspect (per docs.x.com/x-api/rate-limits):
- x-rate-limit-limit — total allowed requests in the current window
- x-rate-limit-remaining — requests left in this window
- x-rate-limit-reset — Unix epoch when the window resets
Best practice: after every request, log x-rate-limit-remaining — if it drops below 10% of the total, proactively slow down before hitting the ceiling.
Per-tier limits vary — Basic tier has stricter limits than Pro; twitterapi.io has its own per-key throughput ceilings. Check your provider's current published limits before designing bulk workflows.
Rate-limit RECOVERY takes 5-15 minutes depending on the endpoint's window. Don't retry aggressively — you'll extend the throttle window.
Account-safety heuristics (avoid 226 + suspension)
226 'automated request' code: X's soft warning that your traffic pattern looks bot-like. Continued triggering escalates to account suspension.
Triggers: burst posting (>5 tweets/minute), unnaturally consistent timing (exactly every N seconds), high-volume follows in short window, missing normal-user headers.
Mitigations:
1. Human-like pacing (3-6s per action + jitter, spread across business hours)
2. Vary user-agent + accept-language per your app's realistic profile
3. Multi-account distribution for high-volume workflows (don't stack 1000 actions on 1 account)
4. Never combine write + follow + like automation on the same account in the same hour — spread across accounts + time-of-day
See /blog/twitter-request-looks-like-it-might-be-automated-error-226 for the full mitigation playbook.
Comparison — 2 error-handling implementations
# Complete production pattern: bulk pull with error handler + circuit breaker + logging.
import os, requests, time, random, json, logging
from typing import Callable
from collections import deque
from pathlib import Path
logging.basicConfig(level=logging.INFO, format="%(asctime)s %(levelname)s %(message)s")
log = logging.getLogger(__name__)
HEADERS = {"X-API-Key": os.environ["TWITTERAPI_IO_KEY"]}
BASE = "https://api.twitterapi.io"
class CircuitBreaker:
def __init__(self, max_failures: int = 5, cool_off_sec: int = 300):
self.max_failures = max_failures
self.cool_off_sec = cool_off_sec
self.failures = deque(maxlen=max_failures)
self.open_until = 0.0
def is_open(self) -> bool:
return time.time() < self.open_until
def record_failure(self):
now = time.time()
self.failures.append(now)
if len(self.failures) == self.max_failures and now - self.failures[0] < 60:
self.open_until = now + self.cool_off_sec
log.warning(f"circuit-breaker OPEN for {self.cool_off_sec}s")
def record_success(self):
self.failures.clear()
CB = CircuitBreaker()
def bulk_pull(query: str, out_path: str, max_pages: int = 20):
if CB.is_open():
log.error("circuit breaker open, aborting")
return
out = Path(out_path)
n, cursor = 0, None
with open(out, "w") as f:
for page in range(max_pages):
for attempt in range(5):
try:
params = {"query": query}
if cursor: params["cursor"] = cursor
r = requests.get(f"{BASE}/twitter/tweet/advanced_search",
headers=HEADERS, params=params, timeout=15)
if r.status_code == 429:
reset = r.headers.get("x-rate-limit-reset")
wait = max(int(reset) - int(time.time()), 5) if reset else (2 ** attempt) + random.uniform(0, 2)
log.info(f"429, waiting {wait:.0f}s (attempt {attempt+1})")
time.sleep(wait); continue
if r.status_code >= 500:
CB.record_failure()
time.sleep((2 ** attempt) + random.uniform(0, 2)); continue
r.raise_for_status()
body = r.json()
for t in body.get("tweets", []):
f.write(json.dumps(t) + "\n"); n += 1
cursor = body.get("next_cursor")
CB.record_success()
break
except requests.RequestException as e:
log.warning(f"network error: {e}, attempt {attempt+1}")
if attempt >= 4:
CB.record_failure(); raise
time.sleep((2 ** attempt) + random.uniform(0, 2))
if not cursor: break
log.info(f"pull complete: {n} tweets")
bulk_pull("anthropic lang:en min_faves:10", "anthropic_signal.jsonl")
# Cost per twitterapi.io/pricing: n × $0.00015Questions readers ask
Why do I get HTTP 200 with an errors array?
X's API design treats certain classes (per-item errors on batch endpoints, permission-based hides on partial data) as 'partial success' rather than full failure. Always parse response.json().get('errors') even on 200.
What's the difference between HTTP 429 and body code 88?
Both mean rate-limit exceeded. HTTP 429 is the primary signal (with x-rate-limit-reset header for wait time). Body code 88 sometimes appears on HTTP 200 responses when partial rate-limiting is applied. Handle both with the same backoff logic.
How long should I back off on 5xx?
Exponential backoff: 2, 4, 8, 16, 32 seconds with ±2s jitter. After 5 consecutive 5xx, open a circuit-breaker for 5 minutes rather than continuing to hammer. Log the failure pattern for post-mortem.
Should I retry on 401 unauthorized?
Only if you can refresh the token first. 401 usually means bearer/OAuth token expired. Refresh once + retry once; if still 401, the token is invalid + must be regenerated (don't retry-loop).
What about network timeouts — same retry logic?
Timeouts are usually transient (network hiccup). Retry once with a short back-off (1-2s + jitter). If a timeout repeats immediately, treat as 5xx-equivalent for backoff purposes.
How do I get 226 to stop triggering?
226 is a soft policy warning about traffic pattern. Slow down, add jitter, spread across time-of-day + accounts. Repeated 226 escalates to hard suspension. See /blog/twitter-request-looks-like-it-might-be-automated-error-226 for the full playbook.
Is the error handling the same between twitterapi.io and X official?
Very close — both mirror X's native error grammar (HTTP status + body errors[].code). twitterapi.io may occasionally translate specific upstream errors to a normalized format; check the specific error code in the body first.
Continue
- twitterapi.io — pricing
- X — API errors reference (body error codes)
- X — rate limits (429 handling headers)
- X API — pricing
- Twitter (X) API — cluster hub
- Twitter (X) rate-limit exceeded — dedicated 429 guide
- Error 226 — automated request soft-warning mitigations
- Twitter (X) API Python — production-grade guide
- Twitter (X) API pricing breakdown
- twitterapi.io pricing
Stop reading. Start building.
Starter credits cover real testing on real data. Google sign-in, no card, no application queue.
Get an API key