Rate limiting, error codes, and retry patterns

Text Lesson7m 45sBeginnerReleased: July 31, 2026

Rate limiting, error codes, and retry patterns

TL;DR

  • Classify errors before retrying: 429 and 500 are retryable with backoff; 401, 403, and 422 are permanent and need a code or config fix.
  • Always use exponential backoff with jitter to avoid synchronized retry storms across concurrent clients.
  • Read X-RateLimit-Remaining on every response to throttle proactively, not just reactively after a 429.

Every API has limits. The difference between a fragile integration and a production-grade one is whether your code anticipates those limits and responds correctly. Contentstack enforces rate limits on both the Content Delivery API and the Content Management API, and the error responses it returns follow specific patterns that your client code should handle deliberately rather than optimistically.

This lesson covers how Contentstack rate limiting works, what each error code means for your integration logic, and how to build retry behavior that recovers gracefully without making the problem worse.

How Contentstack rate limiting works

Contentstack applies rate limits per organization, and the thresholds differ between the Content Delivery API (CDA) and the Content Management API (CMA). The limits also vary by pricing plan. The critical point for developers is that rate limits are not just a theoretical concern for high-traffic sites. Migration scripts, bulk publishing operations, and content synchronization jobs can hit CMA limits quickly during routine operations.

CDA rate limits

The Content Delivery API at cdn.contentstack.io is optimized for high read throughput. CDA rate limits are generous because delivery traffic is cacheable and read-only. As documented, free plans are measured per minute (for example, 1000 requests/minute), while paid plans are typically measured per second (for example, 200 requests/second). However, aggressive client-side polling, uncached server-side rendering on every request, or misconfigured CDN bypass can still exhaust these limits during traffic spikes.

CMA rate limits

The Content Management API at api.contentstack.io has tighter rate limits because it handles write operations, workflow mutations, and administrative actions. The documented default limit is 10 requests/second for CMA (with higher limits available on specific plans). This is the API surface where rate limiting becomes a daily engineering concern, especially during:

  • bulk content imports
  • automated publishing across many entries
  • migration scripts that create or update content types
  • webhook-triggered chains that fan out into many CMA calls

Rate limit headers

Contentstack returns rate limit metadata in HTTP response headers. Your client code should read these headers on every response, not just on error responses.

HeaderMeaning
X-RateLimit-LimitMaximum requests allowed in the current window
X-RateLimit-RemainingRequests remaining before throttling begins

When X-RateLimit-Remaining reaches 0, the next request will return a 429 Too Many Requests response. Proactive clients check this header and slow down before hitting the wall rather than slamming into it and retrying.

// Reading rate limit headers from a Contentstack API response
function logRateLimitStatus(response: Response): void {
  const limit = response.headers.get("X-RateLimit-Limit");
  const remaining = response.headers.get("X-RateLimit-Remaining");

  if (remaining !== null) {
    const remainingCount = parseInt(remaining, 10);
    const limitCount = parseInt(limit ?? "0", 10);
    const utilizationPct = ((limitCount - remainingCount) / limitCount) * 100;

    console.log(
      `Rate limit: ${remaining}/${limit} remaining (${utilizationPct.toFixed(1)}% used)`
    );

    if (remainingCount < limitCount * 0.1) {
      console.warn("Rate limit approaching: less than 10% of quota remaining");
    }
  }
}

Contentstack HTTP error codes

Common pitfall:

Retrying every error uniformly -- including 422 (invalid payload) and 403 (wrong permissions) -- burns through rate limit quota with zero chance of success and masks the real problem.

Not every error deserves a retry. The most damaging pattern in API integration code is treating all errors as transient. Contentstack error responses include an HTTP status code and a JSON body with an error_message and error_code field. Your retry logic must distinguish between errors worth retrying and errors that require a code or configuration fix.

429 Too Many Requests

Meaning: You have exceeded the rate limit for the current window.

Retry: Yes. This is the primary retryable error. Back off, wait, and retry with exponential backoff + jitter.

