Resolve Azure embedding pipeline limitations and improve batch processing efficiency for AI applications
Fixing the Array Error: Overcoming Batch Limits
in Azure Embedding Pipelines
The number "16" is real, but it's not the number that will break your pipeline today. It's the ghost of an older model and an older API surface. The constraint that actually crashes a modern text-embedding-3-large pipeline is quieter, newer, and measured in tokens summed across an entire request — which means a batch that respects every count-based limit you've ever heard of can still fail, because nobody was adding up the total.
# SIGNATURE A — the legacy error, still real for ada-002 / older API versions:
openai.InvalidRequestError: Too many inputs. The max number of inputs
is 16. We hope to increase the number of inputs per request soon.
Please contact us through an Azure support request for further
questions.
(HTTP 400, param=None, code=None)
# This is the error the brief describes. It is verified, real, and
# STILL happens today - but only on the classic API surface, with
# text-embedding-ada-002. It is NOT the limit for current models.
# ─────────────────────────────────────────────────────────────
# SIGNATURE B — the one that actually crashes a MODERN pipeline,
# using text-embedding-3-large via the current /openai/v1/embeddings
# endpoint, with an array well under the documented cap:
POST /openai/v1/embeddings
{
"model": "text-embedding-3-large",
"input": [ /* 340 strings, array length WELL under 2,048 */ ]
}
HTTP/1.1 400 Bad Request
{
"error": {
"message": "This model's maximum context length is 300000 tokens
per request. However, you requested 341982 tokens
(341982 in your input). Please reduce the size of
your request.",
"code": "context_length_exceeded"
}
}
# Array length: 340 (limit: 2,048 -> PASSES)
# Longest single input: 6,100 tokens (limit: 8,192 -> PASSES)
# SUM of all 340 inputs' tokens: 341,982 (limit: 300,000 -> FAILS)
#
# Every count-based guard the team had written passed. The pipeline
# still crashed, because nobody was tracking the running TOTAL.Symptom: Batch embedding requests fail with InvalidRequestError or a 400 context_length_exceeded, sometimes on request #1 of a large backfill, sometimes hours into a job that had been running fine. Failure point: A client-side batching implementation guards only one dimension of the limit — usually array count — while Azure OpenAI's embeddings endpoint enforces up to three independent limits simultaneously, depending on model and API version. Default platform behaviour: The service rejects the whole request, not just the offending inputs. One oversized batch, however it's oversized, fails every input in it — including the ones that were perfectly fine.
Somewhere in the history of most teams' Azure OpenAI embedding pipelines is a moment where someone hit the "16 inputs" error, searched for it, found the fix — chunk your array into groups of 16 — and moved on. That fix was correct, for the model and API surface it applied to. What often doesn't happen next is revisiting the assumption when the pipeline is later migrated to a newer embedding model on a newer API version, where the constraint that actually governs the request has quietly changed shape: it's no longer primarily about how many strings are in the array, but about how many tokens, summed across every string in the array, the whole request adds up to. A batcher hardcoded to "16 items per request" is stuck being needlessly slow on the new limits. A batcher that naively raised its chunk size to "2,048 items per request" because that's the new array cap will crash the first time it hits a batch of long documents, because it never accounted for the token sum. The fix that actually holds is one that tracks all three dimensions — array count, per-item length, and aggregate tokens — continuously, as it assembles each batch.
The error message "Too many inputs. The max number of inputs is 16" is a real, documented, verified Azure OpenAI error — it applies specifically to text-embedding-ada-002 (Version 2) accessed through the classic Azure OpenAI API surface. Multiple SDK issues (LangChain's Python and JavaScript clients both had to special-case this) confirm the number and the model it applies to. If your pipeline is still on ada-002 through an older API version, this constraint is current and correct today, and the "batch in groups of 16" fix is the right fix for that specific pipeline.
But most teams reading a brief about "the 16-input limit" today are not on that exact configuration, or are migrating off it, and the newer models and the newer /openai/v1/embeddings endpoint enforce a meaningfully different set of constraints. Assuming "16" is a universal Azure OpenAI embeddings limit — rather than a specific, model-and-version-scoped one — is the single most common way teams either under-batch (wastefully slow) or, worse, mis-port a working "chunk by N" pattern from one model to another and hit a completely different failure mode.
| Model / API surface | Max array size | Max tokens per input | Aggregate token cap |
|---|---|---|---|
| text-embedding-ada-002 (legacy API) | 16 | 8,191 | Not separately documented at this scope |
| Current models via /openai/v1/embeddings | 2,048 | 8,192 | 300,000 (summed across the request) |
Even under the legacy limit, the constraint was 16 inputs — array items — never 16 tokens. A single input string, even under the old limit, could still be up to 8,191 tokens long. Confusing "inputs" with "tokens" leads to batchers that are wildly over-conservative (chunking every 16 tokens' worth of text into a separate array item, producing enormous numbers of tiny, wasteful requests) or, worse, batchers that get the dimension right but the number wrong when porting logic between limit types.
For current-generation models via the modern endpoint, a single embeddings request is governed by three separate constraints, checked independently by the service, and a batch has to satisfy all three simultaneously — satisfying two doesn't help if the third is violated.
| Limit | Value | What it measures | Fails when... |
|---|---|---|---|
| Array size | 2,048 | Count of items in the input array | You send more than 2,048 strings in one call, regardless of their length |
| Per-input length | 8,192 tokens | Token count of any single input string | One item in the array — even alone — exceeds 8,192 tokens |
| Aggregate request tokens | 300,000 tokens | SUM of token counts across every item in the array | The array and every item are individually compliant, but their total exceeds 300,000 |
The aggregate limit is the one that catches experienced teams off guard, precisely because it's new relative to the older, more commonly-known array-count limit, and because it's easy to satisfy the other two and still violate it. Microsoft's own current documentation states this plainly: requests that exceed the aggregate limit fail with HTTP 400 even when every individual input is under 8,192 tokens and the array length is under 2,048.
300,000 tokens divided across a reasonably-sized array isn't as generous as it first sounds. At an average of 1,000 tokens per input — a modest few paragraphs of text, well within normal document-chunk sizes for RAG pipelines — the aggregate cap is reached at just 300 items, far short of the 2,048-item array limit. A batcher that fills arrays up to the array-count ceiling without also tracking the running token sum will, for any reasonably long input text, hit the aggregate cap long before it hits the array-count cap. The array limit and the aggregate limit are not the same constraint wearing two names — for typical document lengths, the aggregate limit binds first.
The most common batching implementation — and the one most likely to have been written to fix the original "16 inputs" error — is a fixed-size chunker: take the array of rows, slice it into groups of N, send each group as one request. It's simple, and for uniformly-sized inputs it can work fine for a long time. It fails the moment the assumption of uniform input length breaks, which for real-world database content — some rows are a sentence, some are a full document — is not a matter of if but when.
The failure is also intermittent in a way that's genuinely confusing to debug: a batching job processing a database table row-by-row in fixed-size chunks might run cleanly through thousands of short rows, then crash on batch #340 because that particular slice happened to land on a cluster of long documents — support tickets, contract text, long-form articles — that pushed the aggregate sum over 300,000 while every individual row was fine. The error looks random. It isn't; it's a direct function of which 100 rows happened to be adjacent in the source data.
This is the detail that makes fixed-size chunking especially costly to get wrong: Azure OpenAI's embeddings endpoint rejects the entire request when any limit is violated — it does not process the compliant 99 inputs and skip the one that pushed things over. A batch job that doesn't handle this gracefully either loses all 100 rows' embeddings for that chunk (if it doesn't retry) or has to re-derive which specific input(s) to split out and retry separately (if it does) — extra logic that a limit-aware batcher (Section 6) avoids needing in the first place, by never assembling a non-compliant batch to begin with.
Architectural Topology: Failing vs Remediated
| Layer | Failing configuration (current) | Remediated configuration (fix) |
|---|---|---|
| Batching logic | Fixed-size chunking (e.g. every 16 or every 100 rows) | Dynamic, token-aware batching — fills each batch up to all three limits |
| Limit awareness | Array count only, often hardcoded from the legacy "16" fix | Array count + per-item length + aggregate token sum, all tracked live |
| Token counting | None — length assumed from row count or character count | Real tokenizer (tiktoken) counts before assembling each batch |
| Failure mode | Whole batch rejected; compliant rows lost or require manual retry | Batches never assembled non-compliant in the first place |
| Oversized single input | Unhandled — crashes the whole pipeline run | Detected and routed to truncation/chunking logic before it reaches the API |
| Concurrency | Sequential, one batch at a time — slow for large backfills | Async queue with bounded concurrency, respecting TPM/RPM quota |
| Transient failures | No retry, or naive immediate retry (hammers the same limit again) | Exponential backoff honoring Retry-After, partial-batch recovery |
| Portability across models | Hardcoded limit numbers scattered through the codebase | Limits as configuration, keyed by model/API version |
Every limit in this article is expressed in tokens, not characters or words, and there is no reliable shortcut from character count to token count — different text (code, non-English languages, technical jargon) tokenizes at meaningfully different rates. The foundation of a compliant batcher is a real tokenizer, run once per input, before any batching decision is made.
If you're processing a large, recurring backfill (the "thousands of database rows" scenario the brief describes), tokenizing every row on every pipeline run is wasted work if the underlying text doesn't change often. Store the computed token count as a column alongside the source text, recompute it only when the text itself changes, and the batching queue can read a number instead of running a tokenizer on every row, every time. For a one-off backfill this optimization matters less; for a pipeline that runs daily against a growing table, it compounds.
A row whose own text exceeds 8,192 tokens on its own violates Gate 2 regardless of batching strategy — it can never be sent as-is, batched or not. Decide this policy up front, not as an exception handler reacting to a crash: truncate to the limit (simple, lossy), split into multiple chunks and average or concatenate the resulting embeddings (more faithful, more complex), or route oversized rows to a separate review queue for a human decision. Whichever you choose, the batcher needs to detect this case before attempting to include the row in any batch, not discover it via a 400 response.
This is the core of the fix: an async batching queue that fills each outgoing request up to — but never past — all three limits simultaneously, then dispatches batches concurrently within a bounded worker pool.
The batch assembler above uses a straightforward greedy fill: add items until any limit would be crossed, then close the batch. This isn't the theoretically optimal packing (a bin-packing algorithm could in principle achieve marginally better batch utilization), but it's simple, predictable, easy to verify correct, and — critically for a data pipeline — deterministic given the same input order. The gains from a more sophisticated packing algorithm are marginal against the cost of correctness bugs in a more complex implementation. Optimize this only if profiling shows batch count is genuinely a bottleneck, not by default.
Even a correctly-assembled batch can fail for reasons outside the batcher's control — rate limiting, transient service errors. Handle these distinctly from limit-violation errors, which should be treated as defects in the assembler, not transient conditions to retry through.
For a backfill against thousands of database rows, the pipeline will eventually be re-run — after a bug fix, after adding new rows, after a partial failure. Persist which row IDs have confirmed embeddings (a simple embedded_at timestamp column, or a separate tracking table) so a re-run can filter to only the rows still needing embedding, rather than re-processing — and re-paying for — rows that already succeeded. This matters more as the table grows; redoing a 50,000-row backfill because 200 rows failed partway through is an expensive way to fix 200 rows.
Per-request limits (Sections 1-2) and per-minute quota (Tokens-Per-Minute, TPM) are separate concerns, and a batcher that only solves the first will still get rate-limited by the second on a large backfill. A high-concurrency dispatcher that fires many compliant batches simultaneously can burn through TPM quota faster than the deployment allows, producing 429s that are a quota problem, not a batch-assembly problem.
Set the limiter's capacity below your deployment's actual TPM quota, not equal to it — other consumers of the same deployment (other pipelines, interactive traffic sharing the same model deployment), estimation error in token counting, and the quota's own enforcement granularity all argue for headroom. A limiter configured at exactly 100% of quota will still occasionally trigger 429s from timing edge cases; 90-95% of quota is a more realistic ceiling.
Because the "16-input" fix is so widely known, teams often reach for a fix that resembles it without addressing the actual current constraint.
| Anti-pattern | Why it feels right | Why it isn't |
|---|---|---|
| Just raise the chunk size to 2,048 | "That's the new documented array limit" | Ignores the aggregate token cap entirely. Fails the moment average input length is realistic, not toy-sized |
| Estimate tokens from character count (÷4) | "Close enough, and it's fast" | Tokenization ratios vary meaningfully by content type. A batch that "should" be under the aggregate cap by rough estimate can still exceed it in reality |
| Retry a 400 context-length error with the same batch | "Retries fix most API errors" | An oversized batch fails identically every time. This wastes API calls and delays discovering the real assembler bug |
| Fixed concurrency with no TPM awareness | "More parallelism = faster backfill" | High concurrency on compliant-sized batches can still exceed per-minute quota, producing 429s that look like a batching problem but are actually a throughput problem |
| Silently drop or skip rows that fail after retries | "Don't let one bad row block the whole pipeline" | Silent data loss. A backfill that "completed" with gaps nobody tracked is a worse failure mode than a job that visibly stops and reports what's missing |
| Hardcode the limit values inline, scattered across the codebase | "It's just a number" | When the model or API version changes, someone has to hunt down every hardcoded 16, 2048, or 300000 across the codebase instead of updating one config object |
Validation & Verification: Confirm the Fix
Confirm the batcher respects all three gates under realistic data, not just the toy inputs used during development, and that a full backfill run completes with every row accounted for.
Three conditions must hold together. One: property-based unit tests confirm no assembled batch ever violates any of the three gates, across randomized realistic token-length distributions, not just hand-picked examples. Two: an integration test against real (or realistically-shaped) production data — including your actual longest documents — completes with zero API-side batch failures. Three: after a full backfill run, every source row is accounted for: either successfully embedded, or explicitly routed to a documented exception path (oversized, permanently failed after retries) — never silently missing. Miss any of the three and either the fix is unverified against real data shapes, or the pipeline can still lose rows without anyone noticing.
Key Takeaways
Frequently Asked Questions
Related FAVRITE Articles
- The Runaway Vector Index Bill: Tuning Vector Dimensions in Azure Cosmos DB
- How to Fix Azure OpenAI Token Limits: Architectural Patterns for High-Throughput Apps
- The PTU Math Trap: When to Pivot from Pay-As-You-Go to Provisioned Throughput
- Purging the Keys: Migrating Azure OpenAI Applications to Managed Identities and RBAC