Polling Eventbrite Web APIs Without Rate Limiting
Symptom Manifestation & Diagnostic Baseline Link to this section
Event operations teams deploying automated badge printing pipelines consistently encounter a cascading failure signature: badge generation queues stall while host processes spike to 100% CPU, and downstream Eventbrite API endpoints return 429 Too Many Requests. The operational impact compounds rapidly: on-site check-in latency increases linearly, attendee records diverge from ticketing ledgers, and payment reconciliation gaps widen as unprinted badges block downstream settlement workflows. Diagnostic traces across failed deployments reveal three concurrent failure modes:
- Linear polling loops that ignore
X-RateLimit-RemainingandX-RateLimit-Resetheaders, treating the API as stateless. - Cursor drift where pagination tokens are discarded on process restart, causing identical dataset re-fetches.
- Missing idempotency guards that trigger duplicate badge generation when transient network errors force retry logic.
When webhook delivery degrades due to Eventbrite platform latency or regional network partitioning, fallback polling defaults to aggressive fixed-interval requests. This behavior directly violates established Form API Polling Strategies and introduces measurable drift in downstream Registration Ingestion & Payment Reconciliation pipelines.
Root Cause Analysis Link to this section
The failure is architectural, not infrastructural. Stateless ingestion engines decouple request velocity from API capacity constraints. Without persistent state, every restart resets to updated_after=epoch, forcing full dataset scans. Without token-bucket enforcement, retry storms exhaust the tenant request budget within seconds. Without cryptographic deduplication, network partitions cause duplicate processing, corrupting badge queues and triggering reconciliation mismatches. The combination of unbounded retry logic and missing rate-capacity awareness guarantees 429 exhaustion under any webhook degradation scenario.
Resolution Architecture Link to this section
Eliminating rate-limit exhaustion requires shifting from naive interval polling to a stateful, adaptive ingestion model. The architecture enforces four non-negotiable constraints:
- Persistent Cursor Tracking:
updated_aftertimestamps andcontinuationtokens are written to a durable SQLite store before each API call, surviving process crashes and deployments. - Token-Bucket Rate Limiting: Request dispatch aligns with Eventbrite’s documented tier limits. The bucket drains on successful requests and refills based on
X-RateLimit-Resetheaders. When capacity hits zero, dispatch blocks until the window resets. - Exponential Backoff with Jitter: Transient
5xxor429responses trigger truncated exponential backoff (base=2s,max=60s) with uniform jitter to prevent thundering herd synchronization. - Strict Idempotency: Every attendee payload is hashed via SHA-256 before processing. A bounded in-memory LRU cache prevents duplicate badge generation without unbounded memory growth.
Memory and performance boundaries are enforced at the code level: SQLite handles durable state with WAL mode for low-lock contention, the in-memory hash cache caps at 50,000 entries to prevent OOM, and pagination streams results row-by-row rather than buffering full responses.
Production-Grade Python Implementation Link to this section
The following engine requires only the Python standard library and requests. It implements cursor persistence, adaptive scheduling, token-bucket rate limiting, and cryptographic deduplication.
import os
import time
import json
import hashlib
import sqlite3
import random
import requests
from collections import OrderedDict
from typing import Optional, Dict, Any
# Configuration aligned with Eventbrite standard tier limits
EVENTBRITE_API_BASE = "https://www.eventbriteapi.com/v3"
DEFAULT_RATE_LIMIT = 100 # requests per minute
DEFAULT_RESET_WINDOW = 60 # seconds
MAX_HASH_CACHE = 50_000 # Bounded memory footprint
class EventbritePoller:
def __init__(self, api_token: str, event_id: str, db_path: str = "poller_state.db"):
self.api_token = api_token
self.event_id = event_id
self.session = requests.Session()
self.session.headers.update({
"Authorization": f"Bearer {api_token}",
"Accept": "application/json",
"User-Agent": "EventOps-BadgePipeline/1.0"
})
# Durable state store (WAL mode for concurrent read/write safety)
self.db_path = db_path
self._init_db()
# Token bucket state
self.tokens = DEFAULT_RATE_LIMIT
self.last_refill = time.time()
# Bounded idempotency cache (LRU eviction)
self.processed_hashes = OrderedDict()
def _init_db(self):
with sqlite3.connect(self.db_path) as conn:
conn.execute("PRAGMA journal_mode=WAL;")
conn.execute("""
CREATE TABLE IF NOT EXISTS poll_state (
key TEXT PRIMARY KEY,
value TEXT
)
""")
conn.commit()
def _get_state(self, key: str) -> Optional[str]:
with sqlite3.connect(self.db_path) as conn:
cur = conn.execute("SELECT value FROM poll_state WHERE key=?", (key,))
row = cur.fetchone()
return row[0] if row else None
def _set_state(self, key: str, value: str):
with sqlite3.connect(self.db_path) as conn:
conn.execute("INSERT OR REPLACE INTO poll_state (key, value) VALUES (?, ?)", (key, value))
conn.commit()
def _hash_payload(self, payload: Dict[str, Any]) -> str:
# Deterministic hash of attendee ID + updated timestamp
canonical = json.dumps({
"id": payload.get("id"),
"updated": payload.get("updated")
}, sort_keys=True)
return hashlib.sha256(canonical.encode()).hexdigest()
def _is_duplicate(self, payload_hash: str) -> bool:
if payload_hash in self.processed_hashes:
return True
if len(self.processed_hashes) >= MAX_HASH_CACHE:
self.processed_hashes.popitem(last=False) # Evict oldest
self.processed_hashes[payload_hash] = True
return False
def _wait_for_token(self):
"""Token-bucket rate limiter with header-aware refill."""
while self.tokens <= 0:
now = time.time()
elapsed = now - self.last_refill
if elapsed >= DEFAULT_RESET_WINDOW:
self.tokens = DEFAULT_RATE_LIMIT
self.last_refill = now
else:
time.sleep(1)
def _consume_token(self, headers: Dict[str, str]):
remaining = headers.get("X-RateLimit-Remaining")
if remaining is not None:
self.tokens = max(0, int(remaining))
else:
self.tokens -= 1
def _calculate_backoff(self, attempt: int) -> float:
"""Truncated exponential backoff with uniform jitter."""
delay = min(2 ** attempt, 60)
jitter = random.uniform(0, delay * 0.25)
return delay + jitter
def fetch_attendees(self):
"""Stateful, rate-limited, idempotent attendee ingestion loop."""
continuation = self._get_state("continuation_token")
updated_after = self._get_state("updated_after") or "2020-01-01T00:00:00Z"
attempt = 0
while True:
self._wait_for_token()
params = {
"status": "attending",
"updated_after": updated_after,
"expand": "ticket_class"
}
if continuation:
params["continuation"] = continuation
try:
resp = self.session.get(
f"{EVENTBRITE_API_BASE}/events/{self.event_id}/attendees/",
params=params,
timeout=15
)
resp.raise_for_status()
attempt = 0 # Reset on success
# Consume rate limit token
self._consume_token(resp.headers)
data = resp.json()
attendees = data.get("attendees", [])
# Process stream
for attendee in attendees:
payload_hash = self._hash_payload(attendee)
if self._is_duplicate(payload_hash):
continue
self._process_attendee(attendee)
# Advance monotonic cursor
if attendee.get("updated") and attendee["updated"] > updated_after:
updated_after = attendee["updated"]
# Persist state immediately after batch
self._set_state("updated_after", updated_after)
# Handle pagination
pagination = data.get("pagination", {})
continuation = pagination.get("continuation")
if continuation:
self._set_state("continuation_token", continuation)
# Adaptive sleep: scale with remaining rate capacity
sleep_time = max(0.5, (1 - (self.tokens / DEFAULT_RATE_LIMIT)) * 5)
time.sleep(sleep_time)
continue
else:
# End of dataset reached, reset pagination cursor
self._set_state("continuation_token", "")
print("Poll cycle complete. Waiting for next window.")
time.sleep(DEFAULT_RESET_WINDOW)
updated_after = time.strftime("%Y-%m-%dT%H:%M:%SZ", time.gmtime())
self._set_state("updated_after", updated_after)
continue
except requests.exceptions.HTTPError as e:
if e.response.status_code == 429:
retry_after = int(e.response.headers.get("Retry-After", 10))
print(f"Rate limited. Backing off for {retry_after}s")
time.sleep(retry_after)
continue
elif e.response.status_code >= 500:
delay = self._calculate_backoff(attempt)
print(f"Server error {e.response.status_code}. Backing off {delay:.1f}s")
time.sleep(delay)
attempt += 1
continue
raise
except requests.exceptions.RequestException as e:
delay = self._calculate_backoff(attempt)
print(f"Network error. Backing off {delay:.1f}s")
time.sleep(delay)
attempt += 1
continue
def _process_attendee(self, attendee: Dict[str, Any]):
"""Idempotent badge generation / downstream dispatch hook."""
# Replace with actual badge print queue push or reconciliation sync
pass
if __name__ == "__main__":
TOKEN = os.getenv("EVENTBRITE_API_TOKEN")
EVENT_ID = os.getenv("EVENTBRITE_EVENT_ID")
if not TOKEN or not EVENT_ID:
raise EnvironmentError("Set EVENTBRITE_API_TOKEN and EVENTBRITE_EVENT_ID")
poller = EventbritePoller(TOKEN, EVENT_ID)
poller.fetch_attendees()
Incident Response & Rollback Procedures Link to this section
When rate-limit exhaustion or badge queue corruption occurs, execute the following sequence to restore stability within 5 minutes:
- Immediate Triage:
- Pause the poller process (
SIGSTOPor container scale-to-zero). - Verify
poller_state.dbintegrity:sqlite3 poller_state.db "PRAGMA integrity_check;" - Drain in-memory queues to prevent duplicate badge dispatch.
- State Recovery:
- Restore
poller_state.dbfrom the last known-good backup if cursor drift is detected. - Flush
processed_hashesby restarting the service (LRU cache is ephemeral by design).
- Rollback Execution:
- Revert to the previous stable container image or deployment revision.
- Clear any stuck
continuation_tokenvalues that point to expired Eventbrite cursors:sqlite3 poller_state.db "DELETE FROM poll_state WHERE key='continuation_token';" - Restart with
EVENTBRITE_API_TOKENandEVENTBRITE_EVENT_IDvalidated.
- Post-Incident Validation:
- Monitor
X-RateLimit-Remainingheaders via structured logs. Alert if< 10for >60 seconds. - Verify badge print queue depth matches
updated_afterprogression. - Confirm payment reconciliation gap closes within one full poll cycle.
For deeper backoff implementation standards, reference the AWS Architecture Blog on Exponential Backoff and Jitter. For SQLite concurrency tuning, consult the official Python sqlite3 documentation.