n4nAI

Handling embeddings API rate limits in Node.js

Practical steps to handle embeddings API rate limits in Node.js with retry, backoff, batching, and fallback for resilient production pipelines.

n4n Team4 min read784 words

Audio narration

Coming soon — every post will get a voice note here.

Rate limits on embeddings endpoints turn simple batch jobs into fragile pipelines the moment you scale. This guide shows how to build a Node.js client that survives embeddings api rate limits node.js with structured retries, concurrency control, and provider fallback.

Step 1: Identify your rate limit dimensions

Embeddings APIs throttle on two independent axes: requests per minute (RPM) and tokens per minute (TPM). A single request carrying 200 short strings counts as one request against RPM but burns TPM proportional to the total input tokens, so a pipeline that looks safe on request count can still get cut off by token limits.

Most OpenAI-compatible services return the current state in response headers. Probe once at startup to read your ceilings:

import OpenAI from 'openai';

const client = new OpenAI({ apiKey: process.env.OPENAI_API_KEY });

const res = await client.embeddings.create({
  model: 'text-embedding-3-small',
  input: 'probe',
});

const headers = res.headers as Record<string, string>;
console.log('RPM limit:', headers['x-ratelimit-limit-requests']);
console.log('TPM limit:', headers['x-ratelimit-limit-tokens']);
console.log('Remaining requests:', headers['x-ratelimit-remaining-requests']);

When you exceed a limit the server replies 429 Too Many Requests. The body is sparse; the useful signal is in headers:

{
  "error": {
    "type": "requests_limit_reached",
    "message": "Rate limit reached for requests"
  }
}

Header retry-after gives seconds to wait; x-ratelimit-reset-requests gives seconds until the window resets. Client code must read these rather than hard-coding sleep values.

Step 2: Install a concurrency-limited queue

Firing Promise.all over ten thousand documents is the fastest way to hit a 429 wall. You need a leaky bucket. The p-queue package gives you interval-based throttling without writing your own timer logic.

npm install p-queue openai

Create a queue sized to 80% of your observed RPM to leave headroom for other services sharing the key:

import PQueue from 'p-queue';

const RPM_LIMIT = 3000;
const safeCap = Math.floor(RPM_LIMIT * 0.8 / 60); // per second

export const embedQueue = new PQueue({
  interval: 1000,
  intervalCap: safeCap,
  timeout: 30_000,
});

If your bottleneck is TPM, estimate average tokens per item (e.g., 4 chars/token for English) and derive intervalCap from token budget instead. The queue also supports priority if some documents are user-facing and must jump the line.

Step 3: Implement exponential backoff with jitter

Retrying immediately on a 429 multiplies load exactly when the server is struggling. Wrap every call in a retry that honors retry-after and otherwise backs off exponentially with full jitter.

async function withRetry<T>(fn: () => Promise<T>, maxAttempts = 6): Promise<T> {
  for (let attempt = 0; attempt <= maxAttempts; attempt++) {
    try {
      return await fn();
    } catch (err: any) {
      const status = err?.status;
      if (status !== 429 && status !== 500 && status !== 503) throw err;
      if (attempt === maxAttempts) throw err;

      const retryAfter = err?.headers?.['retry-after'];
      const waitMs = retryAfter
        ? parseInt(retryAfter, 10) * 1000
        : Math.min(30_000, 400 * 2 ** attempt + Math.random() * 400);
      await new Promise(r => setTimeout(r, waitMs));
    }
  }
  throw new Error('unreachable');
}

Embeddings requests are read-only and idempotent; retrying them is safe as long as you don’t double-count results downstream. Never retry on 401 or 400—those are permanent.

Step 4: Batch inputs to reduce request count

The embeddings endpoint accepts an array of strings in a single HTTP call. Batching shrinks RPM usage by the batch size but increases per-request TPM. For text-embedding-3-small, each input is capped at 8191 tokens, so chunk by both count and estimated token length.

function chunkByTokens(texts: string[], maxItems: number, maxTokens: number): string[][] {
  const batches: string[][] = [];
  let current: string[] = [];
  let tokenEstimate = 0;
  for (const t of texts) {
    const est = Math.ceil(t.length / 4);
    if (current.length >= maxItems || tokenEstimate + est > maxTokens) {
      batches.push(current);
      current = [];
      tokenEstimate = 0;
    }
    current.push(t);
    tokenEstimate += est;
  }
  if (current.length) batches.push(current);
  return batches;
}

const batches = chunkByTokens(documents, 100, 300_000);

