""" Reproduces LangGraph's GraphRecursionError deterministically, with no model call, no API key and no network. Artifact for /answers/langgraph-recursion-limit-25. The graph below is a two-node cycle whose exit condition is never satisfied. It is the minimal shape of a real looping agent: a worker node, a check node, and a conditional edge back to the worker. No LLM is involved, so the failure is reproducible byte for byte on any machine. Run: pip install langgraph python langgraph-recursion-repro.py Measured output on langgraph 1.2.10 (2026-08-05): case | limit that actually applied ------------------------------------|---------------------------- A. nothing set | 10007 B. recursion_limit=8 | 8 C. nested in configurable, 500 | 10007 <- silently ignored D. recursion_limit=500 | 500 E. env LANGGRAPH_DEFAULT_..._LIMIT | that value What it establishes: 1. The runtime default is NOT 25. It is DEFAULT_RECURSION_LIMIT in langgraph/_internal/_config.py, which reads getenv("LANGGRAPH_DEFAULT_RECURSION_LIMIT", "10007"). The "defaults to 25" line people quote lives in a docstring in langchain_core/runnables/config.py and is stale. 2. recursion_limit is a STANDALONE config key. Case C nests it inside `configurable` and it is dropped without any warning: the run dies at the default, not at 500. This is one of the ways a limit you did set never applies. 3. The counter counts SUPER-STEPS, not tool calls. Each pass through work -> check burns two, so the budget drains twice as fast as it reads. """ import os from typing import TypedDict from langgraph.errors import GraphRecursionError from langgraph.graph import END, START, StateGraph class State(TypedDict): passes: int done: bool def work(state: State) -> dict: return {"passes": state["passes"] + 1} def check(state: State) -> dict: # The bug being reproduced: this never flips `done`, so the conditional edge # below always routes back to `work`. In a real agent this is a tool that # keeps returning a subtly-wrong value, or a condition no tool output can # satisfy. return {"done": False} def route(state: State) -> str: return END if state["done"] else "work" def build(): g = StateGraph(State) g.add_node("work", work) g.add_node("check", check) g.add_edge(START, "work") g.add_edge("work", "check") g.add_conditional_edges("check", route) return g.compile() def applied_limit(app, config: dict) -> str: """Runs until it dies and reports the limit the runtime actually enforced.""" try: out = app.invoke({"passes": 0, "done": False}, config=config) return f"no error (finished at passes={out['passes']})" except GraphRecursionError as exc: first = str(exc).split(" without")[0] return first.replace("Recursion limit of ", "") def declared_default() -> str: from langgraph._internal._config import DEFAULT_RECURSION_LIMIT return str(DEFAULT_RECURSION_LIMIT) if __name__ == "__main__": app = build() cases = [ ("A. nothing set", {}), ("B. recursion_limit=8", {"recursion_limit": 8}), ("C. nested in configurable, 500", {"configurable": {"recursion_limit": 500}}), ("D. recursion_limit=500", {"recursion_limit": 500}), ] print() print(f"langgraph DEFAULT_RECURSION_LIMIT = {declared_default()}") env = os.getenv("LANGGRAPH_DEFAULT_RECURSION_LIMIT") print(f"LANGGRAPH_DEFAULT_RECURSION_LIMIT env = {env or '(unset)'}") print() width = max(len(label) for label, _ in cases) print("case".ljust(width), "| limit actually enforced") print("-" * width, "|", "-" * 30) for label, config in cases: print(label.ljust(width), "|", applied_limit(app, config)) print() print("A and C must show the SAME number. If they do, the nested key in C was") print("dropped silently - that is the failure mode to check first when a limit") print("you set appears to have no effect.") print() print("To set a real ceiling globally, without touching call sites:") print(" LANGGRAPH_DEFAULT_RECURSION_LIMIT=40 python your_app.py")