← Back to DevBytes

Rate Limiting and Retry Strategies with LangGraph: Complete Guide

Rate Limiting and Retry Strategies with LangGraph: Complete Guide

Building production-grade applications with LangGraph often means orchestrating dozens of LLM calls, tool invocations, and external API requests inside a single graph run. When you scale that workload, two problems inevitably surface: rate limits from upstream providers and transient failures that cause your graph to crash mid-execution. This guide walks through everything you need to know to make your LangGraph workflows resilient, observable, and production-ready.

What Is Rate Limiting and Why It Matters

Rate limiting is a mechanism providers use to control how many requests a client can make within a given time window. LLM providers like OpenAI, Anthropic, and Google enforce limits on tokens-per-minute (TPM), requests-per-minute (RPM), and concurrent connections. When you exceed these thresholds, the provider returns an HTTP 429 error instead of a response.

In a LangGraph application, a single user query might trigger a chain of nodes, each making multiple LLM calls. Without rate limiting on your side, a burst of traffic can quickly exhaust your quota, cause cascading failures, and degrade the experience for every user. Worse, retrying failed requests naively can amplify the problem by hammering the provider even harder.

Why Retry Strategies Are Essential

Not every failure is permanent. Network blips, temporary 429 responses, and brief server-side hiccups (HTTP 5xx) often resolve themselves within seconds. A well-designed retry strategy:

Combining rate limiting with retries creates a self-regulating system: the rate limiter keeps you under quota, and the retry layer handles the occasional overflow gracefully.

Understanding LangGraph's Execution Model

LangGraph executes graphs as a series of node invocations connected by edges. Each node receives a state object, performs work, and returns an updated state. Because nodes can call external services, they are the natural place to implement rate limiting and retry logic.

LangGraph also supports checkpointing, which persists state between steps. This is powerful for retries because a failed node can be re-executed from its last known good state rather than restarting the entire graph. The MemorySaver and SqliteSaver checkpointers are commonly used for this purpose.

Implementing Rate Limiting in LangGraph

Approach 1: Token Bucket Rate Limiter

A token bucket is a classic rate-limiting algorithm. Tokens accumulate at a fixed rate, and each request consumes one or more tokens. If the bucket is empty, the request must wait. Here is a thread-safe implementation you can inject into any node:

import time
import threading
from collections import deque

class TokenBucketRateLimiter:
    def __init__(self, rate: float, capacity: int):
        self.rate = rate  # tokens per second
        self.capacity = capacity
        self.tokens = capacity
        self.last_refill = time.monotonic()
        self.lock = threading.Lock()

    def acquire(self, tokens: int = 1, timeout: float = 60.0):
        start = time.monotonic()
        while True:
            with self.lock:
                now = time.monotonic()
                elapsed = now - self.last_refill
                self.tokens = min(self.capacity, self.tokens + elapsed * self.rate)
                self.last_refill = now
                if self.tokens >= tokens:
                    self.tokens -= tokens
                    return True
                needed = tokens - self.tokens
                wait_time = needed / self.rate
            if time.monotonic() - start + wait_time > timeout:
                raise TimeoutError("Rate limit acquire timed out")
            time.sleep(min(wait_time, 0.5))

# Create a shared limiter: 50 requests per minute = ~0.83/sec, burst of 10
llm_rate_limiter = TokenBucketRateLimiter(rate=50 / 60, capacity=10)

Approach 2: Sliding Window Counter

A sliding window counter tracks requests within a rolling time window. It is slightly more accurate than a fixed window because it avoids boundary spikes:

import time
from collections import deque

class SlidingWindowLimiter:
    def __init__(self, max_requests: int, window_seconds: float):
        self.max_requests = max_requests
        self.window = window_seconds
        self.timestamps = deque()

    def acquire(self):
        now = time.monotonic()
        while self.timestamps and self.timestamps[0] <= now - self.window:
            self.timestamps.popleft()
        if len(self.timestamps) >= self.max_requests:
            sleep_time = self.window - (now - self.timestamps[0])
            time.sleep(max(sleep_time, 0))
            return self.acquire()
        self.timestamps.append(now)