Submit each batch to the queue:

const batchResults = await Promise.all(
  batches.map(batch =>
    embedQueue.add(() =>
      withRetry(() =>
        client.embeddings.create({
          model: 'text-embedding-3-small',
          input: batch,
        })
      )
    )
  )
);

Step 5: Handle 429 and Retry-After headers explicitly

The retry wrapper covers transient throttling, but sustained limits need a circuit breaker. Track reset windows and pause the queue when the server signals a long recovery.

let circuitOpen = false;

embedQueue.on('error', (err: any) => {
  const reset = parseInt(err?.headers?.['x-ratelimit-reset-requests'] ?? '0', 10);
  if (reset > 30) {
    circuitOpen = true;
    embedQueue.pause();
    setTimeout(() => {
      circuitOpen = false;
      embedQueue.start();
    }, reset * 1000);
  }
});

While paused, new queue.add calls buffer in memory. If you have millions of items, persist the pending list to a Redis stream instead of growing the Node heap.

Step 6: Add fallback to a secondary provider

Single-provider rate limits are unavoidable during traffic spikes or provider incidents. Route to an OpenAI-compatible gateway that fronts multiple models. n4n.ai exposes one OpenAI-compatible endpoint for 240+ models and applies automatic fallback when a provider is rate-limited or degraded, which pairs well with your client-side queue.

Point a second client at the gateway and try it only after the primary exhausts retries:

const gatewayClient = new OpenAI({
  apiKey: process.env.N4N_API_KEY,
  baseURL: 'https://api.n4n.ai/v1',
});

async function embedWithFallback(batch: string[]) {
  try {
    return await withRetry(() =>
      client.embeddings.create({ model: 'text-embedding-3-small', input: batch })
    );
  } catch (e: any) {
    if (e?.status === 429 || e?.status === 503) {
      return await withRetry(() =>
        gatewayClient.embeddings.create({ model: 'text-embedding-3-small', input: batch })
      );
    }
    throw e;
  }
}

Keep batching and queueing identical for the fallback path. The gateway may also forward provider cache-control hints, so repeated identical inputs can hit cache and avoid token metering entirely.

Step 7: Meter usage and verify the pipeline

Per-token metering is your only visibility into TPM headroom. Accumulate usage from each response and emit a structured log:

let totalTokens = 0;
let totalDocs = 0;

for (const r of batchResults) {
  totalTokens += r.usage?.total_tokens ?? 0;
  totalDocs += r.data.length;
}

console.log(JSON.stringify({ event: 'embed_complete', totalDocs, totalTokens }));

Verify success with a deterministic test. Use nock to intercept the embeddings route and return a 429 twice, then 200:

import nock from 'nock';

nock('https://api.openai.com')
  .post('/v1/embeddings')
  .twice()
  .reply(429, { error: { type: 'rate_limit' } }, { 'retry-after': '0' })
  .post('/v1/embeddings')
  .reply(200, { data: [{ embedding: [0.1] }], usage: { total_tokens: 5 } });

Run the script on a 10-item sample. Assert:

  • The process exits zero with all items embedded.
  • embedQueue concurrency never exceeded intervalCap (add a counter in active events).
  • Fallback client received exactly one call when primary stayed at 429.
  • totalTokens equals the sum reported by mocked responses.

A green run proves your handling of embeddings api rate limits node.js is correct before you point it at production data.

Step 8: Tune for real traffic shapes

Batch jobs and online inference have opposite pressures. For a nightly backfill, maximize batch size and queue depth. For a live API that embeds user queries, keep batches small (size 8–16) and set intervalCap low to maintain tail latency.

If you consume a streaming source (Kafka, SQS), feed the queue continuously instead of loading all texts into memory:

for await (const msg of consumer) {
  embedQueue.add(() => embedWithFallback([msg.body]));
}

Monitor queue size with embedQueue.size and alert if it grows unbounded for five minutes—that indicates a limit you haven’t sized for.

Step 9: Keep embeddings api rate limits node.js boring

The goal is not cleverness; it’s a pipeline that logs a steady trickle of completions and never pages you at 2 a.m. because a provider tightened quotas. Queue, backoff, batch, fallback, meter. Wire those five pieces and the next rate limit event becomes a metric, not an incident.

Tagsnodejsembeddingsrate-limitingapi-integration

Written by

n4n Team

The team building n4n — a single OpenAI-compatible API in front of 240+ models, with automatic fallback, load balancing and pay-per-token metering.

More from n4n Team →

All embeddings api integration across languages posts →