langchain-ai / langchain-ai/langgraph
default_retry_on inverts requests error handling: 4xx are retried, connection errors and timeouts are not
- Dominant language
- Python
- Stars
- 41.8k
- Forks
- 7.1k
- Avg merge
- 23h 7m
- Merged PRs (30d)
- 30
Description
### Checked other resources
- [x] This is a bug, not a usage question.
- [x] I added a clear and descriptive title that summarizes this issue.
- [x] I used the GitHub search to find a similar question and didn't find it.
- [x] I am sure that this is a bug in LangGraph rather than my code.
- [x] The bug is not resolved by updating to the latest stable version of LangGraph.
- [x] This is not related to the langchain-community package.
- [x] I posted a self-contained, minimal, reproducible example.
### Related Issues / PRs
* #7659 previously modified `default_retry_on` (made `NodeTimeoutError` retryable by default).
* #8801 — I opened a PR with the fix and regression tests before filing this issue, and it was
automatically closed for not linking an approved issue. The branch is still up to date with `main`.
I can add `Fixes #` to reopen it as soon as a maintainer approves and assigns.
### Reproduction Steps / Example Code (Python)
```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.**
```python
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:
```python
>>> 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`.
```python
>>> 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
```
Contributor guide
Research direction
Locate the default_retry_on entry point and read test_should_retry_default_retry_on, which currently uses a truthy Mock response. Reproduce the listed 4xx, 5xx, connection-error, and timeout cases with a real requests response; done means permanent 4xx errors run once while transient failures retry as expected, with the regression tests passing.
Written by the indexing model from the issue text.
Assessment
- Tech stack
- python
- Domain
- backend, testing
- Issue type
- Bug
- Difficulty
- 2/5
- Estimated time
- 1-3 hours
- Activity status
- Stale
- Clarity
- Clearly specified
- Newbie friendliness
- 35/100