Azure OpenAI PTU capacity exceeded — throttling on reserved throughput
You reserved Provisioned Throughput Units so this would never happen. It happens anyway when the utilization model is misunderstood — this page shows exactly how PTU capacity is measured and how to stop the 429s.
Quick fix (TL;DR)
Real error messages you'll see
These are the exact strings returned by the Azure OpenAI service and its SDKs when this error occurs. Copy-paste-searching any of them should land on this page.
openai.RateLimitError: Error code: 429 - {'error': {'code': '429', 'message': 'Requests to the ChatCompletions_Create Operation under Azure OpenAI API version 2024-10-21 have exceeded call rate limit of your current OpenAI S0 pricing tier. Please retry after 3 seconds.'}}HTTP/1.1 429 Too Many Requests retry-after: 3 retry-after-ms: 2400 x-ratelimit-remaining-requests: 0 x-ratelimit-remaining-tokens: 0 apim-request-id: 8a3d-...
AzureOpenAIProvisionedManagedUtilization = 100.0 (threshold: 100%; when sustained, new requests receive 429)
Reference
What consumes PTU vs standard capacity
PTU is measured in units of throughput — each unit represents a slice of GPU capacity. Input tokens and output tokens consume PTU at different rates depending on the model.
| Model (deployment SKU=ProvisionedManaged) | Approx. tokens per PTU-second (input) | Approx. tokens per PTU-second (output) | Minimum PTU |
|---|---|---|---|
| gpt-4o (2024-08-06) | ~50 | ~13 | 50 |
| gpt-4o-mini | ~250 | ~65 | 25 |
| gpt-4.1 | ~40 | ~10 | 50 |
| o3-mini (reasoning) | ~30 | ~8 | 50 |
| text-embedding-3-large | ~500 (embeddings only) | n/a | 25 |
PTU vs standard vs global deployments — throttling model
| Deployment type | Throttling based on | Typical use case | Spillover option |
|---|---|---|---|
Standard | Regional TPM + RPM quotas | Dev, low volume | Fallback to another region |
ProvisionedManaged (PTU) | PTU utilization % of your reservation | Steady production traffic | Yes — to standard |
GlobalStandard | Global TPM + RPM, higher ceilings | Best-effort spike absorption | n/a |
DataZoneStandard | Data-residency-bounded TPM/RPM | EU-resident workloads | To standard in same zone |
GlobalBatch | 24-hour SLA, no per-second limit | Async large jobs | n/a |
Root causes, ranked by frequency
Based on developer reports across Azure OpenAI SDK forums, GitHub issues, and Microsoft Q&A during 2025–2026.
- 35%Undersized PTU reservation — you sized on average request shape but p95 requests (long context, long output, reasoning models) consume 3-5× the PTU per second. Always size on p95, not median.
- 22%Bursty traffic pattern — PTU is provisioned for steady state; a 10× spike over 30 seconds saturates 100% utilization even if your daily average is 40%.
- 15%No spillover configured — when PTU saturates, requests fail instead of overflowing to a standard deployment. Spillover is opt-in and off by default.
- 10%Long-running requests holding PTU — reasoning models (o1, o3) can take 30-60s per request, occupying PTU the entire time. Ten concurrent 60s requests can saturate 50 PTU.
- 8%Retry storms — client retries on 429 without honouring
retry-aftermultiply the effective load and keep PTU pinned at 100%. - 6%Wrong model on PTU — deploying gpt-4o on 25 PTU (the minimum for mini) is below the model minimum. Azure allocates the requested PTU but the model needs more to function without throttling.
- 3%Shared PTU across environments — dev, staging, and prod all pointing at one 50 PTU deployment. A dev load test can starve production.
- 1%Region-level saturation — very rare, but Azure has capped total PTU in a region during shortages. Your reservation is honoured but new expansions are refused.
Fixes — copy-paste solutions
Size PTU using the Azure Capacity Calculator with p95 request shape
Compute your p95 input tokens, p95 output tokens, and target requests-per-second over a real production window (7 days minimum). Feed those into the Azure AI Foundry Capacity Calculator or the token-throughput formula below. Provision at 1.3-1.5× the calculated PTU to leave burst headroom.
"""Size PTU from real request telemetry. Feed this Application Insights or Log Analytics data: - p95 input tokens per request - p95 output tokens per request - target sustained RPS """ from dataclasses import dataclass # Rough tokens-per-PTU-second for common models (2026-01, verify in Azure docs) MODEL_INPUT_TPS_PER_PTU = {"gpt-4o": 50, "gpt-4o-mini": 250, "gpt-4.1": 40, "o3-mini": 30} MODEL_OUTPUT_TPS_PER_PTU = {"gpt-4o": 13, "gpt-4o-mini": 65, "gpt-4.1": 10, "o3-mini": 8} @dataclass class TrafficShape: model: str p95_input_tokens: int p95_output_tokens: int target_rps: float burst_headroom: float = 1.4 # +40% for spikes def size_ptu(t: TrafficShape) -> int: input_tps = t.p95_input_tokens * t.target_rps output_tps = t.p95_output_tokens * t.target_rps ptu_for_input = input_tps / MODEL_INPUT_TPS_PER_PTU[t.model] ptu_for_output = output_tps / MODEL_OUTPUT_TPS_PER_PTU[t.model] # PTU serves whichever side is heavier at a given moment base_ptu = max(ptu_for_input, ptu_for_output) sized = int(base_ptu * t.burst_headroom) # Round up to increments of 25 (Azure's granularity) return ((sized + 24) // 25) * 25 # Example: chat app doing 10 RPS with 1500-token prompts and 400-token responses t = TrafficShape(model="gpt-4o", p95_input_tokens=1500, p95_output_tokens=400, target_rps=10.0) print(f"Recommended PTU: {size_ptu(t)}") # -> 100 PTU on gpt-4o
Enable PTU spillover to a Standard deployment
Azure supports opt-in spillover: requests that would exceed PTU capacity are automatically routed to a companion Standard deployment. You pay standard rates only for the overflowed tokens. Configure via the header x-ms-spillover-deployment-name or set it as a default on the PTU deployment.
import os from openai import AzureOpenAI client = AzureOpenAI( api_key=os.environ["AZURE_OPENAI_API_KEY"], azure_endpoint=os.environ["AZURE_OPENAI_ENDPOINT"], api_version="2024-10-21", ) # PTU deployment, with header pointing to a Standard fallback response = client.chat.completions.create( model="gpt4o-ptu", # PTU deployment messages=[{"role": "user", "content": "Hello"}], max_tokens=200, extra_headers={ "x-ms-spillover-deployment-name": "gpt4o-standard", # fallback }, ) # Response headers tell you which deployment served the request served_by = response.model_extra.get("x-ms-deployment-name") or "unknown" print(f"Served by: {served_by}")
# Provision a Standard deployment of the SAME model to receive spillover az cognitiveservices account deployment create \ --name my-aoai-resource \ --resource-group my-rg \ --deployment-name gpt4o-standard \ --model-name gpt-4o \ --model-version 2024-08-06 \ --model-format OpenAI \ --sku-capacity 100 \ --sku-name Standard # Verify PTU deployment carries the spillover default az cognitiveservices account deployment show \ --name my-aoai-resource -g my-rg \ --deployment-name gpt4o-ptu \ --query "properties.spilloverDeploymentName"
Client-side load shedding keyed to PTU utilization
Poll the Azure Monitor metric AzureOpenAIProvisionedManagedUtilization every 30 seconds and use it as a circuit-breaker signal. When utilization is above 85%, downgrade to a cheaper model, queue non-urgent requests, or return a "system busy" response for low-priority traffic.
import time import threading from azure.identity import DefaultAzureCredential from azure.monitor.query import MetricsQueryClient, MetricAggregationType RESOURCE_ID = "/subscriptions/{sub}/resourceGroups/{rg}/providers/Microsoft.CognitiveServices/accounts/{acct}" credential = DefaultAzureCredential() metrics_client = MetricsQueryClient(credential) class PTUUtilizationTracker: """Polls PTU utilization and exposes a boolean 'saturating' flag.""" def __init__(self, resource_id: str, poll_interval: int = 30, threshold: float = 85.0): self.resource_id = resource_id self.poll_interval = poll_interval self.threshold = threshold self._utilization = 0.0 self._stop = threading.Event() @property def saturating(self) -> bool: return self._utilization >= self.threshold def start(self): threading.Thread(target=self._loop, daemon=True).start() def stop(self): self._stop.set() def _loop(self): while not self._stop.is_set(): try: response = metrics_client.query_resource( self.resource_id, metric_names=["AzureOpenAIProvisionedManagedUtilization"], granularity="PT1M", aggregations=[MetricAggregationType.MAXIMUM], ) for metric in response.metrics: for ts in metric.timeseries: latest = ts.data[-1].maximum if ts.data else 0 self._utilization = float(latest or 0) except Exception: pass # Keep last known value on error self._stop.wait(self.poll_interval) # Application code tracker = PTUUtilizationTracker(RESOURCE_ID) tracker.start() def handle_request(is_urgent: bool): if tracker.saturating and not is_urgent: return {"error": "system_busy_retry_later", "utilization": tracker._utilization} # ... make the actual OpenAI call ...
Prevention checklist
Ship these seven safeguards once and this error stops appearing in your logs.
- Size PTU on p95 request shape, not median. Add 40% burst headroom on top.
- Always provision a companion Standard deployment of the same model + version for spillover.
- Set alerts on
AzureOpenAIProvisionedManagedUtilizationat 75% (warning) and 90% (critical) in Azure Monitor. - Never share one PTU deployment across dev, staging, and prod — dev load tests will starve prod.
- Honour the
retry-after-msheader exactly. Do not retry sooner and do not retry more than 3 times. - For reasoning models (o1, o3), size PTU 2-3× larger than non-reasoning at same RPS — long thinking time holds PTU.
- Publish a runbook: "PTU 429 spike" → check utilization metric → confirm spillover is on → consider adding PTU (Azure sizes up in ~5 min).
Frequently asked questions
GlobalStandard and GlobalBatch offer cross-region deployment identifiers but do not offer PTU pricing.Related errors & hubs
Get the weekly AI-error digest
New fixes, provider status recaps, and one deep tutorial — every Tuesday. 8,400+ engineers.