Tutorial 11 min read

Production-Ready Error Handling for OnlyFans API Integrations

Most API integrations work fine until they don't — then they fail silently, corrupt data, or bring down dependent services. This is the error handling architecture we run in production: typed error classification, exponential backoff with jitter, rate limit queues, circuit breakers, and structured logging.

By OFAPI Team · · 11 min read

The first version of our API integration had one error handling strategy: try/catch, log the error, move on. It worked well enough for the first few weeks. Then we had a night where the API returned 429s for twenty minutes during a high-traffic period, our polling loop silently skipped every call, and we woke up to a revenue dashboard that showed zero earnings for six hours. The data existed — we just had not pulled it.

The second version had retry logic. The third version had proper error classification. The version we run now has typed errors, exponential backoff with jitter, a token bucket rate limiter, circuit breakers per creator account, structured logging with correlation IDs, and a dead letter store for events that exhaust all retries.

None of it is complicated. All of it matters at production scale.

Error Classification

Not all errors are equal. A 404 means the resource does not exist — retrying will not help. A 429 means you are hitting rate limits — you need to back off and wait. A 503 means the upstream is temporarily unavailable — retry with backoff. A network timeout might be transient or might indicate a deeper problem — retry a limited number of times.

Start by classifying errors:

export enum ErrorType {
  // Transient — safe to retry
  RateLimit = "RATE_LIMIT",          // 429
  ServerError = "SERVER_ERROR",      // 500, 502, 503, 504
  NetworkError = "NETWORK_ERROR",    // ECONNRESET, ETIMEDOUT, ENOTFOUND

  // Permanent — retrying will not help
  NotFound = "NOT_FOUND",            // 404
  Unauthorized = "UNAUTHORIZED",     // 401
  Forbidden = "FORBIDDEN",           // 403
  ValidationError = "VALIDATION",    // 400, 422
  Gone = "GONE",                     // 410

  // Unknown — treat as transient with limited retries
  Unknown = "UNKNOWN",
}

export class APIError extends Error {
  constructor(
    public readonly type: ErrorType,
    public readonly statusCode: number | null,
    public readonly message: string,
    public readonly retryAfterMs?: number,
    public readonly requestId?: string
  ) {
    super(message);
    this.name = "APIError";
  }

  get isRetryable(): boolean {
    return (
      this.type === ErrorType.RateLimit ||
      this.type === ErrorType.ServerError ||
      this.type === ErrorType.NetworkError ||
      this.type === ErrorType.Unknown
    );
  }
}

export function classifyError(
  statusCode: number | null,
  headers?: Record<string, string>,
  nodeError?: NodeJS.ErrnoException
): APIError {
  // Network-level errors (no HTTP status)
  if (nodeError) {
    const networkCodes = ["ECONNRESET", "ETIMEDOUT", "ECONNREFUSED", "ENOTFOUND", "EPIPE"];
    if (networkCodes.includes(nodeError.code ?? "")) {
      return new APIError(ErrorType.NetworkError, null, `Network error: ${nodeError.code}`);
    }
  }

  if (!statusCode) {
    return new APIError(ErrorType.Unknown, null, "Unknown error — no status code");
  }

  switch (statusCode) {
    case 400:
    case 422:
      return new APIError(ErrorType.ValidationError, statusCode, "Validation error");
    case 401:
      return new APIError(ErrorType.Unauthorized, statusCode, "Authentication failed");
    case 403:
      return new APIError(ErrorType.Forbidden, statusCode, "Insufficient permissions");
    case 404:
      return new APIError(ErrorType.NotFound, statusCode, "Resource not found");
    case 410:
      return new APIError(ErrorType.Gone, statusCode, "Resource permanently removed");
    case 429: {
      // Respect the Retry-After header if present
      const retryAfter = headers?.["retry-after"];
      const retryAfterMs = retryAfter
        ? parseInt(retryAfter, 10) * 1000
        : 60_000; // Default to 60s if header missing
      return new APIError(
        ErrorType.RateLimit,
        statusCode,
        "Rate limit exceeded",
        retryAfterMs
      );
    }
    case 500:
    case 502:
    case 503:
    case 504:
      return new APIError(ErrorType.ServerError, statusCode, `Server error: ${statusCode}`);
    default:
      if (statusCode >= 500) {
        return new APIError(ErrorType.ServerError, statusCode, `Server error: ${statusCode}`);
      }
      return new APIError(ErrorType.Unknown, statusCode, `Unexpected status: ${statusCode}`);
  }
}

