Module 7: Advanced Flows

Cycles and Loops: Retry Patterns

Capsule overview

Your research agent calls a web search API. 90% of the time, it works. The other 10%: timeout, 429 Too Many Requests, 503 Service Unavailable, or simply a connection that drops halfway through the response. Without retry, your agent crashes and the user sees an error. The entire investigation is lost to a transient failure that would have worked 2 seconds later.

Retry is the most fundamental pattern in distributed systems. But a badly implemented retry is worse than no retry at all. If your API returns 429 (rate limit) and your code retries immediately, you're sending more requests to an API that already told you "stop." If 100 clients do the same thing simultaneously, the API collapses. That has a name: an accidental DDoS.

In this capsule you're going to implement retry correctly from the start: with exponential backoff (you wait longer and longer between attempts) and random jitter (not every client retries at the same moment). You'll implement it two ways: as a cycle in a StateGraph (a conditional edge that loops back) and as a while loop in the Functional API. Both approaches are valid — the choice depends on whether your workflow is a graph or a function.


The problem: APIs that fail

Your Research Agent v1 calls a web search API. When the API returns 429 Too Many Requests or times out, your agent dies. The whole pipeline — decomposition, search, synthesis — is lost to a transient failure that would have worked 2 seconds later.

Not every error deserves a retry. The critical distinction:

TypeExamplesRetry?
Transient429 Too Many Requests, 503, Timeout✅ Wait and retry
Permanent401 Unauthorized, 404, 400 Bad Request❌ It's not going to work

Retry only makes sense for transient errors — where the same request, sent seconds later, has a reasonable chance of working.


Naive retry: why it doesn't work

The first instinct is: if it fails, retry immediately. But look at the timeline:

Timeline with immediate retry (3 attempts):

t=0.00s  → Request 1 — 429 Too Many Requests
t=0.01s  → Request 2 — 429 Too Many Requests
t=0.02s  → Request 3 — 429 Too Many Requests
t=0.03s  → Exception: "Failed after 3 attempts"

3 requests in 30 milliseconds to an API that just told you "I'm overloaded." If 100 users do the same, the API gets 300 requests in 30ms. Retry without backoff amplifies the problem instead of solving it.


Exponential backoff with jitter: the right solution

The formula

import random

delay = min(base_delay * (2 ** attempt) + random.uniform(0, jitter), max_delay)
ComponentWhat it doesTypical value
base_delayBase wait time1.0 second
2 ** attemptDoubles the wait on each attempt1, 2, 4, 8, 16...
jitterRandom variation to desynchronize clients0.5 seconds
max_delayMaximum wait ceiling30 seconds

What it looks like in practice

Timeline with exponential backoff + jitter:

t=0.00s   → Request 1 — 429 Too Many Requests
t=1.23s   → Request 2 — 429 Too Many Requests  (waited ~1.2s)
t=3.67s   → Request 3 — 200 OK ✅              (waited ~2.4s)

Total: 3.67 seconds, 3 attempts, successful

Compared to immediate retry:

  • You gave the API time to recover
  • Each attempt waits longer than the last
  • The random jitter keeps 100 clients from retrying on exactly the same second

That's the theory. Now let's see how to implement the same concept inside LangGraph, where the retry is part of the graph topology or of a function's control flow.


Cycles in graphs: an edge that goes back

In StateGraph, a cycle is a conditional edge that routes a node back to itself or to a previous node:

            ┌──────────── retry ──────────────┐
            │                                  │
            ▼                                  │
START → search_node → should_retry ─── success → END
                          │
                          └── max_retries_exceeded → fallback_node → END

Full implementation with StateGraph

import time
import random
from typing import TypedDict
from langgraph.graph import StateGraph, START, END


class RetryState(TypedDict):
    query: str
    result: str
    retry_count: int
    max_retries: int
    last_error: str
    status: str


def search_node(state: RetryState) -> dict:
    if random.random() < 0.6:
        error_msg = f"503 Service Unavailable (attempt {state['retry_count'] + 1})"
        print(f"[SEARCH] ❌ Failed: {error_msg}")
        return {"last_error": error_msg, "status": "error"}
    print(f"[SEARCH] ✅ Success on attempt {state['retry_count'] + 1}")
    return {"result": f"Results for: {state['query']}", "last_error": "", "status": "success"}


