◉ANTIGRAVITY LABJP
Articles/App Development
▣ App Development/2026-04-23Advanced

Keeping the Antigravity Python API Stable in Production — Retries, Timeouts, and Circuit Breakers That Actually Work

A deeply practical guide to keeping Python services built on the Google Gen AI SDK alive under real traffic. We cover retry, timeout, circuit breaker, rate limit, and cost budgeting patterns with runnable code from an Antigravity workflow.

python28antigravity464production71resilience9retry8circuit-breaker3rate-limit4

✦ Premium Article

It's 2 AM, Slack starts pinging, and your production AI endpoint has been flat on its back for fifteen minutes. The cause is almost always one of two things: a transient 503 wave on Google Gen AI's side, or your own retry loop burning through your RPM allowance while the real API was briefly merely grumpy. Either way, the thing that failed was not the API — it was your resilience design.

When you're building Python agents or API servers in Antigravity, it's easy to ship a first version that works beautifully on your laptop and falls apart the moment real traffic hits it. A bare try: ... except: ..., a blanket three-retry loop, a single global httpx.AsyncClient(timeout=30) — none of these are wrong by themselves, but together they tend to produce the worst failure modes: silent retries, client timeouts, cost explosions, and the deeply unpleasant feeling that you're not sure why any of it is happening.

This article walks through a resilience stack I've iterated on across several production services that call the Google Gen AI SDK from Python, all developed inside Antigravity. I'll show you where things actually break, how to respond without overcorrecting, and which parts of the design matter the most. By the end, you should be able to look at your own service and tell — with some confidence — where the next outage will come from and what you'd do about it.

Classify Failures Before You Write a Single Retry Line

The most common mistake I see in "resilient" clients is treating every exception the same way. A blanket retry on Exception is worse than no retry at all: it turns your 400 Bad Request into a slow-motion cascade, and it disguises real bugs behind pretend transient errors.

When you call the Google Gen AI API, the failure modes you'll actually encounter fall into five buckets:

  • Transient service issues — HTTP 500/502/503/504. Recover in seconds to minutes. Safe to retry.
  • Rate limiting — HTTP 429. You've hit an RPM or TPM ceiling. Obey the Retry-After header.
  • Client-side errors — HTTP 400/401/403/404. Something about the request is wrong. Fail immediately.
  • Timeouts — connect, read, or end-to-end. Could be network, could be the model thinking for a long time. You need to know which.
  • Content filter failures — FinishReason.SAFETY, FinishReason.RECITATION. Retrying without changing the prompt is pointless. Never retry.

Only the first two are safe to retry blindly. Everything else needs a different response. The simplest way to enforce this at the code level is to define an exception hierarchy up front and let your retry library branch on it.

Start with a Well-Shaped Exception Hierarchy

Ask the Antigravity agent for "a small exception taxonomy that separates retryable from permanent AI API failures," and you get a template you can extend. Here's the shape I keep coming back to:

# ai_errors.py — retry-aware exception hierarchy
from __future__ import annotations
from dataclasses import dataclass
from typing import Optional
 
class AIError(Exception):
    """Base class for all AI API failures."""
 
class RetryableAIError(AIError):
    """Transient failure. Safe to retry with exponential backoff."""
 
class RateLimitedError(RetryableAIError):
    """HTTP 429. Respect retry_after when provided."""
 
    def __init__(self, message: str, retry_after: Optional[float] = None):
        super().__init__(message)
        self.retry_after = retry_after
 
class PermanentAIError(AIError):
    """Request is malformed or semantically invalid. Do not retry."""
 
@dataclass
class SafetyBlocked(PermanentAIError):
    """Blocked by content filter — prompt must be revisited."""
    reason: str
 
    def __str__(self) -> str:
        return f"Blocked by safety filter: {self.reason}"

The rest of the stack — retry loops, circuit breakers, metrics — is built on top of this taxonomy. Once you enforce "only RetryableAIError triggers retries," roughly 70% of the "retry storm" patterns I've seen in production code disappear on their own.

Exponential Backoff + Jitter, Without the Shortcuts

Fixed-interval retries are a classic anti-pattern. If ten instances all hit a 503 at the same moment and each retries in exactly one second, you'll all slam the API together the instant it recovers — exactly when it's most fragile. This is the "thundering herd" problem, and jitter is the cure.

The pattern I use in Python is tenacity on top of httpx. Tenacity lets you express the policy declaratively, so your business logic isn't drowning in retry plumbing.

# resilient_client.py — tenacity-based retry with exponential backoff + jitter
import asyncio
import logging
import random
import httpx
from tenacity import (
    AsyncRetrying,
    retry_if_exception_type,
    stop_after_attempt,
    stop_after_delay,
    wait_exponential_jitter,
    before_sleep_log,
)
 
from ai_errors import (
    AIError,
    PermanentAIError,
    RateLimitedError,
    RetryableAIError,
)
 
logger = logging.getLogger(__name__)
 