Exponential Backoff with Jitter

Exponential backoff increases the wait time between retries exponentially. Jitter adds randomness to prevent the thundering herd problem — if all your retries are synchronized (e.g., all triggered by the same event), they will hammer the API simultaneously on each retry interval. Jitter spreads them out.

interface RetryConfig {
  maxAttempts: number;
  baseDelayMs: number;
  maxDelayMs: number;
  jitterFactor: number;  // 0 to 1 — fraction of delay to randomize
}

const DEFAULT_RETRY_CONFIG: RetryConfig = {
  maxAttempts: 4,
  baseDelayMs: 1_000,     // 1 second base
  maxDelayMs: 30_000,     // 30 second cap
  jitterFactor: 0.3,      // ±30% jitter
};

function calculateBackoffMs(
  attempt: number,         // 0-indexed — first retry is attempt 1
  config: RetryConfig,
  rateLimitRetryAfterMs?: number
): number {
  // If the server told us exactly when to retry, respect that
  if (rateLimitRetryAfterMs) {
    return rateLimitRetryAfterMs;
  }

  // Exponential backoff: base * 2^attempt
  const exponential = config.baseDelayMs * Math.pow(2, attempt);
  const capped = Math.min(exponential, config.maxDelayMs);

  // Full jitter: randomize between [capped * (1 - jitterFactor), capped]
  const jitterRange = capped * config.jitterFactor;
  const jitter = Math.random() * jitterRange - jitterRange / 2;

  return Math.max(0, Math.round(capped + jitter));
}

async function withRetry<T>(
  operation: () => Promise<T>,
  config: RetryConfig = DEFAULT_RETRY_CONFIG,
  context: { operationName: string; creatorId?: string } = { operationName: "unknown" }
): Promise<T> {
  let lastError: APIError | null = null;

  for (let attempt = 0; attempt < config.maxAttempts; attempt++) {
    try {
      return await operation();
    } catch (err) {
      const apiError = err instanceof APIError ? err : toAPIError(err);
      lastError = apiError;

      // Do not retry permanent errors
      if (!apiError.isRetryable) {
        throw apiError;
      }

      // Do not retry after the last attempt
      if (attempt === config.maxAttempts - 1) {
        break;
      }

      const delayMs = calculateBackoffMs(attempt, config, apiError.retryAfterMs);

      console.warn("Retrying after error", {
        operation: context.operationName,
        creatorId: context.creatorId,
        attempt: attempt + 1,
        maxAttempts: config.maxAttempts,
        errorType: apiError.type,
        statusCode: apiError.statusCode,
        delayMs,
      });

      await sleep(delayMs);
    }
  }

  throw lastError ?? new APIError(ErrorType.Unknown, null, "Retry exhausted");
}

function sleep(ms: number): Promise<void> {
  return new Promise((resolve) => setTimeout(resolve, ms));
}

function toAPIError(err: unknown): APIError {
  if (err instanceof APIError) return err;
  if (err instanceof Error) {
    return new APIError(ErrorType.Unknown, null, err.message);
  }
  return new APIError(ErrorType.Unknown, null, String(err));
}

Usage is a single wrapper around any API call:

const topFans = await withRetry(
  () => fetchTopFans(creatorId),
  DEFAULT_RETRY_CONFIG,
  { operationName: "fetchTopFans", creatorId }
);

Rate Limiting: Understanding Limits and Implementing Queues

The API enforces rate limits at the account level. Hitting a 429 does not mean you did something wrong — it means your request volume exceeded the allowed rate for that window. The operational challenge for agencies is that you have many creator accounts, each making API calls, potentially on overlapping schedules.

A token bucket implementation gives you precise control over throughput:

class TokenBucket {
  private tokens: number;
  private lastRefill: number;

  constructor(
    private readonly capacity: number,      // Max tokens
    private readonly refillRatePerMs: number // Tokens added per millisecond
  ) {
    this.tokens = capacity;
    this.lastRefill = Date.now();
  }

  tryConsume(count: number = 1): boolean {
    this.refill();
    if (this.tokens >= count) {
      this.tokens -= count;
      return true;
    }
    return false;
  }