def wait_with_backoff(state: RetryState) -> dict:
    attempt = state["retry_count"]
    delay = min(1.0 * (2 ** attempt) + random.uniform(0, 0.5), 30.0)
    print(f"[BACKOFF] Waiting {delay:.2f}s (attempt {attempt + 1})")
    time.sleep(delay)
    return {"retry_count": attempt + 1}


def fallback_node(state: RetryState) -> dict:
    return {"result": f"Partial result for '{state['query']}' — source unavailable", "status": "fallback"}


def should_retry(state: RetryState) -> str:
    if state["status"] == "success":
        return "done"
    if state["retry_count"] >= state["max_retries"]:
        return "fallback"
    return "retry"


builder = StateGraph(RetryState)
builder.add_node("search", search_node)
builder.add_node("wait_backoff", wait_with_backoff)
builder.add_node("fallback", fallback_node)
builder.add_edge(START, "search")
builder.add_conditional_edges("search", should_retry, {
    "done": END, "retry": "wait_backoff", "fallback": "fallback"
})
builder.add_edge("wait_backoff", "search")
builder.add_edge("fallback", END)

graph = builder.compile()

result = graph.invoke({
    "query": "prompt engineering best practices",
    "result": "", "retry_count": 0, "max_retries": 3, "last_error": "", "status": ""
})
print(f"\nResult: {result['result']}")
print(f"Status: {result['status']}, Attempts: {result['retry_count'] + 1}")
# Expected output (varies with the random):
# [SEARCH] ❌ Failed: 503 Service Unavailable (attempt 1)
# [BACKOFF] Waiting 1.23s (attempt 1)
# [SEARCH] ✅ Success on attempt 3
# Result: Results for: prompt engineering best practices
# Status: success, Attempts: 3

The cycle is the wait_backoff → search edge — an explicit loop in the graph topology. The should_retry conditional edge decides on each iteration: success → END, failure with attempts left → backoff → search (loop), failure with no attempts left → fallback → END.


Retry in the Functional API: a for loop with try/except

The same pattern without graph topology — pure Python with @entrypoint and @task:

import time
import random
from langgraph.func import entrypoint, task


@task
def search_web(query: str) -> str:
    if random.random() < 0.6:
        raise ConnectionError(f"503 Service Unavailable searching '{query}'")
    return f"Search results for: {query}"


@entrypoint()
def research_with_retry(query: str) -> dict:
    max_retries, base_delay = 3, 1.0

    for attempt in range(max_retries):
        try:
            result = search_web(query).result()
            return {"query": query, "result": result, "status": "success"}
        except Exception as e:
            print(f"[RETRY] Attempt {attempt + 1} failed: {e}")
            if attempt < max_retries - 1:
                delay = min(base_delay * (2 ** attempt) + random.uniform(0, 0.5), 30.0)
                time.sleep(delay)

    return {"query": query, "result": f"Partial result — source unavailable", "status": "fallback"}


result = research_with_retry.invoke("prompt engineering best practices")
print(f"Result: {result['result']}, Status: {result['status']}")
# Expected output (varies):
# [RETRY] Attempt 1 failed: 503 Service Unavailable searching '...'
# Result: Search results for: prompt engineering best practices, Status: success

More compact. Same logic. The for loop replaces the cycle in the graph, the try/except replaces the conditional edge, and the code after the loop replaces the fallback node.


Comparison: retry in StateGraph vs the Functional API

AspectStateGraphFunctional API
CycleConditional edge that loops backfor/while with try/except
Stateretry_count in a TypedDictLocal variable attempt
BackoffDedicated nodetime.sleep() inline
CheckpointingEvery node (granular)Only @task
Lines~50~30

Decision rule: if the user needs to see "retrying search..." in the stream → StateGraph (the cycle is visible and checkpointable). If the retry is an internal detail → Functional API (more compact). In most cases, retry is internal.


Filtering errors: what deserves a retry and what doesn't

A retry that doesn't distinguish between transient and permanent errors wastes time. If the API returns 401 Unauthorized, retrying 3 times with 7 seconds of backoff isn't going to change anything — your API key is still invalid.

import httpx

RETRYABLE_STATUS_CODES = {429, 500, 502, 503, 504}
RETRYABLE_EXCEPTIONS = (httpx.TimeoutException, httpx.ConnectError, ConnectionError, TimeoutError)