Response body example:

{
  "error_message": "You've made too many requests too quickly. Please slow down.",
  "error_code": 429,
  "errors": {}
}

401 Unauthorized

Meaning: The request lacks valid authentication credentials. The api_key, access_token, authorization header, or authtoken is missing or invalid.

Retry: No. Retrying with the same credentials will produce the same result. Fix the credential, then retry.

403 Forbidden

Meaning: The credentials are valid but lack permission for the requested operation. A delivery token trying to access an unpublished entry, or a management token scoped to one stack trying to reach another, will produce a 403.

Retry: No. This is a permission or scope problem, not a transient failure.

404 Not Found

Meaning: The requested resource does not exist. The content type UID is wrong, the entry UID does not exist in the target environment, or the API path is malformed.

Retry: Usually no. However, one edge case exists: if you just published an entry and immediately query CDA for it, propagation delay can cause a brief 404. In this narrow scenario, a short retry with a delay is reasonable. In all other cases, 404 is a permanent error.

412 Precondition Failed

Meaning: The request conflicts with a server-side precondition. In Contentstack's CMA, this commonly occurs during entry updates when the version number in your request does not match the current version on the server. Another entry update was committed between your read and your write.

Retry: Yes, but not blindly. You'll want to re-fetch the current entry, resolve any conflicts between your intended changes and the new server state, and then resubmit with the correct version number. A naive retry without re-reading the entry will fail again.

Response body example:

{
  "error_message": "The version of the entry you are trying to update has changed. Please fetch the latest version.",
  "error_code": 412,
  "errors": {}
}

422 Unprocessable Entity

Meaning: The request body is syntactically valid JSON but semantically invalid. Field values violate content type validation rules, required fields are missing, or a reference UID points to a nonexistent entry.

Retry: No. The payload itself is wrong. Fix the data and resubmit.

500 Internal Server Error

Meaning: Something went wrong on Contentstack's side.

Retry: Yes, with backoff. Server errors are typically transient. If a 500 persists across multiple retries with increasing delay, escalate to Contentstack support with the request ID from the response headers.

Quick reference: retry decision table

Status CodeRetryableAction
429YesExponential backoff with jitter
401NoFix credentials
403NoFix permissions or token scope
404RarelyCheck resource existence; retry only for publish propagation
412Yes (with re-fetch)Re-read current version, merge changes, resubmit
422NoFix request payload
500YesExponential backoff with jitter

Implementing exponential backoff with jitter

When a 429 or 500 occurs, the worst response is to retry immediately at full speed. That creates a retry storm: many clients all retrying at the same instant, re-triggering the same overload condition.

Exponential backoff increases the delay between retries multiplicatively. Jitter adds randomness to that delay so that concurrent clients do not synchronize their retries.

The formula for delay backoff optimization is defined as follows: $$delay = \min(baseDelay \times 2^{attempt} + random(0, jitterMax), maxDelay)$$

function calculateBackoff(
  attempt: number,
  baseDelayMs: number = 1000,
  maxDelayMs: number = 30000,
  jitterMs: number = 500
): number {
  const exponentialDelay = baseDelayMs * Math.pow(2, attempt);
  const jitter = Math.random() * jitterMs;
  return Math.min(exponentialDelay + jitter, maxDelayMs);
}

Idempotency considerations for CMA write operations

Rate limit retries on the CMA introduce a subtle danger: if a request times out but the server actually processed it, retrying creates a duplicate operation. Entry creation is not idempotent. Calling POST /v3/content_types/{content_type_uid}/entries twice with the same payload creates two entries with different UIDs.

Guard against this:

  1. Use UID-based updates when possible. PUT /v3/content_types/{content_type_uid}/entries/{entry_uid} is idempotent. Sending the same update twice produces the same result.
  2. Track operation state externally. Before retrying a create operation, check whether the resource was actually created by querying for it. If it exists, skip the retry and proceed with an update.
  3. Use the entry version field. CMA entry updates require a version field. If you retry an update and the version has already advanced, the 412 response tells you the first attempt succeeded.