  async consume(count: number = 1): Promise<void> {
    while (!this.tryConsume(count)) {
      // Calculate how long until we have enough tokens
      const tokensNeeded = count - this.tokens;
      const waitMs = Math.ceil(tokensNeeded / this.refillRatePerMs);
      await sleep(Math.min(waitMs, 100)); // Re-check every 100ms at most
    }
  }

  private refill(): void {
    const now = Date.now();
    const elapsed = now - this.lastRefill;
    const newTokens = elapsed * this.refillRatePerMs;
    this.tokens = Math.min(this.capacity, this.tokens + newTokens);
    this.lastRefill = now;
  }
}

// Global rate limiter shared across all API calls
// Adjust capacity and rate to match your API tier
const globalRateLimiter = new TokenBucket(
  100,          // burst of 100 requests
  100 / 60_000  // sustain 100 requests per minute
);

async function makeAPIRequest<T>(
  url: string,
  params: Record<string, string>
): Promise<T> {
  // Wait for a token before making the request
  await globalRateLimiter.consume();

  const response = await fetch(url + "?" + new URLSearchParams(params), {
    headers: { "X-API-Key": process.env.OFAPI_KEY! },
  });

  if (!response.ok) {
    const headers = Object.fromEntries(response.headers.entries());
    throw classifyError(response.status, headers);
  }

  return response.json() as Promise<T>;
}

For agencies with many creator accounts running concurrent jobs, add a per-creator semaphore to prevent any one creator’s jobs from monopolizing the global rate limit:

class Semaphore {
  private queue: Array<() => void> = [];
  private running = 0;

  constructor(private readonly maxConcurrent: number) {}

  async acquire(): Promise<void> {
    if (this.running < this.maxConcurrent) {
      this.running++;
      return;
    }
    return new Promise((resolve) => {
      this.queue.push(() => {
        this.running++;
        resolve();
      });
    });
  }

  release(): void {
    this.running--;
    const next = this.queue.shift();
    if (next) next();
  }

  async run<T>(fn: () => Promise<T>): Promise<T> {
    await this.acquire();
    try {
      return await fn();
    } finally {
      this.release();
    }
  }
}

// Max 3 concurrent API calls per creator
const perCreatorSemaphores = new Map<string, Semaphore>();

function getCreatorSemaphore(creatorId: string): Semaphore {
  if (!perCreatorSemaphores.has(creatorId)) {
    perCreatorSemaphores.set(creatorId, new Semaphore(3));
  }
  return perCreatorSemaphores.get(creatorId)!;
}

async function fetchCreatorData<T>(
  creatorId: string,
  operation: () => Promise<T>
): Promise<T> {
  return getCreatorSemaphore(creatorId).run(operation);
}

Circuit Breaker Pattern

A circuit breaker prevents your integration from hammering an API that is consistently failing. Without one, a sustained API outage causes every API call to time out, consuming threads and generating noise. The circuit breaker short-circuits calls after a failure threshold and only allows a test request through after a recovery window.

Three states:

  • Closed — normal operation, requests go through
  • Open — too many failures, requests fail immediately without hitting the API
  • Half-Open — recovery window elapsed, one test request is allowed through
enum CircuitState {
  Closed = "CLOSED",
  Open = "OPEN",
  HalfOpen = "HALF_OPEN",
}

interface CircuitBreakerConfig {
  failureThreshold: number;    // Open after this many consecutive failures
  recoveryWindowMs: number;    // How long to stay open before trying again
  successThreshold: number;    // Successes in half-open before closing
}

class CircuitBreaker {
  private state: CircuitState = CircuitState.Closed;
  private failureCount = 0;
  private successCount = 0;
  private lastFailureTime: number | null = null;

  constructor(
    private readonly name: string,
    private readonly config: CircuitBreakerConfig
  ) {}

  async execute<T>(operation: () => Promise<T>): Promise<T> {
    if (this.state === CircuitState.Open) {
      const elapsed = Date.now() - (this.lastFailureTime ?? 0);
      if (elapsed < this.config.recoveryWindowMs) {
        throw new APIError(
          ErrorType.ServerError,
          null,
          `Circuit breaker open for ${this.name}. ` +
          `Retry in ${Math.ceil((this.config.recoveryWindowMs - elapsed) / 1000)}s`
        );
      }
      // Transition to half-open and let one request through
      this.state = CircuitState.HalfOpen;
      console.log(`Circuit breaker half-open: ${this.name}`);
    }

    try {
      const result = await operation();
      this.onSuccess();
      return result;
    } catch (err) {
      this.onFailure();
      throw err;
    }
  }

