TechWayFit
● Complete Learning Series Python & AI Engineering for .NET Developers

Resilient API Integration: Streaming & Retries

Tech Buddy August 30, 2026 3 min read
Resilient API Integration: Streaming & Retries

Moving from prototype to production means your AI integrations need to handle the real world: rate limits, server errors, network timeouts, partial responses, and the need to stream output to users as it's generated. This lesson covers the patterns that keep AI applications running when the API doesn't cooperate.

Streaming Responses

Streaming is critical for user experience. Without it, users stare at a blank screen for 5–10 seconds, then see the full response appear at once. With streaming, they see tokens appear in real time — much less jarring for long outputs.

Basic Streaming

By default an API call blocks until the entire completion is ready. Passing stream=True instead returns an iterator that yields the response in small chunks as the model generates them — the difference between a spinner and text that appears live, like ChatGPT's typewriter effect. You loop over the chunks and pull the incremental text out of each delta.

from openai import OpenAI
                      
                      client = OpenAI()
                      
                      
                      def stream_to_console(prompt: str) -> str:
                          """Stream a response, printing each chunk and returning the full text."""
                          full_text = ""
                      
                          with client.chat.completions.stream(
                              model="gpt-4o",
                              max_tokens=1024,
                              messages=[{"role": "user", "content": prompt}],
                          ) as stream:
                              for text_chunk in stream.text_stream:
                                  print(text_chunk, end="", flush=True)
                                  full_text += text_chunk
                      
                          print()  # newline at end
                          return full_text
                      
                      
                      # Usage
                      response = stream_to_console("Explain transformer attention in detail.")

Streaming with Metadata

Streaming the text is only half the job — you usually also need the finish reason and token usage once the stream completes, for logging and cost tracking. This version accumulates the text as it arrives and captures the final metadata from the last chunk, so you get live output and a complete record of the call.

from openai import OpenAI
                      import time
                      
                      def stream_with_metadata(prompt: str) -> dict:
                          """Stream a response and collect usage metadata."""
                          start = time.monotonic()
                          chunks = []
                          final_message = None
                      
                          with client.chat.completions.stream(
                              model="gpt-4o",
                              max_tokens=1024,
                              messages=[{"role": "user", "content": prompt}],
                          ) as stream:
                              for text in stream.text_stream:
                                  chunks.append(text)
                                  yield text  # if used as a generator
                      
                              # Final message available after stream completes
                              final_message = stream.get_final_message()
                      
                          latency = (time.monotonic() - start) * 1000
                          full_text = "".join(chunks)
                      
                          return {
                              "text": full_text,
                              "input_tokens": final_message.usage.prompt_tokens,
                              "output_tokens": final_message.usage.completion_tokens,
                              "latency_ms": latency,
                          }
                      
                      
                      # Async streaming
                      async def async_stream(prompt: str):
                          """Async streaming for concurrent applications."""
                          async with AsyncOpenAI() as async_client:
                              async with async_client.chat.completions.stream(
                                  model="gpt-4o",
                                  max_tokens=1024,
                                  messages=[{"role": "user", "content": prompt}],
                              ) as stream:
                                  async for text in stream.text_stream:
                                      yield text  # async generator

Production Retry Strategies

Idempotency: Safe to Retry

An operation is idempotent if calling it multiple times has the same effect as calling it once. LLM read requests are naturally idempotent — asking the same question twice doesn't change the world. Write operations (persisting results, billing) must be idempotent by design.