rpm_limiter = SlidingWindowLimiter(max_requests=40, window_seconds=60)

Integrating the Limiter Into a LangGraph Node

Now let's wire the rate limiter into an actual LangGraph node. The pattern is simple: acquire a token before making the LLM call, then proceed:

from langgraph.graph import StateGraph, START, END
from langchain_openai import ChatOpenAI
from typing import TypedDict

class AgentState(TypedDict):
    query: str
    response: str
    attempts: int

llm = ChatOpenAI(model="gpt-4o-mini", temperature=0)

def research_node(state: AgentState) -> AgentState:
    # Block until a rate-limit slot is available
    llm_rate_limiter.acquire(tokens=1)
    try:
        result = llm.invoke(state["query"])
        return {"response": result.content, "attempts": state.get("attempts", 0) + 1}
    except Exception as e:
        return {"response": "", "attempts": state.get("attempts", 0) + 1}

graph_builder = StateGraph(AgentState)
graph_builder.add_node("research", research_node)
graph_builder.add_edge(START, "research")
graph_builder.add_edge("research", END)
graph = graph_builder.compile()

Implementing Retry Strategies

Approach 1: Decorator-Based Retries

A decorator is a clean way to wrap any node function with retry logic. This example uses exponential backoff with jitter and only retries on specific HTTP status codes:

import time
import random
import functools
from httpx import HTTPStatusError

RETRYABLE_STATUS = {429, 500, 502, 503, 504}

def with_retry(max_attempts=4, base_delay=1.0, max_delay=30.0):
    def decorator(func):
        @functools.wraps(func)
        def wrapper(*args, **kwargs):
            attempt = 0
            while True:
                attempt += 1
                try:
                    return func(*args, **kwargs)
                except HTTPStatusError as e:
                    status = e.response.status_code
                    if status not in RETRYABLE_STATUS or attempt >= max_attempts:
                        raise
                    delay = min(max_delay, base_delay * (2 ** (attempt - 1)))
                    delay = delay + random.uniform(0, delay * 0.1)  # jitter
                    print(f"Attempt {attempt} failed (HTTP {status}), retrying in {delay:.2f}s")
                    time.sleep(delay)
                except Exception as e:
                    if attempt >= max_attempts:
                        raise
                    delay = min(max_delay, base_delay * (2 ** (attempt - 1)))
                    time.sleep(delay + random.uniform(0, 0.5))
        return wrapper
    return decorator

@with_retry(max_attempts=5, base_delay=2.0)
def call_llm(prompt: str) -> str:
    return llm.invoke(prompt).content

Approach 2: Using tenacity for Robust Retries

The tenacity library is battle-tested and offers more features than a hand-rolled decorator, including per-exception retry policies and callbacks:

from tenacity import (
    retry,
    stop_after_attempt,
    wait_exponential_jitter,
    retry_if_exception_type,
    before_sleep_log,
)
import logging

logging.basicConfig(level=logging.INFO)
logger = logging.getLogger(__name__)

@retry(
    stop=stop_after_attempt(6),
    wait=wait_exponential_jitter(initial=1, max=30, jitter=2),
    retry=retry_if_exception_type((HTTPStatusError, ConnectionError, TimeoutError)),
    before_sleep=before_sleep_log(logger, logging.WARNING),
    reraise=True,
)
def call_llm_with_tenacity(prompt: str) -> str:
    response = llm.invoke(prompt)
    return response.content

Approach 3: LangGraph's Built-in Retry Policy

LangGraph supports a RetryPolicy that you can attach to nodes at compile time. This is the most idiomatic approach because it integrates with the graph's checkpointing system, allowing failed nodes to resume from their last checkpoint rather than re-running the entire graph:

from langgraph.pregel import RetryPolicy
from langgraph.errors import GraphRecursionError