  private onSuccess(): void {
    this.failureCount = 0;

    if (this.state === CircuitState.HalfOpen) {
      this.successCount++;
      if (this.successCount >= this.config.successThreshold) {
        this.state = CircuitState.Closed;
        this.successCount = 0;
        console.log(`Circuit breaker closed: ${this.name}`);
      }
    }
  }

  private onFailure(): void {
    this.failureCount++;
    this.lastFailureTime = Date.now();
    this.successCount = 0;

    if (
      this.state !== CircuitState.Open &&
      this.failureCount >= this.config.failureThreshold
    ) {
      this.state = CircuitState.Open;
      console.error(`Circuit breaker opened: ${this.name}`, {
        failureCount: this.failureCount,
        recoveryWindowMs: this.config.recoveryWindowMs,
      });
    }
  }

  getState(): CircuitState {
    return this.state;
  }
}

// One circuit breaker per creator account
const circuitBreakers = new Map<string, CircuitBreaker>();

function getCircuitBreaker(creatorId: string): CircuitBreaker {
  if (!circuitBreakers.has(creatorId)) {
    circuitBreakers.set(
      creatorId,
      new CircuitBreaker(`creator-${creatorId}`, {
        failureThreshold: 5,
        recoveryWindowMs: 60_000,  // 1 minute
        successThreshold: 2,
      })
    );
  }
  return circuitBreakers.get(creatorId)!;
}

Stack the circuit breaker with retry logic:

async function fetchWithProtection<T>(
  creatorId: string,
  operationName: string,
  operation: () => Promise<T>
): Promise<T> {
  const breaker = getCircuitBreaker(creatorId);

  return breaker.execute(() =>
    withRetry(operation, DEFAULT_RETRY_CONFIG, { operationName, creatorId })
  );
}

// Usage
const stats = await fetchWithProtection(
  creatorId,
  "fetchPayoutStats",
  () => fetchPayoutStats(creatorId, startDate, endDate)
);

Structured Logging and Monitoring

Unstructured log lines are useless when you are trying to understand what happened at 2am. Structured logging — every log entry is a JSON object with consistent fields — makes logs queryable.

interface LogEntry {
  timestamp: string;
  level: "debug" | "info" | "warn" | "error";
  message: string;
  correlationId?: string;
  creatorId?: string;
  operationName?: string;
  errorType?: string;
  statusCode?: number;
  durationMs?: number;
  attempt?: number;
  [key: string]: unknown;
}

function log(level: LogEntry["level"], message: string, fields: Partial<LogEntry> = {}): void {
  const entry: LogEntry = {
    timestamp: new Date().toISOString(),
    level,
    message,
    ...fields,
  };
  // In production, send to your log aggregator (Datadog, Logtail, etc.)
  // For local dev, pretty print
  if (process.env.NODE_ENV === "production") {
    process.stdout.write(JSON.stringify(entry) + "\n");
  } else {
    console.log(`[${entry.level.toUpperCase()}] ${message}`, fields);
  }
}

// Wrap API calls with timing and structured logging
async function trackedAPICall<T>(
  operationName: string,
  creatorId: string,
  fn: () => Promise<T>
): Promise<T> {
  const correlationId = crypto.randomUUID();
  const start = Date.now();

  log("info", "API call started", { operationName, creatorId, correlationId });

  try {
    const result = await fn();
    const durationMs = Date.now() - start;

    log("info", "API call succeeded", {
      operationName,
      creatorId,
      correlationId,
      durationMs,
    });

    return result;
  } catch (err) {
    const durationMs = Date.now() - start;
    const apiError = err instanceof APIError ? err : toAPIError(err);

    log("error", "API call failed", {
      operationName,
      creatorId,
      correlationId,
      durationMs,
      errorType: apiError.type,
      statusCode: apiError.statusCode,
      errorMessage: apiError.message,
    });

    throw apiError;
  }
}

Key Metrics to Track

For monitoring, track these signals — each tells you something different about system health:

