Checked other resources
Related Issues / PRs
Reproduction Steps / Example Code (Python)
import requests
from requests.models import Response
from typing_extensions import TypedDict
from langgraph.graph import START, StateGraph
from langgraph.types import RetryPolicy
class State(TypedDict):
n: int
def make_graph(exc_factory):
attempts = {"count": 0}
def node(state: State) -> State:
attempts["count"] += 1
raise exc_factory()
g = StateGraph(State)
g.add_node(
"call", node, retry_policy=RetryPolicy(initial_interval=0.01, max_attempts=3)
)
g.add_edge(START, "call")
return g.compile(), attempts
def http_404():
r = Response()
r.status_code = 404
return requests.HTTPError("404 Not Found", response=r)
app, attempts = make_graph(http_404)
try:
app.invoke({"n": 0})
except Exception:
pass
print(f"requests.HTTPError(404) -> node ran {attempts['count']} time(s); expected 1")
app, attempts = make_graph(lambda: requests.ConnectionError("connection refused"))
try:
app.invoke({"n": 0})
except Exception:
pass
print(f"requests.ConnectionError -> node ran {attempts['count']} time(s); expected 3")
Output:
requests.HTTPError(404) -> node ran 3 time(s); expected 1
requests.ConnectionError -> node ran 1 time(s); expected 3
Description
default_retry_on — the default value of RetryPolicy.retry_on — misclassifies requests
exceptions, and the two errors are exact inverses of each other: permanent failures are
retried, and transient failures are not.
1. Every requests.HTTPError is retried, including 4xx.
return 500 <= exc.response.status_code < 600 if exc.response else True
requests.Response.__bool__ is an alias for Response.ok, so every error response is falsy:
>>> r = requests.models.Response(); r.status_code = 404
>>> bool(r)
False
The if exc.response guard therefore always takes the else True branch, which makes the
500 <= status < 600 comparison unreachable. A 401, 404 or 422 is retried up to
max_attempts with backoff, re-sending a request that cannot succeed against an API that has
already refused it.
2. requests connection errors and timeouts are never retried.
requests.RequestException subclasses OSError, which is in the non-retryable list, so
requests.ConnectionError, ConnectTimeout and ReadTimeout are treated as permanent — even
though the builtin ConnectionError on the first line of the function retries, and an
unrecognised exception falls through to return True.
>>> isinstance(requests.exceptions.ConnectionError(), ConnectionError) # builtin
False
>>> isinstance(requests.exceptions.ConnectionError(), OSError)
True
Expected: 4xx is not retried; 5xx, connection errors and timeouts are.
Actual: 4xx is retried; connection errors and timeouts are not.
Note on test coverage. test_should_retry_default_retry_on already asserts the intended
behaviour, but builds the response with Mock(). A bare Mock is truthy, so the test reaches
a code path production never reaches, and passes.
I have a fix with regression tests (using a real requests.models.Response) — 9 new test cases fail
on main and pass with the change, with no regressions. The branch is pushed and was opened as
#8801, which the bot closed for not linking an approved issue. Happy to relink it here once a
maintainer approves and assigns this.
System Info
System Information
------------------
> OS: Linux
> OS Version: #30~24.04.1-Ubuntu SMP PREEMPT_DYNAMIC Fri Aug 7 13:27:52 UTC 2
> Python Version: 3.12.3 (main, Jun 19 2026, 12:46:00) [GCC 13.3.0]
Package Information
-------------------
> langgraph: 1.2.11 (main @ c0a13bb)
> langchain_core: 1.6.1
> langsmith: 0.12.1
> requests: 2.31.0
> httpx: 0.28.1
Checked other resources
Related Issues / PRs
default_retry_on(madeNodeTimeoutErrorretryable by default).automatically closed for not linking an approved issue. The branch is still up to date with
main.I can add
Fixes #<this issue>to reopen it as soon as a maintainer approves and assigns.Reproduction Steps / Example Code (Python)
Output:
Description
default_retry_on— the default value ofRetryPolicy.retry_on— misclassifiesrequestsexceptions, and the two errors are exact inverses of each other: permanent failures are
retried, and transient failures are not.
1. Every
requests.HTTPErroris retried, including 4xx.requests.Response.__bool__is an alias forResponse.ok, so every error response is falsy:The
if exc.responseguard therefore always takes theelse Truebranch, which makes the500 <= status < 600comparison unreachable. A 401, 404 or 422 is retried up tomax_attemptswith backoff, re-sending a request that cannot succeed against an API that hasalready refused it.
2.
requestsconnection errors and timeouts are never retried.requests.RequestExceptionsubclassesOSError, which is in the non-retryable list, sorequests.ConnectionError,ConnectTimeoutandReadTimeoutare treated as permanent — eventhough the builtin
ConnectionErroron the first line of the function retries, and anunrecognised exception falls through to
return True.Expected: 4xx is not retried; 5xx, connection errors and timeouts are.
Actual: 4xx is retried; connection errors and timeouts are not.
Note on test coverage.
test_should_retry_default_retry_onalready asserts the intendedbehaviour, but builds the response with
Mock(). A bareMockis truthy, so the test reachesa code path production never reaches, and passes.
I have a fix with regression tests (using a real
requests.models.Response) — 9 new test cases failon
mainand pass with the change, with no regressions. The branch is pushed and was opened as#8801, which the bot closed for not linking an approved issue. Happy to relink it here once a
maintainer approves and assigns this.
System Info