# Define a retry policy: up to 5 attempts, exponential backoff
retry_policy = RetryPolicy(
    max_attempts=5,
    initial_interval=1.0,
    backoff_factor=2.0,
    max_interval=30.0,
    jitter=True,
)

# Attach the policy to specific nodes
graph = graph_builder.compile(
    retry_policy=retry_policy,
)

When a node raises an exception, LangGraph automatically waits according to the policy and re-executes the node. Because state is checkpointed, any successfully completed prior nodes are not re-run.

Combining Rate Limiting and Retries in a Real Graph

Let's build a more realistic example: a multi-agent research graph with two LLM-calling nodes, both protected by rate limiting and retries. We'll use a SQLite checkpointer so that state survives crashes:

from langgraph.graph import StateGraph, START, END
from langgraph.checkpoint.sqlite import SqliteSaver
from langgraph.pregel import RetryPolicy
from langchain_openai import ChatOpenAI
from typing import TypedDict
import sqlite3

class ResearchState(TypedDict):
    topic: str
    outline: str
    draft: str
    final: str

llm = ChatOpenAI(model="gpt-4o-mini", temperature=0)
limiter = TokenBucketRateLimiter(rate=40 / 60, capacity=8)

@with_retry(max_attempts=5, base_delay=2.0)
def outline_node(state: ResearchState) -> dict:
    limiter.acquire()
    prompt = f"Create a detailed outline about: {state['topic']}"
    return {"outline": llm.invoke(prompt).content}

@with_retry(max_attempts=5, base_delay=2.0)
def draft_node(state: ResearchState) -> dict:
    limiter.acquire()
    prompt = f"Write a draft using this outline:\n{state['outline']}"
    return {"draft": llm.invoke(prompt).content}

@with_retry(max_attempts=5, base_delay=2.0)
def polish_node(state: ResearchState) -> dict:
    limiter.acquire()
    prompt = f"Polish this draft for clarity and tone:\n{state['draft']}"
    return {"final": llm.invoke(prompt).content}

builder = StateGraph(ResearchState)
builder.add_node("outline", outline_node)
builder.add_node("draft", draft_node)
builder.add_node("polish", polish_node)
builder.add_edge(START, "outline")
builder.add_edge("outline", "draft")
builder.add_edge("draft", "polish")
builder.add_edge("polish", END)

conn = sqlite3.connect("checkpoints.db", check_same_thread=False)
saver = SqliteSaver(conn)

retry_policy = RetryPolicy(max_attempts=4, initial_interval=1.0, backoff_factor=2.0)

app = builder.compile(checkpointer=saver, retry_policy=retry_policy)

# Run with a thread_id so the graph can resume after failures
config = {"configurable": {"thread_id": "research-session-1"}}
result = app.invoke({"topic": "The future of renewable energy"}, config=config)
print(result["final"])

Handling 429 Responses Specifically

Many providers include a Retry-After header in 429 responses. Honoring this value is more efficient than guessing with backoff. Here is how to parse and respect it:

import time
from httpx import HTTPStatusError

@with_retry(max_attempts=6, base_delay=1.0)
def call_with_retry_after(prompt: str) -> str:
    try:
        return llm.invoke(prompt).content
    except HTTPStatusError as e:
        if e.response.status_code == 429:
            retry_after = e.response.headers.get("Retry-After")
            if retry_after:
                wait = float(retry_after)
                print(f"Rate limited. Sleeping {wait}s per Retry-After header.")
                time.sleep(wait)
        raise

Best Practices

Conclusion

Rate limiting and retry strategies are not optional polish — they are foundational requirements for any LangGraph application that touches real LLM APIs in production. By combining a token-bucket or sliding-window limiter with exponential-backoff retries, jitter, checkpointing, and LangGraph's native RetryPolicy, you can build graphs that degrade gracefully under pressure instead of crashing. Start with the simple patterns shown here, instrument them with logging and metrics, and iterate as you learn how your specific workload interacts with provider limits. The result is a resilient agent pipeline that your users — and your API bill — will thank you for.

— Ad —

Google AdSense will appear here after approval

← Back to all articles