class APIMetrics {
  private counters: Map<string, number> = new Map();
  private histograms: Map<string, number[]> = new Map();

  increment(key: string, value: number = 1): void {
    this.counters.set(key, (this.counters.get(key) ?? 0) + value);
  }

  record(key: string, value: number): void {
    if (!this.histograms.has(key)) {
      this.histograms.set(key, []);
    }
    this.histograms.get(key)!.push(value);
  }

  // Call this after every API call, success or failure
  recordAPICall(params: {
    operationName: string;
    creatorId: string;
    success: boolean;
    durationMs: number;
    errorType?: ErrorType;
    statusCode?: number;
  }): void {
    const prefix = `api.${params.operationName}`;

    this.increment(`${prefix}.total`);
    this.record(`${prefix}.duration_ms`, params.durationMs);

    if (params.success) {
      this.increment(`${prefix}.success`);
    } else {
      this.increment(`${prefix}.error`);
      if (params.errorType) {
        this.increment(`${prefix}.error.${params.errorType.toLowerCase()}`);
      }
    }

    // Per-creator metrics for identifying problematic accounts
    this.increment(`creator.${params.creatorId}.api_calls`);
    if (!params.success) {
      this.increment(`creator.${params.creatorId}.api_errors`);
    }
  }
}

export const metrics = new APIMetrics();

The metrics that matter most in production:

  • Error rate per operation — sudden spikes mean something changed on the API side
  • 429 rate — growing rate-limit errors mean you need to throttle further or upgrade your tier
  • p99 latency per operation — the 99th percentile catches slow outliers that averages hide
  • Circuit breaker state per creator — any breaker in Open state needs immediate attention
  • Retry rate — high retry rates without eventual success indicate a systematic problem

Putting It Together: A Complete API Client

This is what the assembled client looks like in practice:

const BASE_URL = "https://api.ofapi.dev/api/v1";

class OFAPIClient {
  private rateLimiter: TokenBucket;

  constructor(private readonly apiKey: string) {
    this.rateLimiter = new TokenBucket(100, 100 / 60_000);
  }

  async getTopFans(
    creatorId: string,
    limit: number = 50
  ): Promise<{ fans: FanRecord[] }> {
    return this.call(creatorId, "getTopFans", () =>
      this.request(`/stats/fans/top`, { creatorId, limit: String(limit) })
    );
  }

  async getPayoutStats(
    creatorId: string,
    startDate: string,
    endDate: string
  ): Promise<PayoutStats> {
    return this.call(creatorId, "getPayoutStats", () =>
      this.request(`/payouts/statistics`, { creatorId, startDate, endDate })
    );
  }

  private async call<T>(
    creatorId: string,
    operationName: string,
    operation: () => Promise<T>
  ): Promise<T> {
    return trackedAPICall(operationName, creatorId, () =>
      fetchWithProtection(creatorId, operationName, operation)
    );
  }

  private async request<T>(
    path: string,
    params: Record<string, string>
  ): Promise<T> {
    await this.rateLimiter.consume();

    const url = `${BASE_URL}${path}?${new URLSearchParams(params)}`;

    let response: Response;
    try {
      response = await fetch(url, {
        headers: { "X-API-Key": this.apiKey },
        signal: AbortSignal.timeout(10_000),  // 10 second timeout
      });
    } catch (err) {
      throw classifyError(null, undefined, err as NodeJS.ErrnoException);
    }

    if (!response.ok) {
      const headers = Object.fromEntries(response.headers.entries());
      throw classifyError(response.status, headers);
    }

    return response.json() as Promise<T>;
  }
}

export const client = new OFAPIClient(process.env.OFAPI_KEY!);

Error handling is not glamorous work, but it is the difference between an integration that runs unattended for months and one that requires constant babysitting. The patterns here — typed errors, bounded retries, rate limit queues, circuit breakers per creator, structured logs — are the minimum viable set for a production agency integration.

For event-driven integrations that need real-time reliability on the receiving side, see the webhook integration guide. For how this fits into a larger data pipeline, see the custom CRM post.

Start with the API at OFAPI pricing or review the full endpoint reference in the documentation.

Get API access — start building.

Full REST API for OnlyFans automation. Get started in minutes.

Get Access →

Ready to automate your OnlyFans operations?

Get full API access and start building in minutes.