langchain-ai / langchain-ai/langgraph

default_retry_on inverts requests error handling: 4xx are retried, connection errors and timeouts are not

Open
#8,802 2 comments 0 reactions 0 assignees View on GitHub
external
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

Open the contributing 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

Get new issues in your inbox

A short digest of beginner-friendly GitHub issues.