Skip to content

Commit e92ced3

Browse files
committed
fix: throttle Azure OpenAI embedding calls and back off on rate limits
The data loader previously fanned out all 4 contribution types ("Spoken", "Written", "Corrections", "Petitions") concurrently in a TaskGroup, with each type spawning one task per page. With nothing limiting concurrent embed_batch calls, the loader could trivially exceed Azure's TPM quota (especially the default S0 tier at 350k TPM). The retry decorator stop_after_attempt(3) had no wait, so it ignored the Retry-After header. Changes: - Add an aiolimiter + semaphore around every embeddings.create call, configurable via OPENAI_EMBED_RATE_PER_SECOND, OPENAI_EMBED_MAX_CONCURRENCY, OPENAI_EMBED_BATCH_SIZE env vars. Defaults are tuned for the S0 tier. - Switch tenacity to wait_random_exponential with jitter, retry only on RateLimitError / APITimeoutError / APIConnectionError, up to 8 attempts. - Pass max_retries=6 to AsyncAzureOpenAI so the SDK honours Retry-After before tenacity takes over for longer waits. - Reduce default batch_size from 100 to 32 (~10k tokens/call) to fit the per-minute quota. - Run load_all_contributions sequentially; pages within each type still parallelise.
1 parent 489cacb commit e92ced3

2 files changed

Lines changed: 70 additions & 19 deletions

File tree

parliament_mcp/openai_helpers.py

Lines changed: 63 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -1,22 +1,59 @@
1+
import asyncio
12
import logging
3+
import os
24
from itertools import batched
35

46
import httpx
7+
import openai
8+
from aiolimiter import AsyncLimiter
59
from openai import AsyncAzureOpenAI
6-
from tenacity import retry, stop_after_attempt
10+
from tenacity import (
11+
retry,
12+
retry_if_exception_type,
13+
stop_after_attempt,
14+
wait_random_exponential,
15+
)
716

817
from parliament_mcp.settings import ParliamentMCPSettings
918

1019
logger = logging.getLogger(__name__)
1120

1221

22+
def _env_float(name: str, default: float) -> float:
23+
try:
24+
return float(os.environ.get(name, default))
25+
except (TypeError, ValueError):
26+
return default
27+
28+
29+
def _env_int(name: str, default: int) -> int:
30+
try:
31+
return int(os.environ.get(name, default))
32+
except (TypeError, ValueError):
33+
return default
34+
35+
36+
# Throttles for Azure OpenAI embedding calls. Defaults are conservative enough
37+
# for the S0 tier (350k TPM / 2,100 RPM); raise via env vars when on a larger
38+
# quota. The TPM cap, not RPM, is the binding constraint at default tier.
39+
_EMBED_RATE_PER_SECOND = _env_float("OPENAI_EMBED_RATE_PER_SECOND", 1.5)
40+
_EMBED_MAX_CONCURRENCY = _env_int("OPENAI_EMBED_MAX_CONCURRENCY", 2)
41+
_EMBED_DEFAULT_BATCH_SIZE = _env_int("OPENAI_EMBED_BATCH_SIZE", 32)
42+
43+
_embed_limiter = AsyncLimiter(max_rate=_EMBED_RATE_PER_SECOND, time_period=1.0)
44+
_embed_semaphore = asyncio.Semaphore(_EMBED_MAX_CONCURRENCY)
45+
46+
1347
def get_openai_client(settings: ParliamentMCPSettings) -> AsyncAzureOpenAI:
1448
"""Get an async Azure OpenAI client."""
1549
return AsyncAzureOpenAI(
1650
api_key=settings.AZURE_OPENAI_API_KEY,
1751
api_version=settings.AZURE_OPENAI_API_VERSION,
1852
azure_endpoint=settings.AZURE_OPENAI_ENDPOINT,
19-
http_client=httpx.AsyncClient(timeout=30.0),
53+
# The SDK honours Retry-After on 429s; let it ride out short windows
54+
# before tenacity takes over for longer waits.
55+
max_retries=6,
56+
http_client=httpx.AsyncClient(timeout=60.0),
2057
)
2158