async function idempotentCreateOrUpdate(
  apiKey: string,
  managementToken: string,
  contentTypeUid: string,
  uniqueField: string,
  uniqueValue: string,
  entryData: Record
): Promise<{ uid: string; created: boolean }> {
  const baseUrl = "https://api.contentstack.io/v3";
  const headers = {
    api_key: apiKey,
    authorization: managementToken,
    "Content-Type": "application/json",
  };

  // Check if entry already exists by querying on a unique field
  const searchResponse = await fetch(
    `${baseUrl}/content_types/${contentTypeUid}/entries?query={"${uniqueField}":"${uniqueValue}"}`,
    { headers }
  );
  const searchResult = await searchResponse.json();

  if (searchResult.entries && searchResult.entries.length > 0) {
    const existing = searchResult.entries[0];
    // Update existing entry with current version
    const updateResponse = await fetch(
      `${baseUrl}/content_types/${contentTypeUid}/entries/${existing.uid}`,
      {
        method: "PUT",
        headers,
        body: JSON.stringify({
          entry: { ...entryData, version: existing._version },
        }),
      }
    );
    const updated = await updateResponse.json();
    return { uid: updated.entry.uid, created: false };
  }

  // Create new entry
  const createResponse = await fetch(
    `${baseUrl}/content_types/${contentTypeUid}/entries`,
    {
      method: "POST",
      headers,
      body: JSON.stringify({ entry: entryData }),
    }
  );
  const created = await createResponse.json();
  return { uid: created.entry.uid, created: true };
}

Building a resilient API client with retry logic

The following TypeScript implementation ties together everything discussed: rate limit header inspection, error classification, exponential backoff with jitter, and maximum retry caps. This is a production-oriented pattern, not a toy example.

// resilient-contentstack-client.ts

interface RetryConfig {
  maxRetries: number;
  baseDelayMs: number;
  maxDelayMs: number;
  jitterMs: number;
}

const DEFAULT_RETRY_CONFIG: RetryConfig = {
  maxRetries: 5,
  baseDelayMs: 1000,
  maxDelayMs: 30000,
  jitterMs: 500,
};

type ErrorCategory = "retryable" | "permanent" | "retryable_with_refetch";

function classifyError(status: number): ErrorCategory {
  switch (status) {
    case 429:
    case 500:
    case 502:
    case 503:
    case 504:
      return "retryable";
    case 412:
      return "retryable_with_refetch";
    case 401:
    case 403:
    case 404:
    case 422:
      return "permanent";
    default:
      return status >= 500 ? "retryable" : "permanent";
  }
}

function getRetryDelay(
  attempt: number,
  _response: Response,
  config: RetryConfig
): number {
  // Exponential backoff with jitter
  const exponentialDelay = config.baseDelayMs * Math.pow(2, attempt);
  const jitter = Math.random() * config.jitterMs;
  return Math.min(exponentialDelay + jitter, config.maxDelayMs);
}

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