def is_retryable(error: Exception) -> bool:
    """Determines whether an error deserves a retry."""
    if isinstance(error, httpx.HTTPStatusError):
        return error.response.status_code in RETRYABLE_STATUS_CODES
    return isinstance(error, RETRYABLE_EXCEPTIONS)

Wired into the retry, the pattern is: catch the exception, check whether it's retryable, and only then apply backoff. If it's not retryable, propagate the error immediately with raise:

import time
import random
import httpx

def search_with_smart_retry(query: str, max_retries: int = 3, base_delay: float = 1.0) -> dict:
    """Smart retry — only retries transient errors."""
    for attempt in range(max_retries):
        try:
            response = httpx.get("https://api.example.com/search", params={"q": query}, timeout=10.0)
            response.raise_for_status()
            return response.json()
        except Exception as e:
            if not is_retryable(e):
                print(f"[SEARCH] Permanent error (not retryable): {e}")
                raise
            if attempt < max_retries - 1:
                delay = min(base_delay * (2 ** attempt) + random.uniform(0, 0.5), 30.0)
                print(f"[RETRY] Attempt {attempt + 1}: {e}. Waiting {delay:.2f}s")
                time.sleep(delay)

    raise Exception(f"Search failed after {max_retries} attempts")

429 → retry with backoff. 401 → propagate immediately. That distinction keeps you from burning time on unrecoverable errors.


Break conditions: when to stop retrying

max_retries isn't the only stop condition. In production, you need a timeout budget — a maximum total time for the entire retry operation. It doesn't matter if you have 5 attempts left; if 30 seconds have already gone by, the user isn't going to wait any longer.

import time
import random


def search_with_timeout_budget(query: str, max_retries: int = 5, timeout_budget: float = 15.0) -> dict:
    """Retry with a total time budget."""
    start_time = time.time()

    for attempt in range(max_retries):
        elapsed = time.time() - start_time
        remaining = timeout_budget - elapsed

        if remaining <= 0:
            return {"status": "timeout", "query": query, "attempts": attempt}

        try:
            if random.random() < 0.5:
                raise ConnectionError("Simulated failure")
            return {"status": "success", "result": f"Results for: {query}", "attempts": attempt + 1}
        except Exception as e:
            if attempt < max_retries - 1:
                delay = min(1.0 * (2 ** attempt) + random.uniform(0, 0.5), remaining)
                if delay <= 0:
                    break
                print(f"[RETRY] Attempt {attempt + 1}: {e}. Waiting {delay:.2f}s ({remaining:.1f}s left)")
                time.sleep(delay)

    return {"status": "failed", "query": query, "attempts": max_retries}


result = search_with_timeout_budget("AI safety", timeout_budget=10.0)
print(f"Status: {result['status']}, Attempts: {result['attempts']}")
# Expected output (varies):
# [RETRY] Attempt 1: Simulated failure. Waiting 1.23s (9.8s left)
# Status: success, Attempts: 2

The key detail: delay = min(..., remaining). If only 2 seconds of budget are left and the backoff suggests 4 seconds, wait only 2. Two stop conditions, whichever is reached first wins: max_retries OR timeout_budget.


recursion_limit: LangGraph's safety net

LangGraph has a safety mechanism for cycles: recursion_limit. If a graph runs more than N supersteps (transitions between nodes), it raises GraphRecursionError. The default is 25 supersteps.

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


class CounterState(TypedDict):
    counter: int


def increment(state: CounterState) -> dict:
    print(f"Counter: {state['counter']}")
    return {"counter": state["counter"] + 1}


def always_loop(state: CounterState) -> str:
    return "loop"


builder = StateGraph(CounterState)
builder.add_node("increment", increment)
builder.add_edge(START, "increment")
builder.add_conditional_edges("increment", always_loop, {"loop": "increment"})

graph = builder.compile()

try:
    result = graph.invoke({"counter": 0}, {"recursion_limit": 5})
except Exception as e:
    print(f"\nError: {type(e).__name__}: {e}")
# Expected output:
# Counter: 0
# Counter: 1
# ...
# Counter: 4
# Error: GraphRecursionError: Recursion limit of 5 reached...

