Problem Description
The controller manager that orchestrates the batch document‑summarization pipeline crashes under high‑throughput inference loads. Typical logs show a rapid succession of unhandled exceptions:
Traceback (most recent call last):
File "/app/controller_manager.py", line 112, in run_batch
responses = await asyncio.gather(*tasks)
File ".../openai/api_resources/completion.py", line 78, in create
raise openai.error.RateLimitError(message, http_status, headers)
openai.error.RateLimitError: Rate limit reached for token (HTTP 429)
In other runs the process is terminated by the OOM killer:
MemoryError: Cannot allocate memory
[1] 25437 killed python controller_manager.py
Operational impact includes:
- Interrupted summarization jobs, requiring manual restarts.
- Loss of in‑flight request state because the controller process exits abruptly.
- Spikes in Kubernetes pod restarts, inflating deployment cost.
Root Cause Analysis
The crash stems from two intertwined failures:
- Uncaught
RateLimitErrorexceptions. The OpenAI API returns HTTP 429 with aRetry‑Afterheader when the per‑minute token quota is exceeded (see OpenAI API Reference – Errors and Rate Limits). The controller manager usesasyncio.gatheron a large list of tasks without individual try/catch blocks, so a single 429 propagates as an unhandled exception and terminates the event loop. - Unbounded request/response buffers. The pipeline builds a list of pending payloads and stores full responses in memory while awaiting rate‑limit back‑off. Under a burst of 10,000 parallel summarization requests (as observed in the internal telemetry logs), the in‑memory queue grows faster than the API can process, exhausting the pod’s RAM and triggering an OOM kill.
Both issues violate the expectations outlined in the OpenAI Cookbook – Handling Rate Limits and Transient Errors, which recommends per‑request retry logic and exponential back‑off, and the Production Best Practices guide, which advises streaming responses and limiting max tokens per request to control memory usage.
Investigation and Debugging
Step‑by‑step diagnostics that reproduced the failure:
- Log inspection – Grep for HTTP 429 and
QueueFullentries:
$ grep -E "429|QueueFull" controller.log
2024-07-28T12:03:14Z ERROR Rate limit reached for token (HTTP 429)
2024-07-28T12:03:14Z ERROR asyncio.exceptions.QueueFull: Queue size limit exceeded
- Metric snapshot – Using
kubectl top podshowed memory usage climbing from 400 MiB to >2 GiB before the pod was killed. - Heap dump analysis – A core dump revealed a
listobject holding >1 million pending request dictionaries, confirming unbounded queue growth. - Reproduction with a minimal script – The following snippet reproduces the unhandled exception when 500 concurrent calls are issued without retry handling:
import asyncio, openai
async def call_api(prompt):
return await openai.ChatCompletion.acreate(
model="gpt-4",
messages=[{"role": "user", "content": prompt}]
)
async def main():
tasks = [call_api("Summarize doc {}".format(i)) for i in range(500)]
await asyncio.gather(*tasks) # <-- crashes on first 429
asyncio.run(main())
Running this script produced the same RateLimitError stack trace shown earlier.
Resolution
The fix addresses both exception handling and memory pressure:
1. Centralized retry wrapper with exponential back‑off
Introduce a reusable decorator that catches openai.error.RateLimitError, reads the Retry-After header, and retries with jitter.
# before: direct API call without retry
async def summarize(text):
return await openai.ChatCompletion.acreate(
model="gpt-4",
messages=[{"role": "user", "content": text}],
max_tokens=256
)
# after: robust wrapper
import asyncio, random, time, openai
from openai.error import RateLimitError
def retry_on_rate_limit(max_retries=5, base_delay=1.0):
def decorator(fn):
async def wrapper(*args, **kwargs):
delay = base_delay
for attempt in range(max_retries):
try:
return await fn(*args, **kwargs)
except RateLimitError as e:
retry_after = e.headers.get("Retry-After")
if retry_after:
delay = float(retry_after)
else:
delay = delay * (2 + random.random())
await asyncio.sleep(delay)
raise RuntimeError("Exceeded max retries for RateLimitError")
return wrapper
return decorator
@retry_on_rate_limit()
async def summarize(text):
return await openai.ChatCompletion.acreate(
model="gpt-4",
messages=[{"role": "user", "content": text}],
max_tokens=256,
stream=False
)
2. Bounded asyncio queue with back‑pressure
Replace the unbounded list of pending tasks with a limited asyncio.Queue. Workers pull from the queue, ensuring the number of inflight API calls never exceeds a configurable ceiling.
# before: accumulating tasks in a plain list
tasks = [summarize(chunk) for chunk in chunks]
await asyncio.gather(*tasks)
# after: queue‑driven producer/consumer pattern
MAX_INFLIGHT = 100 # tuned to stay within token quota
queue = asyncio.Queue(maxsize=MAX_INFLIGHT)
async def producer(chunks):
for chunk in chunks:
await queue.put(chunk) # blocks when queue is full
async def consumer():
while True:
chunk = await queue.get()
try:
await summarize(chunk)
finally:
queue.task_done()
async def run_pipeline(chunks):
prod = asyncio.create_task(producer(chunks))
cons = [asyncio.create_task(consumer()) for _ in range(8)]
await asyncio.gather(prod)
await queue.join()
for c in cons:
c.cancel()
3. Streaming responses & token budgeting
Enable streaming to avoid buffering full responses in memory and limit max_tokens per request to the smallest acceptable value (e.g., 256). This follows the guidance in the OpenAI API Best Practices guide.
Validation
After deploying the patched controller manager, the following checks confirm stability:
- Log sanity – No longer see uncaught
RateLimitErrortraces. Retry attempts are logged atINFOlevel. - Memory profile –
kubectl top podshows steady memory usage around 600 MiB, even during a 10 k request burst. - Successful completion – End‑to‑end batch run of 10 k documents finishes without pod restarts; total runtime aligns with expected back‑off schedule.
- Metrics – Prometheus counters for
openai_api_requests_totalandopenai_api_rate_limit_retriesreflect a controlled retry rate (≈2 % of total calls).
Prevention and Best Practices
| Area | Recommendation |
|---|---|
| Rate‑limit handling | Always wrap OpenAI calls in a retry decorator that respects Retry‑After and uses exponential back‑off with jitter. |
| Concurrency control | Use bounded asyncio.Queue or semaphore to limit in‑flight requests to a safe ceiling (e.g., 100–200 depending on quota). |
| Memory management | Stream responses, cap max_tokens, and avoid storing full payloads in long‑lived collections. |
| Observability | Emit metrics for request rate, retry count, queue size, and memory usage; set alerts on queue‑full or OOM events. |
| Testing | Include load‑testing scripts that simulate burst traffic and verify graceful back‑pressure behavior before production rollout. |
Related Topic Hub: LLM Systems Troubleshooting Hub
FAQ
- Why does the controller crash only when the batch size exceeds a few thousand requests? The unbounded task list grows faster than the API can process, exhausting RAM. A bounded queue enforces back‑pressure, keeping memory usage constant.
- Can I rely on the
Retry‑Afterheader alone for back‑off? Yes, per the OpenAI error documentation the header indicates the minimum wait time. Combining it with exponential jitter prevents thundering‑herd effects. - How should I choose the
MAX_INFLIGHTvalue? Start with a value that stays comfortably below your per‑minute token quota (e.g., 100 requests for a 60 k token/min quota) and adjust based on observed latency and retry metrics. - Is streaming mandatory to avoid MemoryError? Streaming dramatically reduces peak memory because the client discards chunks as they arrive. It is strongly recommended for large responses, but bounding the queue alone also prevents OOM in most cases.
- What monitoring alerts are most useful? Alert on:
- Queue size approaching
maxsize(e.g., >80%). - Rate‑limit retry rate exceeding a threshold (e.g., >5 % of calls).
- Pod memory usage >80 % of its limit.
These give early warning before crashes occur.
- Queue size approaching