import hashlib
                      import json
                      from typing import Optional
                      
                      # Idempotency key pattern for write operations
                      def generate_idempotency_key(request_data: dict) -> str:
                          """Generate a deterministic key from request content."""
                          canonical = json.dumps(request_data, sort_keys=True)
                          return hashlib.sha256(canonical.encode()).hexdigest()[:16]
                      
                      
                      # Cache results to avoid duplicate charges
                      _request_cache: dict[str, str] = {}
                      
                      def idempotent_complete(
                          prompt: str,
                          model: str = "gpt-4o",
                          use_cache: bool = True,
                      ) -> str:
                          """Cache LLM responses by content hash — prevents duplicate API charges on retry."""
                          cache_key = generate_idempotency_key({"prompt": prompt, "model": model})
                      
                          if use_cache and cache_key in _request_cache:
                              return _request_cache[cache_key]
                      
                          response = client.chat.completions.create(
                              model=model,
                              max_tokens=1024,
                              messages=[{"role": "user", "content": prompt}],
                          )
                          result = response.choices[0].message.content
                      
                          if use_cache:
                              _request_cache[cache_key] = result
                      
                          return result

Robust Retry with tenacity

Transient failures — rate limits, timeouts, brief 5xx errors — are normal at scale, and the fix is to retry with exponential backoff rather than fail. The tenacity library turns that into a declarative decorator: you specify how many attempts, how long to wait between them, and which exception types are worth retrying (retrying a 400 Bad Request would just waste calls). It's the Polly of the Python world.

from openai import OpenAI
                      import logging
                      from tenacity import (
                          retry, stop_after_attempt, wait_exponential,
                          retry_if_exception_type, before_sleep_log, after_log
                      )
                      
                      logger = logging.getLogger(__name__)
                      
                      
                      def is_retryable(exception: Exception) -> bool:
                          """Custom predicate — retry on transient errors only."""
                          if isinstance(exception, openai.RateLimitError):
                              return True
                          if isinstance(exception, openai.InternalServerError):
                              return exception.status_code >= 500
                          if isinstance(exception, openai.APIConnectionError):
                              return True
                          return False
                      
                      
                      @retry(
                          retry=retry_if_exception_type((
                              openai.RateLimitError,
                              openai.InternalServerError,
                              openai.APIConnectionError,
                          )),
                          wait=wait_exponential(multiplier=2, min=2, max=60),
                          stop=stop_after_attempt(6),
                          before_sleep=before_sleep_log(logger, logging.WARNING),
                          reraise=True,
                      )
                      def resilient_create(client: OpenAI, **kwargs) -> openai.types.chat.ChatCompletion:
                          """Drop-in wrapper for client.chat.completions.create with automatic retry."""
                          return client.chat.completions.create(**kwargs)

Timeout Management

LLM requests can hang for 30+ seconds on overloaded servers. Setting timeouts prevents your application from waiting indefinitely:

from openai import OpenAI
                      import httpx
                      
                      # Configure timeout at client level
                      client = OpenAI(
                          timeout=httpx.Timeout(
                              connect=5.0,    # TCP connection timeout
                              read=60.0,      # Time to wait for server response (long for streaming)
                              write=10.0,     # Time to send request body
                              pool=5.0,       # Time to acquire connection from pool
                          )
                      )
                      
                      # Or override per-request
                      def fast_complete(prompt: str, timeout_seconds: float = 10.0) -> str | None:
                          """Complete with aggressive timeout — for latency-critical paths."""
                          try:
                              response = client.chat.completions.create(
                                  model="gpt-4o-mini-20240307",  # faster model for short timeout
                                  max_tokens=256,
                                  messages=[{"role": "user", "content": prompt}],
                                  timeout=timeout_seconds,
                              )
                              return response.choices[0].message.content
                          except openai.APITimeoutError:
                              logger.warning(f"Request timed out after {timeout_seconds}s")
                              return None

Circuit Breaker Pattern

A circuit breaker stops sending requests to a service that's consistently failing, giving it time to recover. This prevents your app from hammering a failing API:

import time
                      from enum import Enum
                      from dataclasses import dataclass, field
                      
                      class CircuitState(Enum):
                          CLOSED = "closed"       # normal operation
                          OPEN = "open"           # blocking requests
                          HALF_OPEN = "half_open" # testing recovery
                      
                      
                      @dataclass
                      class CircuitBreaker:
                          """Simple circuit breaker for AI API calls."""
                          failure_threshold: int = 5       # failures before opening
                          recovery_timeout: float = 60.0   # seconds before trying again
                      
                          _state: CircuitState = field(default=CircuitState.CLOSED, init=False)
                          _failure_count: int = field(default=0, init=False)
                          _last_failure_time: float = field(default=0.0, init=False)
                      
                          def call(self, func, *args, **kwargs):
                              if self._state == CircuitState.OPEN:
                                  if time.monotonic() - self._last_failure_time > self.recovery_timeout:
                                      self._state = CircuitState.HALF_OPEN
                                      logger.info("Circuit breaker: half-open, testing API")
                                  else:
                                      raise RuntimeError("Circuit breaker OPEN — API calls blocked")
                      
                              try:
                                  result = func(*args, **kwargs)
                                  self._on_success()
                                  return result
                              except (openai.RateLimitError, openai.InternalServerError) as e:
                                  self._on_failure()
                                  raise
                      
                          def _on_success(self):
                              self._failure_count = 0
                              self._state = CircuitState.CLOSED
                      
                          def _on_failure(self):
                              self._failure_count += 1
                              self._last_failure_time = time.monotonic()
                              if self._failure_count >= self.failure_threshold:
                                  self._state = CircuitState.OPEN
                                  logger.error(f"Circuit breaker OPENED after {self._failure_count} failures")
                      
                      
                      # Usage
                      breaker = CircuitBreaker(failure_threshold=5, recovery_timeout=30.0)
                      
                      def protected_call(prompt: str) -> str:
                          return breaker.call(
                              client.chat.completions.create,
                              model="gpt-4o",
                              max_tokens=512,
                              messages=[{"role": "user", "content": prompt}],
                          ).choices[0].message.content

Rate Limit Awareness

Retrying blindly still hits the wall if you're simply sending requests too fast. Being rate-limit-aware means reading the limit headers the API returns and proactively pacing yourself — backing off before you get a 429 rather than after. The pattern below inspects remaining-quota headers and sleeps when you're close to the limit.

import time
                      import threading
                      from collections import deque
                      
                      class RateLimiter:
                          """Token bucket rate limiter — respect API rate limits proactively."""
                      
                          def __init__(self, requests_per_minute: int = 50):
                              self._rpm = requests_per_minute
                              self._window = 60.0
                              self._timestamps: deque = deque()
                              self._lock = threading.Lock()
                      
                          def acquire(self) -> None:
                              """Block until a request slot is available."""
                              with self._lock:
                                  now = time.monotonic()
                      
                                  # Remove timestamps older than our window
                                  while self._timestamps and now - self._timestamps[0] > self._window:
                                      self._timestamps.popleft()
                      
                                  # If we've hit the limit, wait until the oldest request is outside the window
                                  if len(self._timestamps) >= self._rpm:
                                      wait_time = self._window - (now - self._timestamps[0])
                                      if wait_time > 0:
                                          time.sleep(wait_time)
                      
                                  self._timestamps.append(time.monotonic())
                      
                      
                      limiter = RateLimiter(requests_per_minute=40)  # conservative buffer below actual limit
                      
                      def rate_limited_call(prompt: str) -> str:
                          limiter.acquire()  # blocks if needed
                          return client.chat.completions.create(
                              model="gpt-4o",
                              max_tokens=512,
                              messages=[{"role": "user", "content": prompt}],
                          ).choices[0].message.content

Key Takeaways

  • Use client.chat.completions.stream() for user-facing applications — streaming dramatically improves perceived latency
  • Design retry logic around idempotency — cache results by content hash to prevent charging twice for the same request
  • Exponential backoff with jitter (tenacity) is the standard retry strategy — avoid thundering herd on rate limit recovery
  • Set explicit timeouts at both the client level and per-request — never let an LLM call hang indefinitely
  • The circuit breaker pattern prevents cascading failures when the API is degraded — open the circuit, wait, test, recover
  • Implement proactive rate limiting with a token bucket — staying under limits proactively is better than hitting 429s reactively

Comments

Loading comments…

Leave a comment