If your retry loop has a bug and should_retry never returns "done", the recursion_limit prevents an infinite loop. Always configure both: the business limit (max_retries) and the safety limit (recursion_limit). For a retry cycle with 2 nodes per iteration (search + backoff), set: recursion_limit = max_retries * 2 + 5.


Retry with context: carrying information between attempts

Sometimes the API returns a Retry-After header that tells you how long to wait. Add suggested_delay to the state so the backoff node can use it:

def adaptive_backoff(state: dict) -> dict:
    attempt = state["retry_count"]
    if state.get("suggested_delay", 0) > 0:
        delay = state["suggested_delay"] + random.uniform(0, 0.5)
    else:
        delay = min(1.0 * (2 ** attempt) + random.uniform(0, 0.5), 30.0)
    time.sleep(delay)
    return {"retry_count": attempt + 1}

The graph state carries information between iterations of the cycle. Data from attempt N shapes the strategy of attempt N+1.


Troubleshooting

Problem 1: Unexpected "GraphRecursionError"

Symptom: GraphRecursionError before the retries run out. Cause: default recursion_limit = 25. Each retry with 2 nodes (search + backoff) consumes 2 supersteps. Fix: graph.invoke(state, {"recursion_limit": max_retries * 2 + 5}).

Problem 2: Every client retries at the same time

Symptom: After a spike, all the retries cause another spike. Cause: Backoff without jitter — everyone computes the same delay. Fix: Always include random.uniform(0, jitter) added to the delay.

Problem 3: Retry keeps retrying permanent errors

Symptom: 7 seconds of retry against a 401 Unauthorized. Cause: You're not filtering by error type. Fix: Implement is_retryable(error) — only retry 429, 500, 502, 503, 504.

Problem 4: The delay grows without bound

Symptom: On attempt 10, the delay is 1024 seconds. Cause: Backoff without max_delay. Fix: Always use min(delay, max_delay) with max_delay=30.

Problem 5: Flaky tests

Symptom: Tests pass only sometimes because they depend on random.random(). Fix: Use random.seed(42) at the start of the test for determinism.


Exercises

Exercise 1: Basic exponential backoff (Easy)

Write a function calculate_delays that takes max_retries, base_delay, and jitter=0 (no jitter so you can verify the pattern) and returns a list with the delay for each attempt. Check that the delays double: [1.0, 2.0, 4.0, 8.0] for base_delay=1.0 and max_retries=4.

See solution
def calculate_delays(
    max_retries: int,
    base_delay: float = 1.0,
    jitter: float = 0.0,
    max_delay: float = 60.0
) -> list[float]:
    """Computes the sequence of delays for retry with exponential backoff."""
    import random
    delays = []
    for attempt in range(max_retries):
        delay = min(
            base_delay * (2 ** attempt) + random.uniform(0, jitter),
            max_delay
        )
        delays.append(round(delay, 2))
    return delays


delays_no_jitter = calculate_delays(max_retries=4, base_delay=1.0, jitter=0.0)
print(f"No jitter: {delays_no_jitter}")
# Expected output: No jitter: [1.0, 2.0, 4.0, 8.0]

delays_with_jitter = calculate_delays(max_retries=4, base_delay=1.0, jitter=0.5)
print(f"With jitter: {delays_with_jitter}")
# Expected output: With jitter: [1.23, 2.41, 4.15, 8.33] (varies with the random)

delays_with_cap = calculate_delays(max_retries=6, base_delay=1.0, jitter=0.0, max_delay=10.0)
print(f"With cap: {delays_with_cap}")
# Expected output: With cap: [1.0, 2.0, 4.0, 8.0, 10.0, 10.0]

assert delays_no_jitter == [1.0, 2.0, 4.0, 8.0], "Backoff must double"
assert all(d <= 10.0 for d in delays_with_cap), "max_delay must be respected"
print("\n✅ All assertions passed")

Explanation: The formula base_delay * (2 ** attempt) produces the geometric sequence. Without jitter, it's deterministic: 1, 2, 4, 8, 16... The min(..., max_delay) makes sure the delay never exceeds the ceiling.

Exercise 2: Retry with the Functional API (Easy)

Create an @entrypoint with a @task that simulates a function that fails the first 2 times and works on the third. Implement retry with backoff inside the entrypoint. Check that it returns successfully on the third attempt.

See solution
import time
import random
from langgraph.func import entrypoint, task

call_count = 0