def _classify_httpx_error(exc: httpx.HTTPStatusError) -> AIError:
    status = exc.response.status_code
    if status == 429:
        retry_after = exc.response.headers.get("retry-after")
        return RateLimitedError(
            f"Rate limited: {exc.response.text[:200]}",
            retry_after=float(retry_after) if retry_after else None,
        )
    if status in (500, 502, 503, 504):
        return RetryableAIError(f"Transient {status}: {exc.response.text[:200]}")
    return PermanentAIError(f"{status}: {exc.response.text[:200]}")
 
async def call_with_retry(
    client: httpx.AsyncClient,
    request: httpx.Request,
    *,
    max_attempts: int = 5,
    total_budget_sec: float = 20.0,
) -> httpx.Response:
    """Call the Gen AI API with exponential backoff + jitter.
 
    - max_attempts: includes the first call
    - total_budget_sec: hard cap across all attempts
    - Honors Retry-After on 429
    - Re-raises PermanentAIError immediately
    """
    async for attempt in AsyncRetrying(
        reraise=True,
        stop=stop_after_attempt(max_attempts) | stop_after_delay(total_budget_sec),
        wait=wait_exponential_jitter(initial=1, max=8, jitter=1.5),
        retry=retry_if_exception_type(RetryableAIError),
        before_sleep=before_sleep_log(logger, logging.WARNING),
    ):
        with attempt:
            try:
                response = await client.send(request)
                response.raise_for_status()
                return response
            except httpx.HTTPStatusError as exc:
                err = _classify_httpx_error(exc)
                if isinstance(err, RateLimitedError) and err.retry_after is not None:
                    logger.warning(
                        "429 received; sleeping %.2fs per Retry-After",
                        err.retry_after,
                    )
                    await asyncio.sleep(err.retry_after + random.uniform(0, 0.5))
                    raise RetryableAIError("Retryable after 429") from exc
                raise err from exc
            except (httpx.ConnectError, httpx.ReadTimeout) as exc:
                raise RetryableAIError(f"Network error: {exc}") from exc

Four things in that code matter more than they might look.

First, wait_exponential_jitter(initial=1, max=8, jitter=1.5) gives you backoffs of roughly 1, 2, 4, 8 seconds, each scrambled by up to 1.5 seconds of jitter. Even if dozens of workers retry at the same time, they desynchronize naturally.

Second, combining stop_after_attempt with stop_after_delay means you stop when either the count or the time budget runs out. Without the time cap, a single slow attempt can push the whole retry window past any user-facing deadline.

Third, retry_if_exception_type(RetryableAIError) is where the earlier exception hierarchy pays off. PermanentAIError simply escapes the retry loop — no boilerplate needed.

Fourth, 429 is the odd one out. Exponential jitter is the wrong tool when the server has already told you exactly how long to wait. Respecting Retry-After (plus a tiny randomization to prevent everyone from returning at the same instant) is strictly better than computing your own schedule.

✦

Thank you for reading this far.

Continue Reading

What follows includes implementation code, benchmarks, and practical content we hope you'll find useful. This site runs without ads — server and development costs are supported entirely by members like you. If it's been helpful, we'd be truly grateful for your support.

WHAT YOU'LL LEARN
✦You'll learn how to survive the classic 'middle-of-the-night 503 cascade' and the equally classic 'retry storm that doubled our bill overnight' — with concrete defensive patterns you can drop into your Antigravity Python projects today
✦You will be able to implement exponential backoff with jitter, circuit breakers, and budget-aware rate limiters on top of tenacity, httpx, and asyncio, and you'll understand exactly which knob to turn when production behavior deviates from your mental model
✦You'll see the double-sleep trap — honoring Retry-After inside the attempt while tenacity also backs off — measured at 21.4s vs 12.6s, plus the custom wait policy that fixes it and the reason stop_after_delay never fired
Secure payment via Stripe · Cancel anytime
✦

Unlock This Article

Get full access to the rest of this article. Buy once, read anytime. This site is ad-free — your support goes directly toward keeping it running.

or
Unlock all articles with Membership →
Share

Thank You for Reading

Antigravity Lab is ad-free, supported entirely by members like you. We publish practical guides daily with implementation code, benchmarks, and production-ready patterns. If you've found it useful, we'd love to have you on board.

  • ✦Copy-paste ready implementation code
  • ✦New advanced guides published daily
  • ✦$5/mo or $10 for lifetime access
View Membership →

Related Articles

⬡ Integrations2026-04-22
Three Safeguards Every Antigravity Python API Deployment Needs Before Production
Retries, timeouts, and circuit breakers — the three production safeguards you need around your Antigravity Python API calls, with working code for each.
▣ App Dev2026-10-14
Before Turning Undocumented Code Into a Spec, Decide the Reading Order by Fan-In
When you ask an agent to turn undocumented code into a spec, which files do you hand over first? A 36-line Python script ranks files by import count (fan-in) plus route entry points, with measurements from a 77-file Next.js project.
▣ App Dev2026-05-03
Building Idempotency Keys and Dedupe Stores in TypeScript with Antigravity
A production guide to designing idempotency keys and dedupe stores in TypeScript with Antigravity — covering Stripe webhook retries, Temporal replays, and the Cloudflare KV / Redis / Postgres trade-offs you actually need to choose between.