export async function resilientFetch(
  url: string,
  options: RequestInit,
  config: RetryConfig = DEFAULT_RETRY_CONFIG
): Promise {
  let lastResponse: Response | null = null;
  let lastError: Error | null = null;

  for (let attempt = 0; attempt <= config.maxRetries; attempt++) {
    try {
      const response = await fetch(url, options);

      // Log rate limit status on every response
      const remaining = response.headers.get("X-RateLimit-Remaining");
      if (remaining !== null) {
        console.log(
          `[Attempt ${attempt}] ${url} - Rate limit remaining: ${remaining}`
        );
      }

      // Success: return immediately
      if (response.ok) {
        return response;
      }

      const category = classifyError(response.status);

      if (category === "permanent") {
        // No amount of retrying will fix this. Return the error response.
        const body = await response.text();
        console.error(
          `Permanent error ${response.status} on ${url}: ${body}`
        );
        return response;
      }

      if (category === "retryable_with_refetch") {
        // 412: caller must re-fetch and reconcile before retrying
        console.warn(
          `Version conflict (412) on ${url}. Caller must re-fetch before retry.`
        );
        return response;
      }

      // Retryable error: back off and try again
      lastResponse = response;
      if (attempt < config.maxRetries) {
        const delayMs = getRetryDelay(attempt, response, config);
        console.warn(
          `Retryable error ${response.status} on ${url}. ` +
          `Retrying in ${delayMs.toFixed(0)}ms (attempt ${attempt + 1}/${config.maxRetries})`
        );
        await sleep(delayMs);
      }
    } catch (err) {
      // Network-level failures (DNS, connection reset) are retryable
      lastError = err instanceof Error ? err : new Error(String(err));
      if (attempt < config.maxRetries) {
        const delayMs =
          config.baseDelayMs * Math.pow(2, attempt) +
          Math.random() * config.jitterMs;
        console.warn(
          `Network error on ${url}: ${lastError.message}. ` +
          `Retrying in ${delayMs.toFixed(0)}ms (attempt ${attempt + 1}/${config.maxRetries})`
        );
        await sleep(delayMs);
      }
    }
  }

  // All retries exhausted
  if (lastResponse) {
    return lastResponse;
  }
  throw lastError ?? new Error(`Request to ${url} failed after all retries`);
}

// Usage: fetching entries from CDA with automatic retry on rate limits
async function fetchBlogEntries(): Promise {
  const response = await resilientFetch(
    "https://cdn.contentstack.io/v3/content_types/product/entries?environment=production&locale=en-us",
    {
      headers: {
        api_key: process.env.NEXT_PUBLIC_CONTENTSTACK_API_KEY!,
        access_token: process.env.NEXT_PUBLIC_CONTENTSTACK_DELIVERY_TOKEN!,
      },
    }
  );

  if (!response.ok) {
    throw new Error(`Failed to fetch blog entries: ${response.status}`);
  }

  return response.json();
}

// Usage: bulk CMA operations with rate-aware throttling
async function bulkPublishEntries(
  entryUids: string[],
  contentTypeUid: string,
  environment: string
): Promise {
  const baseUrl = "https://api.contentstack.io/v3";
  const headers: Record = {
    api_key: process.env.NEXT_PUBLIC_CONTENTSTACK_API_KEY!,
    authorization: process.env.NEXT_PUBLIC_CONTENTSTACK_MANAGEMENT_TOKEN!,
    "Content-Type": "application/json",
  };

  for (const uid of entryUids) {
    const response = await resilientFetch(
      `${baseUrl}/content_types/${contentTypeUid}/entries/${uid}/publish`,
      {
        method: "POST",
        headers,
        body: JSON.stringify({
          entry: { environments: [environment], locales: ["en-us"] },
        }),
      }
    );

    if (!response.ok) {
      const body = await response.json();
      console.error(`Failed to publish ${uid}:`, body.error_message);
    }
  }
}

This client handles the entire lifecycle: it reads rate limit headers for observability, classifies errors into retryable and permanent categories, applies exponential backoff with jitter for retryable responses, and surfaces 412 version conflicts to the caller for explicit handling.

Common mistakes

Mistake 1: Retrying everything uniformly

Teams wrap all API calls in a generic retry loop that treats 422 and 429 identically. The result: invalid payloads hammer the API on repeat, burning through rate limit quota without any chance of success. Classify errors before deciding whether to retry.

Mistake 2: Synchronized retry storms

When many serverless functions or workers hit a 429 simultaneously and all retry after exactly the same fixed delay, they create a thundering herd. The retries arrive in unison, trigger another 429 wave, and the cycle continues. Jitter breaks the synchronization. Every retry implementation must include randomness.

Mistake 3: Ignoring rate limit headers until failure

Some implementations only inspect the response when the status code is not 200. This means the client has zero visibility into how close it is to the rate limit ceiling until it crashes through it. Read X-RateLimit-Remaining on every response. Use it to proactively throttle batch operations before the 429 arrives.