2259

@@ -27,21 +64,27 @@ async def embed_single(
2764
dimensions: int = 1024,
2865
) -> list[float]:
2966
"""Generate a single embedding for a text using Azure OpenAI."""
30-
response = await client.embeddings.create(
31-
input=text,
32-
model=model,
33-
dimensions=dimensions,
34-
)
67+
async with _embed_semaphore, _embed_limiter:
68+
response = await client.embeddings.create(
69+
input=text,
70+
model=model,
71+
dimensions=dimensions,
72+
)
3573
return response.data[0].embedding
3674

3775

38-
@retry(stop=stop_after_attempt(3))
76+
@retry(
77+
retry=retry_if_exception_type((openai.RateLimitError, openai.APITimeoutError, openai.APIConnectionError)),
78+
wait=wait_random_exponential(min=2, max=60),
79+
stop=stop_after_attempt(8),
80+
reraise=True,
81+
)
3982
async def embed_batch(
4083
client: AsyncAzureOpenAI,
4184
texts: list[str],
4285
model: str,
4386
dimensions: int = 1024,
44-
batch_size: int = 100,
87+
batch_size: int = _EMBED_DEFAULT_BATCH_SIZE,
4588
) -> list[list[float]]:
4689
"""Generate embeddings for a list of texts using Azure OpenAI.
4790
@@ -50,7 +93,7 @@ async def embed_batch(
5093
texts: List of texts to embed
5194
model: Deployment name for the embedding model
5295
dimensions: Number of dimensions for the embeddings (default 1024)
53-
batch_size: Number of texts to process in each API call
96+
batch_size: Number of texts per API call (default tuned for S0 tier)
5497
5598
Returns:
5699
List of embedding vectors
@@ -59,15 +102,20 @@ async def embed_batch(
59102

60103
for i, batch in enumerate(batched(texts, batch_size)):
61104
try:
62-
response = await client.embeddings.create(
63-
input=batch,
64-
model=model,
65-
dimensions=dimensions,
66-
)
105+
async with _embed_semaphore, _embed_limiter:
106+
response = await client.embeddings.create(
107+
input=batch,
108+
model=model,
109+
dimensions=dimensions,
110+
)
67111

68112
batch_embeddings = [item.embedding for item in response.data]
69113
all_embeddings.extend(batch_embeddings)
70114

115+
except openai.RateLimitError:
116+
# Let tenacity retry the whole batch with backoff.
117+
logger.warning("Azure OpenAI rate limit hit on batch %d; backing off", i // batch_size + 1)
118+
raise
71119
except Exception:
72120
logger.exception("Error generating embeddings for batch %d", i // batch_size + 1)
73121
raise

parliament_mcp/qdrant_data_loaders.py

Lines changed: 7 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -234,12 +234,15 @@ async def load_all_contributions(
234234
from_date: str = "2020-01-01",
235235
to_date: str = "2020-02-10",
236236
) -> None:
237-
"""Load all contribution types concurrently."""
237+
"""Load all contribution types sequentially.
238+
239+
Sequential rather than concurrent to keep total in-flight embedding
240+
calls bounded; the per-type loader still parallelises pages internally.
241+
"""
238242
contribution_types = ["Spoken", "Written", "Corrections", "Petitions"]
239243
with self.progress_context():
240-
async with asyncio.TaskGroup() as tg:
241-
for contrib_type in contribution_types:
242-
tg.create_task(self.load_contributions_by_type(contrib_type, from_date, to_date))
244+
for contrib_type in contribution_types:
245+
await self.load_contributions_by_type(contrib_type, from_date, to_date)
243246

244247
async def load_contributions_by_type(
245248
self,

0 commit comments

Comments
 (0)