@task
def flaky_api_call(query: str) -> str:
    """Simulates an API that fails the first 2 times."""
    global call_count
    call_count += 1
    if call_count <= 2:
        raise ConnectionError(f"Simulated failure (attempt {call_count})")
    return f"Successful response for: {query}"


@entrypoint()
def agent_with_retry(query: str) -> dict:
    max_retries = 4
    base_delay = 0.1  # Short delays for the exercise

    for attempt in range(max_retries):
        try:
            result = flaky_api_call(query).result()
            return {"result": result, "attempts": attempt + 1, "status": "success"}
        except Exception as e:
            print(f"[RETRY] Attempt {attempt + 1}: {e}")
            if attempt < max_retries - 1:
                delay = base_delay * (2 ** attempt) + random.uniform(0, 0.05)
                time.sleep(delay)

    return {"result": "", "attempts": max_retries, "status": "failed"}


call_count = 0
result = agent_with_retry.invoke("test query")
print(f"\nResult: {result['result']}")
print(f"Attempts: {result['attempts']}")
print(f"Status: {result['status']}")
# Expected output:
# [RETRY] Attempt 1: Simulated failure (attempt 1)
# [RETRY] Attempt 2: Simulated failure (attempt 2)
#
# Result: Successful response for: test query
# Attempts: 3
# Status: success

assert result["status"] == "success"
assert result["attempts"] == 3
print("✅ Retry worked correctly")

Explanation: The @task fails twice and succeeds on the third try. The for loop inside the @entrypoint implements retry with backoff. The try/except catches the error from .result() and decides whether to retry.

Exercise 3: Retry with StateGraph and an accumulated error log (Medium)

Implement a StateGraph with a retry cycle where the state has errors: Annotated[list[str], operator.add] as a reducer to accumulate errors. Include process, backoff, fallback nodes, and a should_retry conditional edge. At the end, print the error list to see the full failure history.

See solution
import time
import random
from typing import TypedDict, Annotated
import operator
from langgraph.graph import StateGraph, START, END


class ProcessState(TypedDict):
    input_data: str
    result: str
    retry_count: int
    max_retries: int
    errors: Annotated[list[str], operator.add]
    status: str


def process_node(state: ProcessState) -> dict:
    if random.random() < 0.7:
        return {"errors": [f"Attempt {state['retry_count'] + 1}: failed"], "status": "error"}
    return {"result": f"Processed: {state['input_data']}", "status": "success"}


def backoff_node(state: ProcessState) -> dict:
    delay = min(0.1 * (2 ** state["retry_count"]) + random.uniform(0, 0.05), 5.0)
    time.sleep(delay)
    return {"retry_count": state["retry_count"] + 1}


def fallback_node(state: ProcessState) -> dict:
    return {"result": f"Fallback for: {state['input_data']}", "status": "fallback"}


def should_retry(state: ProcessState) -> str:
    if state["status"] == "success":
        return "done"
    return "fallback" if state["retry_count"] >= state["max_retries"] else "retry"


builder = StateGraph(ProcessState)
builder.add_node("process", process_node)
builder.add_node("backoff", backoff_node)
builder.add_node("fallback", fallback_node)
builder.add_edge(START, "process")
builder.add_conditional_edges("process", should_retry, {"done": END, "retry": "backoff", "fallback": "fallback"})
builder.add_edge("backoff", "process")
builder.add_edge("fallback", END)
graph = builder.compile()

random.seed(42)
result = graph.invoke({"input_data": "test data", "result": "", "retry_count": 0, "max_retries": 3, "errors": [], "status": ""})
print(f"Status: {result['status']}, Errors: {result['errors']}")
# Expected output: Status: success/fallback, Errors: ['Attempt 1: failed', ...]

Explanation: Annotated[list[str], operator.add] accumulates errors from every attempt. At the end, result['errors'] holds the full history.

Exercise 4: Retryable vs permanent error filter (Medium)

Create a function smart_retry that takes a function and two tuples: retryable_exceptions and permanent_exceptions. If the function raises a retryable exception, retry with backoff. If it raises a permanent one, propagate it immediately. Test it with ValueError (permanent) and ConnectionError (retryable).

See solution
import time
import random


