How OpenSEO Handles DataForSEO API Calls with Retry Logic and Timeouts
OpenSEO implements DataForSEO API resilience through a centralized wrapper in src/server/lib/dataforseo/core.ts that combines AbortController-based timeouts with selective exponential backoff retry logic—retrying only idempotent read operations while strictly avoiding retries on billable task-post endpoints.
The every-app/open-seo repository provides a production-ready TypeScript integration with the DataForSEO API. Rather than calling the dataforseo-client SDK directly, all requests flow through a protective abstraction layer that handles transient failures, prevents double-billing, and enforces hard deadlines on every outbound call.
Why Centralized Request Handling Matters
DataForSEO's infrastructure occasionally returns transient 5xx errors during maintenance windows or traffic spikes. Without careful handling, these failures can:
- Corrupt user workflows by surfacing raw SDK errors
- Double-charge accounts if task-creation endpoints are naively retried
- Hang indefinitely without network-level timeouts
The core.ts module solves these problems through context-aware retry policies and mandatory request timeouts.
Core Implementation: Timeouts and Abort Controllers
Every DataForSEO request in OpenSEO is wrapped with a fresh AbortController instance. In src/server/lib/dataforseo/core.ts, the timeout logic creates a deadline that cannot be bypassed:
// src/server/lib/dataforseo/core.ts (simplified)
export async function fetchWithRetry<T>(fn: () => Promise<T>, opts = {}) {
const { retries = 3, timeout = 30_000 } = opts;
for (let attempt = 0; attempt <= retries; ++attempt) {
const controller = new AbortController();
const timer = setTimeout(() => controller.abort(), timeout);
try {
const result = await fn({ signal: controller.signal });
clearTimeout(timer);
return result;
} catch (err) {
clearTimeout(timer);
// Retry logic evaluated here...
throw err;
}
}
}
Key timeout characteristics:
- Default deadline: 30 seconds (
DEFAULT_TIMEOUT_MS = 30_000) - Configurable per-call: Override via
opts.timeoutfor long-running batch operations - Guaranteed cleanup:
clearTimeout(timer)prevents memory leaks on both success and failure paths - Abort signal propagation: Passed through to the underlying SDK call for true cancellation
Selective Retry Logic: Idempotent vs. Billable Endpoints
The most critical design decision in OpenSEO's DataForSEO handling is endpoint-aware retry behavior. The system distinguishes between safe, idempotent reads and dangerous, billable writes.
Read-Only Operations: Exponential Backoff Retry
Endpoints that fetch existing data (SERP results, keyword rankings, competitor analysis) receive full retry protection:
| Retry Parameter | Value |
|---|---|
| Maximum attempts | 4 total (initial + 3 retries) |
| Backoff strategy | Exponential: 100ms → 200ms → 400ms |
| Retryable status codes | 502, 503, 504, 429 (rate limit) |
These calls are handled by helpers like serpApi in src/server/lib/dataforseo/serp.ts:
// src/server/lib/dataforseo/serp.ts
import { fetchWithRetry } from './core';
export async function serpApi(client: DataForSeoClient, payload: SerpPayload) {
// Marked as idempotent → retries enabled
return fetchWithRetry(
() => client.serpApi.googleOrganicTaskGet(payload),
{ retries: 3, idempotent: true }
);
}
Task-Post Endpoints: No Retry Protection
Endpoints that create new tasks (taskPost, taskUpload) are never retried regardless of error type. This prevents double-charging when a 5xx occurs after the server has already accepted the request.
In src/server/lib/dataforseo/business.ts, these calls bypass fetchWithRetry entirely or invoke it with retries: 0:
// src/server/lib/dataforseo/business.ts
export async function createBacklinksTask(
client: DataForSeoClient,
payload: TaskPostPayload
) {
// Explicitly disable retry for billable operation
return fetchWithRetry(
() => client.backlinksApi.taskPost(payload),
{ retries: 0 } // CRITICAL: Prevents double-billing
);
// Alternative: Direct SDK call with timeout wrapper only
}
Error Classification and Response Normalization
Retry decisions depend on accurate error classification. OpenSEO's src/server/lib/dataforseo/envelope.ts provides the DataforseoError type hierarchy and isTransient() utility:
// src/server/lib/dataforseo/envelope.ts
export function isTransient(error: unknown): boolean {
if (!(error instanceof DataforseoError)) return false;
// Network errors and specific HTTP status codes
return (
error.code === 'ECONNRESET' ||
error.code === 'ETIMEDOUT' ||
(error.status >= 500 && error.status < 600) ||
error.status === 429
);
}
export function assertOk<T>(response: ApiResponse<T>): T {
if (response.status_code !== 20000) { // DataForSEO success code
throw new DataforseoError(response.status_message, response.status_code);
}
return response.tasks;
}
The envelope.ts module ensures that SDK responses are validated consistently before retry logic evaluates them.
Complete Usage Example
Here's how OpenSEO's DataForSEO abstraction is used in practice, combining client initialization, retry-enabled reads, and protected task creation:
import { createDataforseoClient } from '@/server/lib/dataforseo/client';
import { serpApi } from '@/server/lib/dataforseo/core';
import { createBacklinksTask } from '@/server/lib/dataforseo/business';
// Initialize once per request or use singleton pattern
const client = createDataforseoClient({
username: process.env.DATAFORSEO_USERNAME!,
password: process.env.DATAFORSEO_PASSWORD!,
});
// Example 1: Fetch organic SERP results (with retry/timeout)
async function fetchRankings(keyword: string, domain: string) {
const result = await serpApi(client, {
keyword,
target: domain,
location_code: 2840, // New York, USA
language_code: 'en',
});
return result.tasks?.[0]?.result?.[0]?.items ?? [];
}
// Example 2: Start new backlink analysis (no retry, timeout protected)
async function startBacklinkAnalysis(projectId: string) {
try {
const task = await createBacklinksTask(client, { project_id: projectId });
return task.tasks?.[0]?.id; // Task ID for polling
} catch (err) {
// Handle timeout or error without retry risk
if (err.name === 'AbortError') throw new Error('Request timed out');
throw err;
}
}
Architecture Overview: Request Flow
┌─────────────────┐ ┌─────────────────────────────┐ ┌─────────────────┐
│ Application │────▶│ src/server/lib/dataforseo/ │────▶│ dataforseo- │
│ Code │ │ core.ts: fetchWithRetry() │ │ client SDK │
└─────────────────┘ └─────────────────────────────┘ └─────────────────┘
│ ▲
▼ │
┌──────────────┴────────┐
│ AbortController │
│ (timeout enforcement) │
│ │
│ Retry loop (conditional) │
│ • Idempotent reads: YES │
│ • Task-post: NO │
└─────────────────────────┘
Key Source Files
| File | Responsibility |
|---|---|
src/server/lib/dataforseo/core.ts |
Central fetchWithRetry() implementation with AbortController timeouts and backoff logic |
src/server/lib/dataforseo/envelope.ts |
Response normalization, DataforseoError types, isTransient() classification |
src/server/lib/dataforseo/client.ts |
Factory for configured SDK instances with injected wrappers |
src/server/lib/dataforseo/serp.ts |
SERP-specific helpers (read-only, retry-enabled) |
src/server/lib/dataforseo/business.ts |
Task-post endpoints (retry explicitly disabled) |
Summary
- Timeouts are mandatory: Every DataForSEO request carries an
AbortControllerwith a 30-second default deadline - Retry is conditional: Only idempotent read operations receive exponential backoff retry; billable task-posts never retry
- Implementation centers on
core.ts: ThefetchWithRetry()function insrc/server/lib/dataforseo/core.tsorchestrates all protective behavior - Error classification matters:
src/server/lib/dataforseo/envelope.tsprovides theisTransient()logic that gates retry decisions - Double-billing protection is architectural: Task-post endpoints in
business.tsexplicitly setretries: 0
Frequently Asked Questions
What is the default timeout for DataForSEO API calls in OpenSEO?
The default timeout is 30 seconds (30,000 ms), defined as DEFAULT_TIMEOUT_MS in src/server/lib/dataforseo/core.ts. This can be overridden per-call via the timeout option in fetchWithRetry().
Why doesn't OpenSEO retry task-post endpoints?
Task-post endpoints create billable jobs on DataForSEO's infrastructure. A retry after a transient 5xx could create duplicate tasks and double-charge the account. OpenSEO's business.ts module explicitly disables retries for these operations to prevent financial risk.
How does OpenSEO distinguish between retryable and non-retryable errors?
The isTransient() function in src/server/lib/dataforseo/envelope.ts classifies errors by status code (502, 503, 504, 429) and network error codes (ECONNRESET, ETIMEDOUT). Additionally, the idempotent flag passed to fetchWithRetry() determines whether retry logic executes even for transient errors.
Can I adjust the retry count or backoff timing?
Yes. The fetchWithRetry() function accepts opts.retries (default: 3) and the backoff calculation can be customized in core.ts. The current implementation uses backoff(attempt) = 100 * 2^attempt milliseconds, producing 100ms, 200ms, 400ms delays across three retry attempts.
Have a question about this repo?
These articles cover the highlights, but your codebase questions are specific. Give your agent direct access to the source. Share this with your agent to get started:
curl -s "https://instagit.com/install.md" Maintain an open-source project? Get it listed too →