twitterapi.io is an independent third-party service. Not affiliated with X Corp.

Blogtwitter api error handling

Twitter (X) API Error Handling — Production Best Practices

By Michael Park5 min read

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.

01 — Section

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)

02 — Section

HTTP status decision tree

HTTP statusMeaningRetry?
200 + no body errorssuccessno
200 + body errors arraypartial failure — parse errors[], act per codedepends on code
400your query is malformedNO (fix the request)
401auth invalid/expiredrefresh token then retry once
403forbidden (policy/permission/private)check body code — 226 no retry; others depend
404not found (deleted or never existed)no
429rate limit exceededyes with backoff — see x-rate-limit-reset
5xxserver errorretry with exponential backoff + circuit breaker
network timeoutinfrastructureretry once (may be transient)

This applies uniformly across twitterapi.io + X official endpoints (both mirror the X error grammar).

03 — Section

Runnable — production-grade error handler

A single decorator/wrapper that handles all four error classes with sane defaults:

python
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")
04 — Section

The 4 retry/backoff patterns compared

PatternWhen to useDownside
Exponential backoff + jitter5xx + generic 429can burn user-perceived latency on repeated 5xx
Token bucket (leaky-bucket rate limiter your side)high-throughput write opsrequires state management + tuning
Dead-letter queuenon-retryable errors (4xx code 226, 63, 179)needs separate reprocessing pipeline
Circuit breakercascading 5xx / rate-limit-per-account trippingrequires monitoring + auto-open threshold

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.

05 — Section

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.

06 — Section

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.

07 — Section

Comparison — 2 error-handling implementations

DimensionNaive (200-only check)Production (dual-surface + backoff)
Catches HTTP errors
Catches body errors on 200✗ (silently drops data)
Rate-limit awareness✓ (respects x-rate-limit-reset)
Retry logicnone / naive-immediateexponential backoff + jitter
Account-safety pacing✓ (jitter + human-like cadence)
Cost impact on failureswasted API calls on retriesminimized via structured backoff
Failure mode discoverabilitysilent dropsstructured errors + logging
python
# 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.00015
08 — Questions

Questions 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.

09 — Further reading

Continue

Sources & further reading
More from this series
Build it

Stop reading. Start building.

Starter credits cover real testing on real data. Google sign-in, no card, no application queue.

Get an API key
    Twitter (X) API Error Handling — Patterns | TwitterAPI.io