def smart_retry(func, max_retries=3, base_delay=0.1,
                retryable=(ConnectionError, TimeoutError),
                permanent=(ValueError, TypeError)):
    last_exc = None
    for attempt in range(max_retries):
        try:
            return func()
        except permanent as e:
            print(f"[PERMANENT] {type(e).__name__}: {e}")
            raise
        except retryable as e:
            last_exc = e
            print(f"[RETRY] {attempt + 1}/{max_retries}: {type(e).__name__}: {e}")
            if attempt < max_retries - 1:
                time.sleep(min(base_delay * (2 ** attempt) + random.uniform(0, 0.05), 10.0))
    raise last_exc


# Test 1: retryable — works on the third attempt
call_count = 0
def flaky():
    global call_count
    call_count += 1
    if call_count <= 2:
        raise ConnectionError(f"Failure {call_count}")
    return "OK"

call_count = 0
print(smart_retry(flaky, max_retries=4))
# Output: [RETRY] 1/4: ... → [RETRY] 2/4: ... → OK

# Test 2: permanent — doesn't retry
try:
    smart_retry(lambda: (_ for _ in ()).throw(ValueError("bad input")), max_retries=4)
except ValueError as e:
    print(f"Caught: {e}")
# Output: [PERMANENT] ValueError: bad input → Caught: bad input

Explanation: The permanent except blocks are caught first and propagated with raise. The retryable ones enter the backoff cycle. The order of the except blocks is what determines the behavior.

Exercise 5: Retry with a timeout budget in @entrypoint (Medium-Advanced)

Create an @entrypoint that searches with retry and two stop conditions: max_retries=10 and timeout_budget=3.0. Use a @task that always fails to verify that the budget runs out in ~3 seconds, not in 10 attempts. Log the elapsed time on each attempt.

See solution
import time
import random
from langgraph.func import entrypoint, task


@task
def always_fail(query: str) -> str:
    raise ConnectionError(f"Service unavailable for: {query}")


@entrypoint()
def search_with_budget(inputs: dict) -> dict:
    query, max_retries = inputs["query"], inputs.get("max_retries", 10)
    timeout_budget, base_delay = inputs.get("timeout_budget", 10.0), inputs.get("base_delay", 1.0)
    start_time = time.time()
    attempts = 0

    for attempt in range(max_retries):
        remaining = timeout_budget - (time.time() - start_time)
        if remaining <= 0:
            break
        attempts += 1
        try:
            return {"status": "success", "result": always_fail(query).result(), "attempts": attempts}
        except Exception as e:
            print(f"[RETRY] Attempt {attempts} at t={time.time() - start_time:.2f}s: {e}")
            if attempt < max_retries - 1:
                delay = min(base_delay * (2 ** attempt) + random.uniform(0, 0.3), remaining)
                if delay > 0:
                    time.sleep(delay)

    elapsed = round(time.time() - start_time, 2)
    return {"status": "budget_exhausted", "attempts": attempts, "elapsed": elapsed}


result = search_with_budget.invoke({"query": "test", "max_retries": 10, "timeout_budget": 3.0, "base_delay": 1.0})
print(f"\nStatus: {result['status']}, Attempts: {result['attempts']}, Time: {result['elapsed']}s")
assert result["elapsed"] <= 4.0, f"3s budget exceeded: {result['elapsed']}s"
print(f"✅ Budget respected: {result['elapsed']}s <= 4.0s")
# Expected output:
# [RETRY] Attempt 1 at t=0.00s: Service unavailable for: test
# [RETRY] Attempt 2 at t=1.12s: Service unavailable for: test
# Status: budget_exhausted, Attempts: 3, Time: ~3.0s
# ✅ Budget respected

Explanation: With max_retries=10 but timeout_budget=3.0, the budget runs out after ~3 attempts (delays: 1s, 2s). The budget, not max_retries, is what stops the loop.

Exercise 6: Retry encapsulated as a building block (Advanced)

Create a @task called search_source_with_retry that encapsulates all the retry logic internally. It takes {"source": str, "query": str}, simulates a search with failures, implements retry with backoff + jitter, and returns {"status", "source", "results", "attempts"}. Then use that task from an @entrypoint that searches 3 sources. This building block gets reused in Capsule 03 for parallel branching.

See solution
import time
import random
from langgraph.func import entrypoint, task

FAILURE_RATES = {"web": 0.3, "papers": 0.5, "news": 0.2}


def simulate_search(source: str, query: str) -> list[dict]:
    failure_rate = FAILURE_RATES.get(source, 0.3)
    if random.random() < failure_rate:
        raise ConnectionError(f"{source}: 503 Service Unavailable")
    return [{"title": f"[{source}] Result for '{query}'", "relevance": 0.9}]


@task
def search_source_with_retry(inputs: dict) -> dict:
    source, query = inputs["source"], inputs["query"]
    max_retries, base_delay = 3, 0.5
    errors = []

    for attempt in range(max_retries):
        try:
            results = simulate_search(source, query)
            return {"status": "success", "source": source, "results": results, "attempts": attempt + 1}
        except ConnectionError as e:
            errors.append(str(e))
            if attempt < max_retries - 1:
                delay = min(base_delay * (2 ** attempt) + random.uniform(0, 0.3), 10.0)
                time.sleep(delay)

    return {"status": "exhausted", "source": source, "results": [], "attempts": max_retries, "errors": errors}


@entrypoint()
def multi_source_search(query: str) -> dict:
    sources = ["web", "papers", "news"]
    futures = [search_source_with_retry({"source": s, "query": query}) for s in sources]
    results = [f.result() for f in futures]

    successful = [r for r in results if r["status"] == "success"]
    return {"query": query, "successful": len(successful), "total": len(sources), "results": results}


random.seed(123)
report = multi_source_search.invoke("retry patterns")
print(f"Successful sources: {report['successful']}/{report['total']}")
for r in report["results"]:
    icon = "✅" if r["status"] == "success" else "⚠️"
    print(f"  {icon} {r['source']}: {r['status']} ({r['attempts']} attempts)")
# Expected output (varies with the seed):
# Successful sources: 3/3
#   ✅ web: success (1 attempts)
#   ✅ papers: success (2 attempts)
#   ✅ news: success (1 attempts)

Explanation: search_source_with_retry encapsulates retry, backoff, and jitter in a single @task. The @entrypoint uses it per source without knowing anything about the retry logic. In Capsule 03, this same task will run in parallel with branching.


Summary

In this capsule you learned:

  • Retry without backoff is an anti-pattern. Retrying immediately amplifies the problem — you send more requests to an API that already told you "stop." Always use exponential backoff with jitter
  • The formula: delay = min(base * 2^attempt + random.uniform(0, jitter), max_delay). It doubles the wait on each attempt, adds random variation, and respects a ceiling
  • Distinguish transient errors from permanent ones. 429 and 503 deserve a retry. 401 and 404 don't. Retrying permanent errors wastes time
  • In StateGraph, retry is an explicit cycle: a conditional edge that routes back to a node. The state carries retry_count and last_error. The topology is visible and streamable
  • In the Functional API, retry is a for/while loop with try/except and time.sleep(). More compact, same effect. The retry is a hidden implementation detail
  • Two stop conditions: max_retries (number of attempts) and timeout_budget (total time). Whichever is reached first wins
  • recursion_limit is LangGraph's safety net against infinite loops. Always set it above your expected max_retries
  • Retry with context carries information between attempts (like Retry-After headers) to adapt the strategy

Next capsule: Branching and Merge: Parallel Execution — your Research Agent searches 3 sources sequentially (9 seconds). You're going to run all 3 searches in parallel (3 seconds) using fan-out/fan-in and LangGraph's Send API.


Additional resources

  1. Exponential Backoff and Jitter (AWS Architecture Blog) — The definitive article on backoff with jitter. It compares full jitter, equal jitter, and decorrelated jitter with simulations
  2. LangGraph — Concepts: Cycles — Official documentation on how LangGraph handles cycles and the recursion_limit
  3. LangGraph — How to control graph recursion limit — Practical guide to configuring and handling the recursion limit
  4. Retry Pattern — Microsoft Azure Architecture — Microsoft's documentation on the retry pattern with implementation guidance
  5. Circuit Breaker Pattern — Martin Fowler — The next level after retry: when to stop trying altogether. A preview of Module 7 Capsule 06
  6. httpx — Timeouts — Timeout reference for httpx, the HTTP library used in the examples

Module 7 — LangChain & LangGraph: From Chains to Agents

Next capsule: Branching and Merge: Parallel Execution — you'll learn fan-out/fan-in to run searches in parallel, LangGraph's Send API, and how to merge results from multiple sources with deduplication.