Compare commits

..
Author SHA1 Message Date
Elior Nataf Lackritz ffc3336367 fix(sdk-py): retry runs.wait, join and cancel(wait=True) reconnects with backoff (#9481)
`runs.wait`, `runs.join` and `runs.cancel(wait=True)` reconnect to the
run's `Location` when the response is cut off, but with no delay: a 429,
a 5xx or a refused connection on the reconnect raised at once, and a
dropped body reconnected again immediately, so they could not ride out a
server restart or a deploy. #9462 fixed the same for streams.

The reconnect now waits with the stream path's backoff and retries those
failures within the same five attempts; any other status and a failed
first request still raise. Each attempt is one GET with
`reconnect_limit=0`, so the loop owns every retry (the join `Location`
points back at itself, so nothing is lost by not following it).
`request_reconnect` raises plain `httpx.HTTPStatusError`, so it checks
429 and 5xx directly, the same set the stream path catches as
`RateLimitError` and `InternalServerError`.

`test_wait_retries_a_failed_reconnect_after_a_backoff` covers each
failure, sync and async, and fails on `main`; the other two new tests
check the budget and that a 404 still raises. `no_sleep` moves to
`conftest.py` so both test files can use it.
2026-10-28 18:55:31 -04:00
Elior Nataf Lackritz 5f4b28a9f7 fix(sdk-py): warn when a stream reconnects without a Last-Event-ID (#9482)
A dropped stream reconnects even when it has no event id to resume from
(a run created without `stream_resumable=True`, or one that dropped
before its first event). The reconnect can't replay what was sent while
it was disconnected, so those events were lost without a sign.

The SDK now warns when a reconnect request carries no `Last-Event-ID`,
whether the cursor would come from the stream's ids or from the caller's
own header. It still reconnects: everything sent after the reconnect
arrives, so refusing would turn a gap into losing the rest of the
stream.

The new table test covers a stream with no ids (warns; fails on `main`),
one with ids and one with a caller cursor (no warning), sync and async.
The empty-id tests now expect the warning, since they reconnect without
a cursor too.
2026-10-28 18:44:56 -04:00
dependabot[bot] 5c96bce8d3 chore(deps): bump setuptools from 83.0.0 to 84.1.0 in /libs/langgraph (#9433)
Bumps [setuptools](https://github.com/pypa/setuptools) from 83.0.0 to
84.1.0.
<details>
<summary>Changelog</summary>
<p><em>Sourced from <a
href="https://github.com/pypa/setuptools/blob/main/NEWS.rst">setuptools's
changelog</a>.</em></p>
<blockquote>
<h1>v84.1.0</h1>
<h2>Features</h2>
<ul>
<li><code>setuptools.archive_util</code> now raises the new
<code>UnsafeMember</code> exception when
an archive member would be extracted outside of the extraction
directory,
where previously such a member was silently skipped. Aborting makes a
malicious or malformed archive visible to the caller instead of yielding
a
quietly incomplete extraction, and matches the behavior of the standard
library's <code>tarfile</code> extraction filters.</li>
</ul>
<h2>Bugfixes</h2>
<ul>
<li>
<p><code>Wheel.install_as_egg</code> no longer creates namespace package
directories
outside of the destination egg directory. Entries in a wheel's
<code>namespace_packages.txt</code> were split on <code>.</code> and
passed straight to
<code>os.path.join</code>, so an entry that was absolute,
drive-qualified, or UNC
discarded the destination and placed an <code>__init__.py</code> at a
path chosen by
the wheel. Namespace entries are now validated as dotted module names on
every platform -- see GHSA-xmj3-9gf7-h52w.</p>
<p>This code path is reached only by the deprecated
<code>setup_requires</code>
mechanism when <code>setup()</code> is invoked outside a PEP 517
frontend; the
setuptools build backend never reaches it.</p>
</li>
<li>
<p><code>setuptools.archive_util</code> no longer extracts archive
members outside of the
requested extraction directory. Both the tar and zip formats specify
<code>/</code> as
the only path separator, but the guard against <code>..</code>
components split member
names on <code>/</code> alone, so a name such as
<code>..\escaped.txt</code> was treated as a
single component and then resolved as a traversal by the filesystem on
Windows. Member names containing a backslash, a drive letter, or a UNC
prefix
are now rejected on every platform, as are members whose resolved
destination
falls outside the extraction directory -- see GHSA-grgh-hr87-3jpw.</p>
</li>
</ul>
<h1>v84.0.0</h1>
<h2>Features</h2>
<ul>
<li>Newline-separated <code>keywords</code> and
<code>platforms</code><code>pypa/setuptools#4887</code><code>old
specification &lt;https://peps.python.org/pep-0345/&gt;</code>_
separated items with spaces and the current one uses commas. (<a
href="https://redirect.github.com/pypa/setuptools/issues/4887">#4887</a>)</li>
<li><code>Extension</code><code>pypa/distutils#373</code><a
href="https://redirect.github.com/pypa/setuptools/issues/5022">#5022</a>)</li>
<li>The C compiler modules now emit log messages through their own
<code>compilers.C.*</code> loggers instead of the distutils root logger,
part of decoupling the compilers package from distutils. The logger
names are normalized to a stable <code>compilers.C.*</code> prefix so
they remain constant as the package migrates toward a standalone
<code>compilers.C</code> distribution. (<a
href="https://redirect.github.com/pypa/setuptools/issues/5266">#5266</a>)</li>
<li>The C compilers gained a <code>Compiler.call</code> method -- a thin
wrapper over <code>subprocess.check_call</code> (with macOS
deployment-target env injection) that is the modern replacement for
<code>Compiler.spawn</code>. The compilers no longer depend on
<code>distutils.spawn</code>, <code>distutils.dir_util</code>,
<code>distutils.file_util</code>, <code>distutils._modified</code>, or
<code>distutils.util.execute</code>/<code>split_quoted</code>: the
generic <code>newer</code>/<code>newer_group</code> and
<code>split_quoted</code> helpers are vendored into the
<code>compilers</code> package, and
<code>Compiler.mkpath</code>/<code>move_file</code>/<code>execute</code>
are implemented directly on the standard library
(<code>os.makedirs</code>/<code>shutil.move</code>). The methods are
retained for backward compatibility. (<a
href="https://redirect.github.com/pypa/setuptools/issues/5267">#5267</a>)</li>
<li>The compilers no longer depend on <code>distutils.util</code>,
<code>distutils.version</code>, <code>distutils.compat</code>, or
<code>distutils._macos_compat</code>. The platform-identification
helpers
(<code>get_platform</code>/<code>get_host_platform</code>/<code>is_mingw</code>)
now live in <code>distutils.compilers.platform.detect</code> and the
macOS deployment-target logic and <code>compiler_fixup</code> in
<code>distutils.compilers.platform.macos</code>;
<code>CygwinCCompiler.gcc_version</code> returns a
<code>packaging.version.Version</code>. <code>distutils.util</code>
re-exports the platform/macOS helpers from their new homes for backward
compatibility rather than keeping duplicate copies. (sysconfig lookups
still route through distutils pending its own decoupling.) (<a
href="https://redirect.github.com/pypa/setuptools/issues/5268">#5268</a>)</li>
<li>The compilers now read their build configuration from the standard
library's <code>sysconfig</code> instead of
<code>distutils.sysconfig</code>. Per-compiler customization --
previously <code>distutils.sysconfig.customize_compiler</code> -- has
moved into <code>Compiler.configure_system()</code>: a no-op on the base
class, with <code>UnixCCompiler</code> applying the
compiler/flag/archiver settings CPython recorded in
<code>sysconfig</code> (and the usual
<code>CC</code>/<code>CFLAGS</code>/<code>LDSHARED</code>/… environment
overrides). <code>distutils.sysconfig.customize_compiler</code> is
retained as a thin wrapper that calls
<code>compiler.configure_system()</code>. (<a
href="https://redirect.github.com/pypa/setuptools/issues/5269">#5269</a>)</li>
</ul>
<!-- raw HTML omitted -->
</blockquote>
<p>... (truncated)</p>
</details>
<details>
<summary>Commits</summary>
<ul>
<li><a
href="https://github.com/pypa/setuptools/commit/77b7ace0aaf660beeb9b3f9da4aabf21cbcf3fa9"><code>77b7ace</code></a>
Bump version: 84.0.0 → 84.1.0</li>
<li><a
href="https://github.com/pypa/setuptools/commit/e66004c64eaf6cf2f8c53ccdfac75cf7abce7ccf"><code>e66004c</code></a>
Merge pull request <a
href="https://redirect.github.com/pypa/setuptools/issues/5329">#5329</a>
from pypa/fix/wheel-namespace-path-escape</li>
<li><a
href="https://github.com/pypa/setuptools/commit/54b5dcadc1bd938a5a66919e8ab7bcd59805e7fd"><code>54b5dca</code></a>
Cover the containment check with a symlink escape test</li>
<li><a
href="https://github.com/pypa/setuptools/commit/6d07b8d17890dda58d944ac51e58f54af92d52af"><code>6d07b8d</code></a>
Validate namespace package names during legacy wheel installation</li>
<li><a
href="https://github.com/pypa/setuptools/commit/8419f006d4e34c4ecfc7a0bd1bc6993855adc3cc"><code>8419f00</code></a>
Merge pull request <a
href="https://redirect.github.com/pypa/setuptools/issues/5356">#5356</a>
from pypa/skeleton-ruff-0.16</li>
<li><a
href="https://github.com/pypa/setuptools/commit/6dadc86a3f7dbe1cf49c6301548ffde61d4e74e1"><code>6dadc86</code></a>
Remove DTZ011 suppressions that Ruff 0.17 reports as unused.</li>
<li><a
href="https://github.com/pypa/setuptools/commit/715ec38b46424533806195a96983e3dd70fdf3b9"><code>715ec38</code></a>
Merge <a
href="https://github.com/jaraco/skeleton">https://github.com/jaraco/skeleton</a></li>
<li><a
href="https://github.com/pypa/setuptools/commit/9695460ebfa879ada16545d62b26b9a489a6d5ed"><code>9695460</code></a><code>jaraco/skeleton#219</code></li>
<li><a
href="https://github.com/pypa/setuptools/commit/9e0812976b1154a7aece47188814385c887bc6c9"><code>9e08129</code></a><code>jaraco/skeleton#216</code></li>
<li><a
href="https://github.com/pypa/setuptools/commit/4e612f9e44ecfad130d0b2a27a2dce0740599b4b"><code>4e612f9</code></a>
Ignore PyPy's deprecated check_home warning in tests</li>
<li>Additional commits viewable in <a
href="https://github.com/pypa/setuptools/compare/v83.0.0...v84.1.0">compare
view</a></li>
</ul>
</details>
<br />


[![Dependabot compatibility
score](https://dependabot-badges.githubapp.com/badges/compatibility_score?dependency-name=setuptools&package-manager=uv&previous-version=83.0.0&new-version=84.1.0)](https://docs.github.com/en/github/managing-security-vulnerabilities/about-dependabot-security-updates#about-compatibility-scores)

Dependabot will resolve any conflicts with this PR as long as you don't
alter it yourself. You can also trigger a rebase manually by commenting
`@dependabot rebase`.

[//]: # (dependabot-automerge-start)
[//]: # (dependabot-automerge-end)

---

<details>
<summary>Dependabot commands and options</summary>
<br />

You can trigger Dependabot actions by commenting on this PR:
- `@dependabot rebase` will rebase this PR
- `@dependabot recreate` will recreate this PR, overwriting any edits
that have been made to it
- `@dependabot show <dependency name> ignore conditions` will show all
of the ignore conditions of the specified dependency
- `@dependabot ignore this major version` will close this PR and stop
Dependabot creating any more for this major version (unless you reopen
the PR or upgrade to it yourself)
- `@dependabot ignore this minor version` will close this PR and stop
Dependabot creating any more for this minor version (unless you reopen
the PR or upgrade to it yourself)
- `@dependabot ignore this dependency` will close this PR and stop
Dependabot creating any more for this dependency (unless you reopen the
PR or upgrade to it yourself)
You can disable automated security fix PRs for this repo from the
[Security Alerts
page](https://github.com/langchain-ai/langgraph/network/alerts).

</details>

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-10-29 03:03:53 +09:00
2f46bb3ca4 fix(sdk-py): preserve SSE data and cursor state across chunks (#9477)
Replaces https://github.com/langchain-ai/langgraph/pull/9451, which
GitHub would not reopen after the branch was rebased. Prior discussion
and review remain on that PR.

Preserve empty data lines and unnamed null events, emit CR-terminated
lines immediately, and retain decoded cursor IDs across reconnects.
Replace malformed UTF-8 consistently with JS, expose decoder state
through its constructor/read-only property, and suppress repeated
control placeholders.

Rebased onto the current reconnect/backoff fixes without changing
request header encoding, retry parsing, JSON-only payloads or tolerant
EOF behavior. Matching JS changes:
https://github.com/langchain-ai/langgraphjs/pull/3128.

### SSE references
- [MDN event stream format and
fields](https://developer.mozilla.org/en-US/docs/Web/API/Server-sent_events/Using_server-sent_events#event_stream_format):
UTF-8, comments, colonless fields and multiline data.
- [WHATWG
parsing](https://html.spec.whatwg.org/multipage/server-sent-events.html#parsing-an-event-stream):
CR/LF/CRLF framing and buffering.
- [WHATWG
interpretation](https://html.spec.whatwg.org/multipage/server-sent-events.html#event-stream-interpretation):
BOM, data assembly and ID rules.
- [WHATWG
reconnection](https://html.spec.whatwg.org/multipage/server-sent-events.html#sse-processing-model):
cursor propagation.

Native Python/JS JSON semantics remain language-specific; this does not
claim exhaustive conformance.

## References
-
[Plan](https://openswe.langchain.dev/agents/86db0ac8-b177-5c04-a1e5-e3226a37b42e/plan)
- [Slack
thread](https://langchain.slack.com/archives/C0C411URFR6/p1792789247110279)

Made by [Open SWE](https://github.com/langchain-ai/open-swe) · [view
thread](https://openswe.langchain.dev/agents/86db0ac8-b177-5c04-a1e5-e3226a37b42e)
· openai:gpt-6.1-sol (medium)

---------

Co-authored-by: Hunter Lovell <hntrl@users.noreply.github.com>
Co-authored-by: open-swe[bot] <open-swe@users.noreply.github.com>
2026-10-28 10:53:22 -07:00
dependabot[bot] 4b7f2f5230 chore(deps): bump zod from 4.3.6 to 4.6.7 in /libs/cli/js-monorepo-example (#9431)
Bumps [zod](https://github.com/colinhacks/zod) from 4.3.6 to 4.6.7.
<details>
<summary>Release notes</summary>
<p><em>Sourced from <a
href="https://github.com/colinhacks/zod/releases">zod's
releases</a>.</em></p>
<blockquote>
<h2>v4.6.7</h2>
<h2>Commits:</h2>
<ul>
<li>b500a2ee95134f81d74da7911d7378dd2853d042 4.6.7</li>
</ul>
<h2>v4.6.6</h2>
<blockquote>
<p>Six malicious commits pushed to <code>main</code> on September 21
with a compromised git credential were reverted the same day in <a
href="https://redirect.github.com/colinhacks/zod/issues/6620">#6620</a>
and have since been removed from the history of <code>main</code>. None
were ever published to npm. The npm provenance for 4.6.6 names 284243fb,
the pre-rewrite copy of 9c219833 with identical contents.</p>
</blockquote>
<h2>Commits:</h2>
<ul>
<li>0cd3af5e9f50ad11bbcd4407a46a21b8848ba574 docs: load the en locale
directly in Zod Mini</li>
<li>9d5e60f162997b764b40d45aa02481888db50dea docs: import locales from
zod/locales, not z.locales</li>
<li>34a626f307871ecbb0123947559328dc52ed5e3e docs: tighten the z.locales
tree-shaking note</li>
<li>74304ce5b5796b5edb9a88ceb8e170b6ea3e82df release: stage npm versions
instead of publishing them (<a
href="https://redirect.github.com/colinhacks/zod/issues/6622">#6622</a>)</li>
<li>b2ef983e0711ff6517d6020b021071e2e5653efa docs: add Code for Japan to
bronze sponsors</li>
<li>8fb2faf0584828a6a027d8279db36f6f52784462 docs: crop the Code for
Japan logo and list it first in bronze</li>
<li>bacad518c41610d654658172d325874bf0ee6290 docs: square the Code for
Japan logo to match the other bronze tiles</li>
<li>c42596bd1c87e941d7241d10e012c5c196adcc10 release: hold the run on
the release-approval environment until the staged versions are approved
(<a
href="https://redirect.github.com/colinhacks/zod/issues/6638">#6638</a>)</li>
<li>a2ba4a67986a3e6b2b131be30084a2f460d9ea67 fix(types): declare record
output and input as members, not base type arguments (<a
href="https://redirect.github.com/colinhacks/zod/issues/6645">#6645</a>)</li>
<li>a0ebf970f2d752e5f58527cd2942ff48be4f46bd fix(v4): check array, set
and map size bounds before parsing elements (<a
href="https://redirect.github.com/colinhacks/zod/issues/6652">#6652</a>)</li>
<li>529f78dd9431b07f49647fee3aa0819b3f8b66b7 4.6.6</li>
<li>9c219833c3a3764fa1f1ea9c5ecec8254a63c0b1 ci: stage at verbose so a
failed OIDC token exchange says why</li>
</ul>
<h2>v4.6.5</h2>
<h2>Commits:</h2>
<ul>
<li>d2b135cfb7a3582b9eb515756b9166bcb9521f4a docs: add the 4.6.x patch
highlights to the 4.6 post</li>
<li>f1448f7cee00df9fe1e9ad84a000aa1828cc8bc1 docs: fold the 4.6.x patch
highlights into the 4.6 post's own sections</li>
<li>de65a5cb39ed22a507fac935788f718fa88d104f docs: lead the properties
section with the check and add a Zod Mini tab (<a
href="https://redirect.github.com/colinhacks/zod/issues/6598">#6598</a>)</li>
<li>56222cd1532c07bcb91b67df529cab4c0a215330 feat(instanceof): key the
.properties() shape off the instance type (<a
href="https://redirect.github.com/colinhacks/zod/issues/6600">#6600</a>)</li>
<li>ca0229a404818290e6cdcfefcd7eb2d04bcbb543 Revert &quot;feat: add
z.currencyCode() over a vendored ISO 4217 list, refreshed weekly by CI
(<a
href="https://redirect.github.com/colinhacks/zod/issues/6595">#6595</a>)&quot;</li>
<li>cc4cd4ee9c52fcaa10964e48cc144541e41a5ed9 Revert &quot;Revert
&quot;feat: add z.currencyCode() over a vendored ISO 4217 list,
refreshed weekly by CI (<a
href="https://redirect.github.com/colinhacks/zod/issues/6595">#6595</a>)&quot;&quot;</li>
<li>0f3f5ee3ca56c7574bf849e54f79e9a6e02562ee 4.6.5</li>
<li>59bbc03e10c636b9eb3c393dfeb552819774ec21 chore: re-pin the
integration peers to the workspace zod after the 4.6.5 bump</li>
</ul>
<h2>v4.6.4</h2>
<p>A patch on top of <a
href="https://github.com/colinhacks/zod/releases/tag/v4.6.3">4.6.3</a>.</p>
<ul>
<li><a
href="https://github.com/colinhacks/zod/commit/d6bc1e30"><code>d6bc1e30</code></a>
feat: add <code>z.currencyCode()</code> over a vendored ISO 4217 list,
refreshed weekly by CI (<a
href="https://redirect.github.com/colinhacks/zod/pull/6595">#6595</a>)</li>
<li><a
href="https://github.com/colinhacks/zod/commit/ad32d751"><code>ad32d751</code></a>
perf: <code>z.url()</code> rejects an invalid URL with
<code>URL.canParse()</code> instead of a throwing constructor, about 50x
faster; fewer allocations on the validation path (<a
href="https://redirect.github.com/colinhacks/zod/pull/6588">#6588</a>)</li>
<li><a
href="https://github.com/colinhacks/zod/commit/2bb08717"><code>2bb08717</code></a>
chore: re-pin the integration peers to the workspace zod after the 4.6.4
bump</li>
<li><a
href="https://github.com/colinhacks/zod/commit/f6e1701a"><code>f6e1701a</code></a>
chore(deps): bump next to 15.5.25 and vite to 7.3.6 (<a
href="https://redirect.github.com/colinhacks/zod/pull/6153">#6153</a>)</li>
</ul>
<h2>v4.6.3</h2>
<p>A patch on top of <a
href="https://github.com/colinhacks/zod/releases/tag/v4.6.2">4.6.2</a>.</p>
<ul>
<li><a
href="https://github.com/colinhacks/zod/commit/413cce9a"><code>413cce9a</code></a>
fix(v4): make z.properties() a check again (<a
href="https://redirect.github.com/colinhacks/zod/pull/6594">#6594</a>) —
removes the standalone <code>z.properties()</code> schema from 4.6.0;
<code>z.instanceof().properties()</code> and
<code>.check(...z.properties())</code> are unchanged</li>
<li><a
href="https://github.com/colinhacks/zod/commit/75d63ee1"><code>75d63ee1</code></a>
docs: show only the <code>.properties()</code> method form in the 4.6
post</li>
<li><a
href="https://github.com/colinhacks/zod/commit/46da9572"><code>46da9572</code></a>
docs: match the error-message examples to what the parsers emit</li>
</ul>
<!-- raw HTML omitted -->
</blockquote>
<p>... (truncated)</p>
</details>
<details>
<summary>Commits</summary>
<ul>
<li><a
href="https://github.com/colinhacks/zod/commit/b500a2ee95134f81d74da7911d7378dd2853d042"><code>b500a2e</code></a>
4.6.7</li>
<li><a
href="https://github.com/colinhacks/zod/commit/9c219833c3a3764fa1f1ea9c5ecec8254a63c0b1"><code>9c21983</code></a>
ci: stage at verbose so a failed OIDC token exchange says why</li>
<li><a
href="https://github.com/colinhacks/zod/commit/529f78dd9431b07f49647fee3aa0819b3f8b66b7"><code>529f78d</code></a>
4.6.6</li>
<li><a
href="https://github.com/colinhacks/zod/commit/a0ebf970f2d752e5f58527cd2942ff48be4f46bd"><code>a0ebf97</code></a>
fix(v4): check array, set and map size bounds before parsing elements
(<a
href="https://redirect.github.com/colinhacks/zod/issues/6652">#6652</a>)</li>
<li><a
href="https://github.com/colinhacks/zod/commit/a2ba4a67986a3e6b2b131be30084a2f460d9ea67"><code>a2ba4a6</code></a>
fix(types): declare record output and input as members, not base type
argumen...</li>
<li><a
href="https://github.com/colinhacks/zod/commit/c42596bd1c87e941d7241d10e012c5c196adcc10"><code>c42596b</code></a>
release: hold the run on the release-approval environment until the
staged ve...</li>
<li><a
href="https://github.com/colinhacks/zod/commit/bacad518c41610d654658172d325874bf0ee6290"><code>bacad51</code></a>
docs: square the Code for Japan logo to match the other bronze
tiles</li>
<li><a
href="https://github.com/colinhacks/zod/commit/8fb2faf0584828a6a027d8279db36f6f52784462"><code>8fb2faf</code></a>
docs: crop the Code for Japan logo and list it first in bronze</li>
<li><a
href="https://github.com/colinhacks/zod/commit/b2ef983e0711ff6517d6020b021071e2e5653efa"><code>b2ef983</code></a>
docs: add Code for Japan to bronze sponsors</li>
<li><a
href="https://github.com/colinhacks/zod/commit/74304ce5b5796b5edb9a88ceb8e170b6ea3e82df"><code>74304ce</code></a>
release: stage npm versions instead of publishing them (<a
href="https://redirect.github.com/colinhacks/zod/issues/6622">#6622</a>)</li>
<li>Additional commits viewable in <a
href="https://github.com/colinhacks/zod/compare/v4.3.6...v4.6.7">compare
view</a></li>
</ul>
</details>
<details>
<summary>Maintainer changes</summary>
<p>This version was pushed to npm by <a
href="https://www.npmjs.com/~GitHub%20Actions">GitHub Actions</a>, a new
releaser for zod since your current version.</p>
</details>
<br />


[![Dependabot compatibility
score](https://dependabot-badges.githubapp.com/badges/compatibility_score?dependency-name=zod&package-manager=npm_and_yarn&previous-version=4.3.6&new-version=4.6.7)](https://docs.github.com/en/github/managing-security-vulnerabilities/about-dependabot-security-updates#about-compatibility-scores)

Dependabot will resolve any conflicts with this PR as long as you don't
alter it yourself. You can also trigger a rebase manually by commenting
`@dependabot rebase`.

[//]: # (dependabot-automerge-start)
[//]: # (dependabot-automerge-end)

---

<details>
<summary>Dependabot commands and options</summary>
<br />

You can trigger Dependabot actions by commenting on this PR:
- `@dependabot rebase` will rebase this PR
- `@dependabot recreate` will recreate this PR, overwriting any edits
that have been made to it
- `@dependabot show <dependency name> ignore conditions` will show all
of the ignore conditions of the specified dependency
- `@dependabot ignore this major version` will close this PR and stop
Dependabot creating any more for this major version (unless you reopen
the PR or upgrade to it yourself)
- `@dependabot ignore this minor version` will close this PR and stop
Dependabot creating any more for this minor version (unless you reopen
the PR or upgrade to it yourself)
- `@dependabot ignore this dependency` will close this PR and stop
Dependabot creating any more for this dependency (unless you reopen the
PR or upgrade to it yourself)
You can disable automated security fix PRs for this repo from the
[Security Alerts
page](https://github.com/langchain-ai/langgraph/network/alerts).

</details>

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-10-29 02:29:45 +09:00
Richard Scarrott 5a3e5dd739 fix(sdk-py): retry failed stream reconnects with backoff (#9462)
## Summary

A stream reconnect that fails to open (429, 5xx, transport error,
non-SSE response) is now retried with backoff instead of ending the
stream, e.g. while the server restarts or deploys.

```diff
 HttpClient.stream / SyncHttpClient.stream, on reconnect (GET to Location)
-  retry only a mid-read drop, with no delay, 5 times per stream
+  also retry a reconnect that fails to open: 429, 5xx, transport error, non-SSE
+  sleep 1, 2, 4, 5, 5s (+ up to 25% jitter) before each reconnect
+  reset the 5 attempts once a reconnect yields an event
+  give up with "Exceeded maximum…" raised from the last error
```

The first POST still raises on any error, since retrying it could start
a duplicate run. The backoff formula moves to `_shared/utilities.py`,
shared with the stream controllers.

The sleep also delays the first reconnect after a plain drop by about a
second, which matches the JS SDK. A run created without
`stream_resumable=True` can't replay what it sent while the client was
away, so it now loses about a second more of events. Resumable runs lose
nothing.

## Evidence

- **Before:** a reconnect hitting 429/502/503, a connect error or a
non-SSE response raised, a stream with 6 spaced-out drops gave up, and
reconnects never slept.
**After:** `test_client_stream.py` covers each (sync and async), plus
404 and first-POST failures staying fatal, and giving up after ~20s.

## Merge Danger

**Door:** two-way

**Blast Radius:** small (sdk-py stream reconnects only)
2026-10-28 11:46:39 +00:00
Hunter Lovell eb8ec111b3 fix(langgraph): emit node-output messages from a graph streamed inside a node (#9463)
When a node streams a graph with `stream_mode="messages"`, messages that
one of that graph's nodes *returns* (rather than streams from an LLM)
are dropped. The same graph streamed at the top level reports them:

```python
def respond(state: MessagesState) -dict:
    return {"messages": [AIMessage("hi there")]}

subgraph = StateGraph(MessagesState).add_node("respond", respond).add_edge(START, "respond").compile()

subgraph.stream(msgs, stream_mode="messages")          # -> "hi there"

def parent_node(state, config):
    for msg, _ in subgraph.stream(msgs, config, stream_mode="messages"):
        ...                                             # -> nothing
```

A graph streamed this way attaches its own messages handler with the
node's namespace as `parent_ns`, so it still reports that graph's own
messages with `subgraphs=False` (#5836). `on_chat_model_start` honors
`parent_ns`, but `on_chain_start` didn't, so node outputs were filtered
out as if they came from a subgraph. This applies the same check in
`on_chain_start`.

Split out of #9453 (see @eliornl's [review
comment](https://github.com/langchain-ai/langgraph/pull/9453#discussion_r…)).
2026-10-27 14:46:39 -07:00
Hunter Lovell 8c177f7388 fix(langgraph): respect subgraphs=False in RemoteGraph.stream under a streaming parent (#9453)
When a node streams a `RemoteGraph` with `subgraphs=False` while its
parent graph is itself streamed with `subgraphs=True`,
`RemoteGraph.stream`/`astream` yield the remote graph's subgraph events
to the caller. With `stream_mode="updates"`, the node gets `['model',
'child']` where an in-memory graph streamed the same way gives
`['child']`.

`RemoteGraph` requests `stream_subgraphs=True` whenever the config
carries a parent stream, so the parent sees the remote graph's subgraph
events. Those events are forwarded to the parent correctly, but the
values yielded to the caller were never filtered by the caller's own
`subgraphs` setting. Locally this falls out of how streams are wired:
with `subgraphs=False` the run never installs the caller's queue for its
subgraphs, so their events only reach the parent. This applies the same
split on the client: subgraph events still go to the parent, and only
reach the caller when it passed `subgraphs=True`.

`messages` events are left as they are. The server's messages handler
dedupes across namespaces, so once it has sent a message from inside a
subgraph it doesn't send the top-level copy a `subgraphs=False` caller
would see locally. Filtering would leave that caller with nothing, so it
keeps getting the subgraph tokens, as today. Matching local exactly
needs the server to produce the caller's view; I'll open an issue for
that.

The new tests fail on `main`.
2026-10-27 14:40:42 -07:00
55013146ae fix(sdk-py): match Auth.on overload to optional resources (#9450)
Match the `Auth.on` decorator-factory overload to its existing optional
`resources` argument so `auth.on()`, action-only calls, and
`resources=None` type-check. Add regression coverage for the overload
and wildcard registration; runtime authorization behavior is unchanged.

## References
- [Slack
thread](https://langchain.slack.com/archives/C0C411URFR6/p1792789013113819)

Made by [Open SWE](https://github.com/langchain-ai/open-swe) · [view
thread](https://openswe.langchain.dev/agents/094c284b-638a-5b04-95f0-623b47d0a52f)
· openai:gpt-6.1-sol (medium)

Co-authored-by: Hunter Lovell <hntrl@users.noreply.github.com>
Co-authored-by: open-swe[bot] <open-swe@users.noreply.github.com>
2026-10-27 14:34:20 -07:00
20 changed files with 993 additions and 250 deletions

No files matched your search

+3 -3
View File
@@ -2148,6 +2148,6 @@ yocto-queue@^0.1.0:
integrity sha512-rVksvsnNCdJ/ohGc6xgPwyN8eheCxsiLM8mxuE/t/mOVqJewPuO1miLpTHQiRgTKCLexL4MeAFVagts7HmNZ2Q==
"zod@^3.25.76 || ^4":
version "4.3.6"
resolved "https://registry.yarnpkg.com/zod/-/zod-4.3.6.tgz#89c56e0aa7d2b05107d894412227087885ab112a"
integrity sha512-rftlrkhHZOcjDwkGlnUtZZkvaPHCsDATp4pGpuOOMDaTdDDXF91wuVDJoWoPsKX/3YPQ5fHuF3STjcYyKr+Qhg==
version "4.6.7"
resolved "https://registry.yarnpkg.com/zod/-/zod-4.6.7.tgz#e806cf64a7f4e37635ce9f2f52c04c3766f75e5b"
integrity sha512-g8W2D2kU6a8untc053fX1uVBKzM7yqAxW/3zwk83+6RyF2+SKOuV+c0wBDhUwzV1PaGapmZH+rmcUzPf+3CGwg==
-34
View File
@@ -232,8 +232,6 @@ def draw_graph(
tools_by_name = getattr(node.bound, "tools_by_name", None)
if isinstance(tools_by_name, Mapping) and tools_by_name:
metadata["__tools"] = list(tools_by_name)
if subagents := _get_tool_subagents(tools_by_name):
metadata["__subagents"] = subagents
graph.add_node(node.bound, name, metadata=metadata or None)
# add start node
if START not in nodes:
@@ -283,38 +281,6 @@ def draw_graph(
return graph
def _get_tool_subagents(
tools_by_name: Mapping[str, Any],
) -> dict[str, list[dict[str, Any]]]:
"""Collect the subagents each tool can run, keyed by tool name."""
subagents: dict[str, list[dict[str, Any]]] = {}
for tool_name, tool in tools_by_name.items():
tool_subagents = getattr(tool, "subagents", None)
if (
isinstance(tool_subagents, Sequence)
and not isinstance(tool_subagents, str)
and tool_subagents
and all(
isinstance(subagent, Mapping) and isinstance(subagent.get("name"), str)
for subagent in tool_subagents
)
):
subagents[tool_name] = [
_subagent_entry(subagent) for subagent in tool_subagents
]
return subagents
def _subagent_entry(subagent: Mapping[str, Any]) -> dict[str, Any]:
"""Keep only the subagent fields graph viewers display."""
entry: dict[str, Any] = {"name": subagent["name"]}
if isinstance(description := subagent.get("description"), str):
entry["description"] = description
if subagent.get("forked") is True:
entry["forked"] = True
return entry
def add_edge(
graph: Graph,
source: str,
+1 -1
View File
@@ -216,7 +216,7 @@ class StreamMessagesHandler(BaseCallbackHandler, _StreamingCallbackHandler):
ns = tuple(cast(str, metadata["langgraph_checkpoint_ns"]).split(NS_SEP))[
:-1
]
if not self.subgraphs and len(ns) > 0:
if not self.subgraphs and len(ns) > 0 and ns != self.parent_ns:
return
self.metadata[run_id] = (ns, metadata)
for value in _state_values(inputs):
+10
View File
@@ -847,6 +847,11 @@ class RemoteGraph(PregelProtocol):
# filter for what was actually requested
if mode not in requested:
continue
# subgraph events may have been requested only for the parent stream.
# messages are exempt: the server dedupes them across namespaces, so
# dropping them here would also drop the root-level message
if not subgraphs and NS_SEP in chunk.event and mode != "messages":
continue
if chunk.event.startswith("messages"):
chunk = chunk._replace(data=tuple(chunk.data))
@@ -997,6 +1002,11 @@ class RemoteGraph(PregelProtocol):
# filter for what was actually requested
if mode not in requested:
continue
# subgraph events may have been requested only for the parent stream.
# messages are exempt: the server dedupes them across namespaces, so
# dropping them here would also drop the root-level message
if not subgraphs and NS_SEP in chunk.event and mode != "messages":
continue
if chunk.event.startswith("messages"):
chunk = chunk._replace(data=tuple(chunk.data))
+34 -44
View File
@@ -36,7 +36,7 @@ from langchain_core.runnables import (
RunnablePassthrough,
)
from langchain_core.runnables.graph import Edge
from langchain_core.tools import StructuredTool, tool
from langchain_core.tools import tool
from langchain_core.version import VERSION as LANGCHAIN_CORE_VERSION
from langgraph.cache.base import BaseCache
from langgraph.checkpoint.base import (
@@ -3105,49 +3105,6 @@ def test_simple_multi_edge(snapshot: SnapshotAssertion) -> None:
)
def test_tool_node_lists_tool_subagents_in_graph_metadata() -> None:
class TaskTool(StructuredTool):
subagents: list[dict[str, Any]] = []
def run_task(description: str, subagent_type: str) -> str:
return subagent_type
task = TaskTool.from_function(run_task, name="task", description="Run a subagent.")
task.subagents = [
{"name": "researcher", "description": "Researches a topic."},
{
"name": "critic",
"description": "Reviews drafts.",
"forked": True,
"system_prompt": "You are a critic.",
"runnable": object(),
},
{"name": "planner", "description": None, "forked": "yes"},
]
@tool
def search(query: str) -> str:
"""Search the web."""
return query
builder = StateGraph(MessagesState)
builder.add_node("agent", lambda state: state)
builder.add_node("tools", ToolNode([search, task]))
builder.add_edge(START, "agent")
builder.add_edge("agent", "tools")
graph = builder.compile().get_graph().to_json()
nodes = {node["id"]: node for node in graph["nodes"]}
assert nodes["tools"]["metadata"]["__subagents"] == {
"task": [
{"name": "researcher", "description": "Researches a topic."},
{"name": "critic", "description": "Reviews drafts.", "forked": True},
{"name": "planner"},
]
}
assert "__subagents" not in (nodes["agent"].get("metadata") or {})
def test_nested_graph_xray(snapshot: SnapshotAssertion) -> None:
class State(TypedDict):
my_key: Annotated[str, operator.add]
@@ -8993,6 +8950,39 @@ def test_subgraph_streaming_sync() -> None:
assert result["num_chunks"] == 9
def test_subgraph_streaming_node_output_messages_sync() -> None:
"""Messages returned by a node (not streamed by an LLM) are emitted when a
graph is explicitly streamed with `stream_mode="messages"` inside a node."""
def respond(state: MessagesState) -> dict:
return {"messages": [AIMessage("hi there", id="ai1")]}
subgraph = (
StateGraph(MessagesState)
.add_node("respond", respond)
.add_edge(START, "respond")
.compile()
)
msgs = {"messages": [("user", "hello")]}
top_level = [msg for msg, _ in subgraph.stream(msgs, stream_mode="messages")]
def parent_node(state: MessagesState, config: RunnableConfig) -> dict:
nonlocal in_node
in_node = [
msg for msg, _ in subgraph.stream(msgs, config, stream_mode="messages")
]
return {}
in_node: list = []
StateGraph(MessagesState).add_node("parent", parent_node).add_edge(
START, "parent"
).compile().invoke(msgs)
assert [m.content for m in top_level] == ["hi there"]
assert [m.content for m in in_node] == ["hi there"]
def test_get_graph_nonterminal_last_step_source(snapshot: SnapshotAssertion) -> None:
class State(TypedDict):
messages: list[str]
+38
View File
@@ -9503,6 +9503,44 @@ async def test_subgraph_streaming_async() -> None:
assert result["num_chunks"] == 9
async def test_subgraph_streaming_node_output_messages_async() -> None:
"""Messages returned by a node (not streamed by an LLM) are emitted when a
graph is explicitly streamed with `stream_mode="messages"` inside a node."""
async def respond(state: MessagesState) -> dict:
return {"messages": [AIMessage("hi there", id="ai1")]}
subgraph = (
StateGraph(MessagesState)
.add_node("respond", respond)
.add_edge(START, "respond")
.compile()
)
msgs = {"messages": [("user", "hello")]}
top_level = [msg async for msg, _ in subgraph.astream(msgs, stream_mode="messages")]
async def parent_node(state: MessagesState, config: RunnableConfig) -> dict:
nonlocal in_node
in_node = [
msg
async for msg, _ in subgraph.astream(msgs, config, stream_mode="messages")
]
return {}
in_node: list = []
await (
StateGraph(MessagesState)
.add_node("parent", parent_node)
.add_edge(START, "parent")
.compile()
.ainvoke(msgs)
)
assert [m.content for m in top_level] == ["hi there"]
assert [m.content for m in in_node] == ["hi there"]
@NEEDS_CONTEXTVARS
async def test_null_resume_disallowed_with_multiple_interrupts(
async_checkpointer: BaseCheckpointSaver,
+83
View File
@@ -16,9 +16,11 @@ from langgraph_sdk.schema import StreamPart
from pydantic import BaseModel
from typing_extensions import TypedDict
from langgraph._internal._constants import CONFIG_KEY_STREAM
from langgraph.errors import GraphInterrupt
from langgraph.graph import END, START, MessagesState, StateGraph, add_messages
from langgraph.pregel import Pregel
from langgraph.pregel.protocol import StreamProtocol
from langgraph.pregel.remote import RemoteGraph
from langgraph.types import Interrupt, StateSnapshot
from tests.any_str import AnyStr
@@ -863,6 +865,87 @@ async def test_astream_subgraphs_keeps_caller_namespace():
assert stream_parts == _CALLER_PREFIXED
# Subgraph events requested only on behalf of a streaming parent graph
_PARENT_STREAMED_PARTS = [
StreamPart(event="updates|sub:abc", data={"inner": {"chunk": "inner"}}),
StreamPart(event="messages|sub:abc", data=[{"content": "token"}, {}]),
StreamPart(event="updates", data={"sub": {"chunk": "root"}}),
]
def _parent_streamed_config(parent_parts: list) -> RunnableConfig:
return {
"configurable": {
"thread_id": "thread_1",
"checkpoint_ns": "outer:123",
CONFIG_KEY_STREAM: StreamProtocol(
parent_parts.append, {"updates", "messages"}
),
}
}
def test_stream_without_subgraphs_skips_parent_requested_subgraph_events():
mock_sync_client = MagicMock()
mock_sync_client.runs.stream.return_value = _PARENT_STREAMED_PARTS
remote_pregel = RemoteGraph("test_graph_id", sync_client=mock_sync_client)
for stream_mode, expected in [
("updates", [{"sub": {"chunk": "root"}}]),
# messages are not filtered: the server dedupes them across namespaces
("messages", [({"content": "token"}, {})]),
]:
parent_parts: list = []
stream_parts = list(
remote_pregel.stream(
{"input": "data"},
_parent_streamed_config(parent_parts),
stream_mode=stream_mode,
)
)
assert stream_parts == expected
# subgraph events are still requested and forwarded to the parent
assert mock_sync_client.runs.stream.call_args.kwargs["stream_subgraphs"]
assert [ns for ns, _, _ in parent_parts] == [
("outer:123", "sub:abc"),
("outer:123", "sub:abc"),
("outer:123",),
]
async def test_astream_without_subgraphs_skips_parent_requested_subgraph_events():
mock_async_client = MagicMock()
async_iter = MagicMock()
async_iter.__aiter__.return_value = _PARENT_STREAMED_PARTS
mock_async_client.runs.stream.return_value = async_iter
remote_pregel = RemoteGraph("test_graph_id", client=mock_async_client)
for stream_mode, expected in [
("updates", [{"sub": {"chunk": "root"}}]),
# messages are not filtered: the server dedupes them across namespaces
("messages", [({"content": "token"}, {})]),
]:
parent_parts: list = []
stream_parts = [
part
async for part in remote_pregel.astream(
{"input": "data"},
_parent_streamed_config(parent_parts),
stream_mode=stream_mode,
)
]
assert stream_parts == expected
# subgraph events are still requested and forwarded to the parent
assert mock_async_client.runs.stream.call_args.kwargs["stream_subgraphs"]
assert [ns for ns, _, _ in parent_parts] == [
("outer:123", "sub:abc"),
("outer:123", "sub:abc"),
("outer:123",),
]
def test_invoke():
# set up test
mock_sync_client = MagicMock()
+3 -3
View File
@@ -3419,11 +3419,11 @@ wheels = [
[[package]]
name = "setuptools"
version = "83.0.0"
version = "84.1.0"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/34/26/f5d29e25ffdb535afef2d35cdb55b325298f96debd670da4c325e08d70f4/setuptools-83.0.0.tar.gz", hash = "sha256:025bccbbf0fa05b6192bc64ae1e7b16e001fd6d6d4d5de03c97b1c1ade523bef", size = 1154254, upload-time = "2026-07-04T15:31:22.699Z" }
sdist = { url = "https://files.pythonhosted.org/packages/73/eb/7e5f628ee2eb168fecbf8026de7856b2543d6d609cc85f086b9a91ac93d6/setuptools-84.1.0.tar.gz", hash = "sha256:23657205b63d4d31395ab917815247f694aafad28cd21316bf5fd45a51048ea0", size = 1171783, upload-time = "2026-10-19T20:11:43.489Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/5d/40/e1e72872c6354b306daef1703549e8e83b4d43cfea356311bf722a043752/setuptools-83.0.0-py3-none-any.whl", hash = "sha256:29b23c360f22f414dc7336bb39178cc7bcbf6021ed2733cde173f09dba19abb3", size = 1008090, upload-time = "2026-07-04T15:31:20.885Z" },
{ url = "https://files.pythonhosted.org/packages/18/02/b4bbb90f8f29e334898ff45c6d9b1fffaa6f378017052b3023a06262b5f7/setuptools-84.1.0-py3-none-any.whl", hash = "sha256:5231689c6ac8156cd155cfb98c3fd6fb252c3e7e9bd3d78b64d3c55f132624c9", size = 819380, upload-time = "2026-10-19T20:11:41.684Z" },
]
[[package]]
+105 -59
View File
@@ -14,9 +14,14 @@ import orjson
from langgraph_sdk._shared.utilities import (
_orjson_default,
_reconnect_delay,
_validate_reconnect_location,
)
from langgraph_sdk.errors import _araise_for_status_typed
from langgraph_sdk.errors import (
InternalServerError,
RateLimitError,
_araise_for_status_typed,
)
from langgraph_sdk.schema import QueryParamTypes, StreamPart
from langgraph_sdk.sse import SSEDecoder, aiter_lines_raw
@@ -146,7 +151,8 @@ class HttpClient:
on_response: Callable[[httpx.Response], None] | None = None,
reconnect_limit: int = 5,
) -> Any:
"""Send a request that automatically reconnects to Location header."""
"""Send a request that reconnects to its Location header if the response is
cut off, with backoff between attempts."""
request_headers, content = await _aencode_json(json)
if headers:
request_headers.update(headers)
@@ -176,13 +182,27 @@ class HttpClient:
stacklevel=2,
)
await r.aclose()
return await self.request_reconnect(
loc,
"GET",
headers=headers,
# don't pass on_response so it's only called once
reconnect_limit=reconnect_limit - 1,
)
for attempt in range(reconnect_limit):
await asyncio.sleep(_reconnect_delay(attempt, base=1.0, cap=5.0))
# A restart or a deploy looks like a refused connection, a
# dropped body, a 429 or a 5xx; other errors won't go away.
try:
return await self.request_reconnect(
loc,
"GET",
headers=headers,
# don't pass on_response so it's only called once
reconnect_limit=0,
)
except httpx.HTTPStatusError as err:
status = err.response.status_code
if attempt == reconnect_limit - 1 or (
status != 429 and status < 500
):
raise
except httpx.TransportError:
if attempt == reconnect_limit - 1:
raise
async def stream(
self,
@@ -225,70 +245,96 @@ class HttpClient:
}
if last_event_id:
current_headers["Last-Event-ID"] = last_event_id
if reconnect_path is not None and not any(
key.lower() == "last-event-id" and value
for key, value in current_headers.items()
):
warnings.warn(
"Reconnecting without a Last-Event-ID, so events sent while "
"disconnected are not replayed. Only runs created with "
"stream_resumable=True send the event IDs a reconnect resumes "
"from.",
stacklevel=2,
)
current_method = method if reconnect_path is None else "GET"
current_content = content if reconnect_path is None else None
current_params = params if reconnect_path is None else None
retry = False
async with self.client.stream(
current_method,
reconnect_path or path,
headers=current_headers,
content=current_content,
params=current_params,
) as res:
if reconnect_path is None and on_response:
on_response(res)
# check status
await _araise_for_status_typed(res)
# check content type
content_type = res.headers.get("content-type", "").partition(";")[0]
if "text/event-stream" not in content_type:
raise httpx.TransportError(
"Expected response header Content-Type to contain 'text/event-stream', "
f"got {content_type!r}"
)
reconnect_cause: httpx.HTTPError | None = None
try:
async with self.client.stream(
current_method,
reconnect_path or path,
headers=current_headers,
content=current_content,
params=current_params,
) as res:
if reconnect_path is None and on_response:
on_response(res)
# check status
await _araise_for_status_typed(res)
# check content type
content_type = res.headers.get("content-type", "").partition(";")[0]
if "text/event-stream" not in content_type:
raise httpx.TransportError(
"Expected response header Content-Type to contain 'text/event-stream', "
f"got {content_type!r}"
)
reconnect_location = res.headers.get("location")
if reconnect_location:
_validate_reconnect_location(
self.client.base_url, reconnect_location
)
reconnect_path = reconnect_location
reconnect_location = res.headers.get("location")
if reconnect_location:
_validate_reconnect_location(
self.client.base_url, reconnect_location
)
reconnect_path = reconnect_location
# parse SSE
decoder = SSEDecoder()
try:
async for line in aiter_lines_raw(res):
sse = decoder.decode(line=cast("bytes", line).rstrip(b"\n"))
if sse is not None:
# parse SSE
decoder = SSEDecoder(last_event_id=last_event_id)
try:
async for line in aiter_lines_raw(res):
sse = decoder.decode(line=cast("bytes", line).rstrip(b"\n"))
if sse is not None:
if decoder.last_event_id is not None:
last_event_id = decoder.last_event_id
if sse.event or decoder.has_data:
# Progress since the last drop earns a fresh
# set of reconnect attempts.
reconnect_attempts = 0
yield sse
except httpx.HTTPError as err:
# httpx.TransportError inherits from HTTPError, so transient
# disconnects during streaming land here.
if reconnect_path is None:
raise
reconnect_cause = err
else:
if sse := decoder.decode(b""):
if decoder.last_event_id is not None:
last_event_id = decoder.last_event_id
if sse.event or sse.data is not None:
if sse.event or decoder.has_data:
# decoder.decode(b"") flushes the in-flight event and may
# return an empty placeholder when there is no pending
# message. Skip these no-op events so the stream doesn't
# emit a trailing blank item after reconnects.
yield sse
except httpx.HTTPError:
# httpx.TransportError inherits from HTTPError, so transient
# disconnects during streaming land here.
if reconnect_path is None:
raise
retry = True
else:
if sse := decoder.decode(b""):
if decoder.last_event_id is not None:
last_event_id = decoder.last_event_id
if sse.event or sse.data is not None:
# decoder.decode(b"") flushes the in-flight event and may
# return an empty placeholder when there is no pending
# message. Skip these no-op events so the stream doesn't
# emit a trailing blank item after reconnects.
yield sse
if retry:
except (httpx.TransportError, RateLimitError, InternalServerError) as err:
# A reconnect that can't open (connection refused, 429, 5xx or a
# non-SSE body) usually means the server is restarting or
# mid-deploy, so retry it like a drop. Before the server sends a
# Location there is nothing to reconnect to, so raise.
if reconnect_path is None:
raise
reconnect_cause = err
if reconnect_cause is not None:
reconnect_attempts += 1
if reconnect_attempts > max_reconnect_attempts:
raise httpx.TransportError(
"Exceeded maximum SSE reconnection attempts"
)
) from reconnect_cause
await asyncio.sleep(
_reconnect_delay(reconnect_attempts - 1, base=1.0, cap=5.0)
)
continue
break
+8 -7
View File
@@ -16,7 +16,6 @@ from __future__ import annotations
import asyncio
import contextlib
import logging
import random
from collections.abc import AsyncGenerator, AsyncIterator, Generator, Mapping
from dataclasses import dataclass, field
from typing import Any, Literal, TypedDict, cast
@@ -25,7 +24,7 @@ from langchain_core.language_models.chat_model_stream import AsyncChatModelStrea
from langchain_protocol import Event, SubscribeParams
from langgraph_sdk._async.http import HttpClient
from langgraph_sdk._shared.utilities import _quote_path_param
from langgraph_sdk._shared.utilities import _quote_path_param, _reconnect_delay
from langgraph_sdk.schema import LangSmithTracing, QueryParamTypes
from langgraph_sdk.stream.controller import _SeenEventIds
from langgraph_sdk.stream.decoders import (
@@ -1691,11 +1690,13 @@ class AsyncThreadStream:
async def _reconnect_sleep(self, attempt: int) -> None:
"""Sleep with exponential backoff and jitter for reconnect attempt `attempt`."""
base = self._shared_reconnect_backoff_base
cap = self._shared_reconnect_backoff_cap
delay = min(cap, base * (2**attempt))
jitter = random.uniform(0, delay * 0.25)
await asyncio.sleep(delay + jitter)
await asyncio.sleep(
_reconnect_delay(
attempt,
base=self._shared_reconnect_backoff_base,
cap=self._shared_reconnect_backoff_cap,
)
)
async def _reconnect_shared_stream(self) -> bool:
"""Attempt to reopen the shared stream after a post-ready transport drop.
@@ -4,6 +4,7 @@ from __future__ import annotations
import functools
import os
import random
import re
from collections.abc import Mapping
from datetime import tzinfo
@@ -194,6 +195,15 @@ def _validate_reconnect_location(base_url: httpx.URL, location: str) -> str:
return location
def _reconnect_delay(attempt: int, *, base: float, cap: float) -> float:
"""Seconds to wait before reconnect attempt `attempt` (0-based).
Exponential backoff capped at `cap`, plus up to 25% jitter.
"""
delay = min(cap, base * (2**attempt))
return delay + random.uniform(0, delay * 0.25)
def _provided_vals(d: Mapping[str, Any]) -> dict[str, Any]:
return {k: v for k, v in d.items() if v is not None}
+101 -56
View File
@@ -4,6 +4,7 @@ from __future__ import annotations
import logging
import sys
import time
import warnings
from collections.abc import Callable, Iterator, Mapping
from typing import Any, cast
@@ -13,9 +14,14 @@ import orjson
from langgraph_sdk._shared.utilities import (
_orjson_default,
_reconnect_delay,
_validate_reconnect_location,
)
from langgraph_sdk.errors import _raise_for_status_typed
from langgraph_sdk.errors import (
InternalServerError,
RateLimitError,
_raise_for_status_typed,
)
from langgraph_sdk.schema import QueryParamTypes, StreamPart
from langgraph_sdk.sse import SSEDecoder, iter_lines_raw
@@ -146,7 +152,8 @@ class SyncHttpClient:
on_response: Callable[[httpx.Response], None] | None = None,
reconnect_limit: int = 5,
) -> Any:
"""Send a request that automatically reconnects to Location header."""
"""Send a request that reconnects to its Location header if the response is
cut off, with backoff between attempts."""
request_headers, content = _encode_json(json)
if headers:
request_headers.update(headers)
@@ -176,13 +183,27 @@ class SyncHttpClient:
stacklevel=2,
)
r.close()
return self.request_reconnect(
loc,
"GET",
headers=headers,
# don't pass on_response so it's only called once
reconnect_limit=reconnect_limit - 1,
)
for attempt in range(reconnect_limit):
time.sleep(_reconnect_delay(attempt, base=1.0, cap=5.0))
# A restart or a deploy looks like a refused connection, a
# dropped body, a 429 or a 5xx; other errors won't go away.
try:
return self.request_reconnect(
loc,
"GET",
headers=headers,
# don't pass on_response so it's only called once
reconnect_limit=0,
)
except httpx.HTTPStatusError as err:
status = err.response.status_code
if attempt == reconnect_limit - 1 or (
status != 429 and status < 500
):
raise
except httpx.TransportError:
if attempt == reconnect_limit - 1:
raise
def stream(
self,
@@ -227,67 +248,91 @@ class SyncHttpClient:
}
if last_event_id:
current_headers["Last-Event-ID"] = last_event_id
if reconnect_path is not None and not any(
key.lower() == "last-event-id" and value
for key, value in current_headers.items()
):
warnings.warn(
"Reconnecting without a Last-Event-ID, so events sent while "
"disconnected are not replayed. Only runs created with "
"stream_resumable=True send the event IDs a reconnect resumes "
"from.",
stacklevel=2,
)
current_method = method if reconnect_path is None else "GET"
current_content = content if reconnect_path is None else None
current_params = params if reconnect_path is None else None
retry = False
with self.client.stream(
current_method,
reconnect_path or path,
headers=current_headers,
content=current_content,
params=current_params,
) as res:
if reconnect_path is None and on_response:
on_response(res)
# check status
_raise_for_status_typed(res)
# check content type
content_type = res.headers.get("content-type", "").partition(";")[0]
if "text/event-stream" not in content_type:
raise httpx.TransportError(
"Expected response header Content-Type to contain 'text/event-stream', "
f"got {content_type!r}"
)
reconnect_cause: httpx.HTTPError | None = None
try:
with self.client.stream(
current_method,
reconnect_path or path,
headers=current_headers,
content=current_content,
params=current_params,
) as res:
if reconnect_path is None and on_response:
on_response(res)
# check status
_raise_for_status_typed(res)
# check content type
content_type = res.headers.get("content-type", "").partition(";")[0]
if "text/event-stream" not in content_type:
raise httpx.TransportError(
"Expected response header Content-Type to contain 'text/event-stream', "
f"got {content_type!r}"
)
reconnect_location = res.headers.get("location")
if reconnect_location:
_validate_reconnect_location(
self.client.base_url, reconnect_location
)
reconnect_path = reconnect_location
reconnect_location = res.headers.get("location")
if reconnect_location:
_validate_reconnect_location(
self.client.base_url, reconnect_location
)
reconnect_path = reconnect_location
decoder = SSEDecoder()
try:
for line in iter_lines_raw(res):
sse = decoder.decode(cast(bytes, line).rstrip(b"\n"))
if sse is not None:
decoder = SSEDecoder(last_event_id=last_event_id)
try:
for line in iter_lines_raw(res):
sse = decoder.decode(cast(bytes, line).rstrip(b"\n"))
if sse is not None:
if decoder.last_event_id is not None:
last_event_id = decoder.last_event_id
if sse.event or decoder.has_data:
# Progress since the last drop earns a fresh
# set of reconnect attempts.
reconnect_attempts = 0
yield sse
except httpx.HTTPError as err:
# httpx.TransportError inherits from HTTPError, so transient
# disconnects during streaming land here.
if reconnect_path is None:
raise
reconnect_cause = err
else:
if sse := decoder.decode(b""):
if decoder.last_event_id is not None:
last_event_id = decoder.last_event_id
if sse.event or sse.data is not None:
if sse.event or decoder.has_data:
# See async stream implementation for rationale on
# skipping empty flush events.
yield sse
except httpx.HTTPError:
# httpx.TransportError inherits from HTTPError, so transient
# disconnects during streaming land here.
if reconnect_path is None:
raise
retry = True
else:
if sse := decoder.decode(b""):
if decoder.last_event_id is not None:
last_event_id = decoder.last_event_id
if sse.event or sse.data is not None:
# See async stream implementation for rationale on
# skipping empty flush events.
yield sse
if retry:
except (httpx.TransportError, RateLimitError, InternalServerError) as err:
# A reconnect that can't open (connection refused, 429, 5xx or a
# non-SSE body) usually means the server is restarting or
# mid-deploy, so retry it like a drop. Before the server sends a
# Location there is nothing to reconnect to, so raise.
if reconnect_path is None:
raise
reconnect_cause = err
if reconnect_cause is not None:
reconnect_attempts += 1
if reconnect_attempts > max_reconnect_attempts:
raise httpx.TransportError(
"Exceeded maximum SSE reconnection attempts"
)
) from reconnect_cause
time.sleep(_reconnect_delay(reconnect_attempts - 1, base=1.0, cap=5.0))
continue
break
+1 -1
View File
@@ -811,7 +811,7 @@ class _On:
def __call__(
self,
*,
resources: str | Sequence[str],
resources: str | Sequence[str] | None = None,
actions: str | Sequence[str] | None = None,
) -> Callable[[AHO], AHO]: ...
+33 -20
View File
@@ -30,18 +30,14 @@ class BytesLineDecoder:
# See https://docs.python.org/3/glossary.html#term-universal-newlines
NEWLINE_CHARS = b"\n\r"
# We always push a trailing `\r` into the next decode iteration.
if self.trailing_cr:
text = b"\r" + text
self.trailing_cr = False
if text.endswith(b"\r"):
self.trailing_cr = True
text = text[:-1]
if not text:
return []
if self.trailing_cr and text.startswith(b"\n"):
text = text[1:]
self.trailing_cr = text.endswith(b"\r")
if not text:
# NOTE: the edge case input of empty text doesn't occur in practice,
# because other httpx internals filter out this value
return [] # pragma: no cover
return []
trailing_newline = text[-1] in NEWLINE_CHARS
lines = cast(list[BytesLike], text.splitlines())
@@ -66,7 +62,7 @@ class BytesLineDecoder:
return lines
def flush(self) -> list[BytesLike]:
if not self.buffer and not self.trailing_cr:
if not self.buffer:
return []
lines: list[BytesLike] = [self.buffer]
@@ -76,18 +72,25 @@ class BytesLineDecoder:
class SSEDecoder:
def __init__(self) -> None:
def __init__(self, *, last_event_id: str | None = None) -> None:
self._event = ""
self._data = bytearray()
self._last_event_id: str | None = None
self._last_event_id = last_event_id
self._retry: int | None = None
self._at_stream_start = True
self._has_data = False
self._id_changed = False
@property
def has_data(self) -> bool:
"""Whether the last decoded block contained a data field."""
return self._has_data
@property
def last_event_id(self) -> str | None:
"""Return the last event identifier that was seen.
`None` until the stream sends an `id` field, and `""` after an empty
`None` until an ID is supplied or received, and `""` after an empty
one, which resets the reconnect cursor.
"""
@@ -101,17 +104,26 @@ class SSEDecoder:
line = line.removeprefix(b"\xef\xbb\xbf")
if not line:
self._has_data = bool(self._data)
if (
not self._event
and not self._data
and self._last_event_id is None
and not self._id_changed
and self._retry is None
):
return None
body = self._data[:-1]
data = None
if body and not body.isspace():
try:
data = orjson.loads(body)
except orjson.JSONDecodeError:
data = orjson.loads(body.decode(errors="replace"))
sse = StreamPart(
event=self._event,
data=orjson.loads(self._data) if self._data else None, # ty: ignore[invalid-argument-type]
data=data, # ty: ignore[invalid-argument-type]
id=self._last_event_id or None,
)
@@ -119,6 +131,7 @@ class SSEDecoder:
self._event = ""
self._data = bytearray()
self._retry = None
self._id_changed = False
return sse
@@ -131,16 +144,16 @@ class SSEDecoder:
value = value[1:]
if fieldname == b"event":
self._event = value.decode()
self._event = value.decode(errors="replace")
elif fieldname == b"data":
if self._data:
self._data.extend(b"\n")
self._data.extend(value)
self._data.extend(b"\n")
elif fieldname == b"id":
if b"\0" in value:
pass
else:
self._last_event_id = value.decode()
self._last_event_id = value.decode(errors="replace")
self._id_changed = True
elif fieldname == b"retry":
with contextlib.suppress(TypeError, ValueError):
self._retry = int(value)
@@ -15,7 +15,6 @@ from __future__ import annotations
import asyncio
import contextlib
import logging
import random
from collections import OrderedDict
from collections.abc import AsyncGenerator, AsyncIterator, Awaitable, Callable
from dataclasses import dataclass, field
@@ -23,6 +22,7 @@ from typing import Any
from langchain_protocol import Event, SubscribeParams
from langgraph_sdk._shared.utilities import _reconnect_delay
from langgraph_sdk.stream.transport import AsyncProtocolTransport, EventStreamHandle
# ---------------------------------------------------------------------------
@@ -275,11 +275,13 @@ class StreamController:
async def _reconnect_sleep(self, attempt: int) -> None:
"""Sleep with exponential backoff and jitter for reconnect attempt *attempt*."""
base = self._reconnect_backoff_base
cap = self._reconnect_backoff_cap
delay = min(cap, base * (2**attempt))
jitter = random.uniform(0, delay * 0.25)
await asyncio.sleep(delay + jitter)
await asyncio.sleep(
_reconnect_delay(
attempt,
base=self._reconnect_backoff_base,
cap=self._reconnect_backoff_cap,
)
)
async def _reconnect_shared_stream(self) -> bool:
"""Attempt to reopen the shared stream after a transport drop.
@@ -4,7 +4,6 @@ from __future__ import annotations
import contextlib
import logging
import random
import threading
import time
from dataclasses import dataclass, field
@@ -13,6 +12,7 @@ from typing import Any
from langchain_protocol import Event, SubscribeParams
from langgraph_sdk._shared.utilities import _reconnect_delay
from langgraph_sdk.stream.errors import (
is_non_retryable_stream_error,
stream_error_frame,
@@ -282,11 +282,13 @@ class SyncStreamController:
def _reconnect_sleep(self, attempt: int) -> None:
"""Sleep with exponential backoff + jitter before a reconnect attempt."""
base = self._reconnect_backoff_base
cap = self._reconnect_backoff_cap
delay = min(cap, base * (2**attempt))
jitter = random.uniform(0, delay * 0.25)
time.sleep(delay + jitter)
time.sleep(
_reconnect_delay(
attempt,
base=self._reconnect_backoff_base,
cap=self._reconnect_backoff_cap,
)
)
def _reconnect_shared_stream(self) -> bool:
"""Attempt to reopen the shared stream after a transport drop.
+17
View File
@@ -0,0 +1,17 @@
import asyncio
import time
import pytest
@pytest.fixture
def no_sleep(monkeypatch):
"""Record backoff sleeps without actually waiting."""
sleeps: list[float] = []
async def fake_async_sleep(d):
sleeps.append(d)
monkeypatch.setattr(asyncio, "sleep", fake_async_sleep)
monkeypatch.setattr(time, "sleep", lambda d: sleeps.append(d))
return sleeps
+41
View File
@@ -1,8 +1,49 @@
import ast
import inspect
import pytest
from langgraph_sdk import Auth
@pytest.mark.parametrize(
"kwargs",
[{}, {"actions": "read"}, {"resources": None}],
)
def test_global_handler_factory_accepts_optional_resources(kwargs) -> None:
auth = Auth()
tree = ast.parse(inspect.getsource(type(auth.on)))
cls = tree.body[0]
assert isinstance(cls, ast.ClassDef)
factory_overload = next(
node
for node in cls.body
if isinstance(node, ast.FunctionDef)
and node.name == "__call__"
and node.decorator_list
and any(arg.arg == "resources" for arg in node.args.kwonlyargs)
)
resources = next(
arg for arg in factory_overload.args.kwonlyargs if arg.arg == "resources"
)
assert resources.annotation is not None
assert ast.unparse(resources.annotation) == "str | Sequence[str] | None"
default = factory_overload.args.kw_defaults[0]
assert isinstance(default, ast.Constant) and default.value is None
@auth.on(**kwargs)
async def handler(ctx, value):
del ctx, value
return None
if kwargs.get("actions"):
assert auth._handlers == {("*", "read"): [handler]}
assert auth._global_handlers == []
else:
assert auth._global_handlers == [handler]
assert auth._handlers == {}
def test_handler_multiple_resources_and_actions() -> None:
auth = Auth()
+391 -7
View File
@@ -1,6 +1,6 @@
from __future__ import annotations
from collections.abc import Iterator, Sequence
from collections.abc import Callable, Iterator, Sequence
from pathlib import Path
from typing import Any
@@ -13,6 +13,7 @@ from langgraph_sdk._async.runs import _wrap_stream_v2
from langgraph_sdk._shared.utilities import _sse_to_v2_dict
from langgraph_sdk._sync.runs import _wrap_stream_v2_sync
from langgraph_sdk.client import HttpClient, SyncHttpClient
from langgraph_sdk.errors import InternalServerError, NotFoundError
from langgraph_sdk.schema import (
CheckpointPayload,
CheckpointsStreamPart,
@@ -130,6 +131,110 @@ def test_sse_decoder_strips_a_leading_bom():
assert decoder.decode(b"") == StreamPart(event="values", data=1, id=None) # ty: ignore[invalid-argument-type]
def test_control_blocks_do_not_repeat_after_a_cursor_update():
decoder = SSEDecoder()
decoder.decode(b"id: cursor")
assert decoder.decode(b"") == StreamPart(event="", data=None, id="cursor") # ty: ignore[invalid-argument-type]
decoder.decode(b": heartbeat")
assert decoder.decode(b"") is None
assert decoder.decode(b"") is None
decoder.decode(b"id:")
assert decoder.decode(b"") == StreamPart(event="", data=None, id=None) # ty: ignore[invalid-argument-type]
assert decoder.last_event_id == ""
assert decoder.decode(b"") is None
def test_cr_lines_are_emitted_without_waiting_for_another_chunk():
decoder = BytesLineDecoder()
assert decoder.decode(b"data: 1\r\r") == [b"data: 1", b""]
assert decoder.decode(b"\n") == []
assert decoder.decode(b"data: 2\r") == [b"data: 2"]
assert decoder.decode(b"") == []
assert decoder.decode(b"\n\r") == [b""]
assert decoder.flush() == []
def test_empty_data_lines_preserve_their_newlines():
decoder = SSEDecoder()
for line in (b"data", b"data: 1", b"data:"):
decoder.decode(line)
assert decoder._data == b"\n1\n\n"
assert decoder.decode(b"") == StreamPart(event="", data=1, id=None) # ty: ignore[invalid-argument-type]
decoder.decode(b"data:")
decoder.decode(b"data:")
assert decoder.decode(b"") == StreamPart(event="", data=None, id=None) # ty: ignore[invalid-argument-type]
assert decoder.has_data
assert decoder.decode(b"") is None
assert not decoder.has_data
def test_malformed_utf8_is_replaced_in_fields_and_json_strings():
decoder = SSEDecoder()
for line in (b"event: \xff", b"id: \xff", b'data: "\xff"'):
decoder.decode(line)
assert decoder.decode(b"") == StreamPart(event="\ufffd", data="\ufffd", id="\ufffd") # ty: ignore[invalid-argument-type]
@pytest.mark.parametrize("asynchronous", [False, True])
async def test_null_and_empty_data_are_not_dropped(asynchronous: bool):
def handler(_request: httpx.Request) -> httpx.Response:
return httpx.Response(
200,
headers={"Content-Type": "text/event-stream"},
content=b"data: null\n\ndata:\n\n",
)
transport = httpx.MockTransport(handler)
if asynchronous:
async with httpx.AsyncClient(
transport=transport, base_url="https://example.com"
) as client:
parts = [part async for part in HttpClient(client).stream("/stream", "GET")]
else:
with httpx.Client(
transport=transport, base_url="https://example.com"
) as client:
parts = list(SyncHttpClient(client).stream("/stream", "GET"))
assert parts == [StreamPart(event="", data=None), StreamPart(event="", data=None)] # ty: ignore[invalid-argument-type]
@pytest.mark.parametrize("asynchronous", [False, True])
async def test_cursor_is_inherited_after_reconnect(asynchronous: bool):
requests: list[httpx.Request] = []
def handler(request: httpx.Request) -> httpx.Response:
requests.append(request)
if len(requests) == 1:
stream_cls = AsyncListByteStream if asynchronous else ListByteStream
return httpx.Response(
200,
headers={"Content-Type": "text/event-stream", "Location": "/reconnect"},
stream=stream_cls(
[b"id: cursor\nevent: values\ndata: 1\n\n"],
httpx.RemoteProtocolError("incomplete chunked read"),
),
)
assert (b"Last-Event-ID", b"cursor") in request.headers.raw
return httpx.Response(
200,
headers={"Content-Type": "text/event-stream"},
content=b"event: values\ndata: 2\n\n",
)
transport = httpx.MockTransport(handler)
if asynchronous:
async with httpx.AsyncClient(
transport=transport, base_url="https://example.com"
) as client:
parts = [part async for part in HttpClient(client).stream("/stream", "GET")]
else:
with httpx.Client(
transport=transport, base_url="https://example.com"
) as client:
parts = list(SyncHttpClient(client).stream("/stream", "GET"))
assert [part.id for part in parts] == ["cursor", "cursor"]
# --- HTTP client streaming ---
@@ -176,6 +281,7 @@ def test_sync_http_client_stream_flushes_trailing_event():
assert parts == [StreamPart(event="foo", data={"bar": 1})]
@pytest.mark.usefixtures("no_sleep")
def test_sync_http_client_stream_recovers_after_disconnect():
reconnect_path = "/reconnect"
first_chunks = [
@@ -241,6 +347,7 @@ def test_sync_http_client_stream_recovers_after_disconnect():
@pytest.mark.asyncio
@pytest.mark.usefixtures("no_sleep")
async def test_http_client_stream_recovers_after_disconnect():
reconnect_path = "/reconnect"
first_chunks = [
@@ -375,28 +482,33 @@ _CALLER_CURSORS = pytest.mark.parametrize(
@_EMPTY_IDS
@_CALLER_CURSORS
@pytest.mark.usefixtures("no_sleep")
def test_sync_stream_reconnects_without_a_cursor_after_an_empty_id(
empty_id: bytes, headers: dict[str, str] | None
):
reconnect = _sync_reconnect(
[b"id: 1\nevent: values\ndata: 1\n\n", empty_id], headers
)
with pytest.warns(UserWarning, match="Last-Event-ID"):
reconnect = _sync_reconnect(
[b"id: 1\nevent: values\ndata: 1\n\n", empty_id], headers
)
assert "last-event-id" not in reconnect.headers
@_EMPTY_IDS
@_CALLER_CURSORS
@pytest.mark.usefixtures("no_sleep")
async def test_async_stream_reconnects_without_a_cursor_after_an_empty_id(
empty_id: bytes, headers: dict[str, str] | None
):
reconnect = await _async_reconnect(
[b"id: 1\nevent: values\ndata: 1\n\n", empty_id], headers
)
with pytest.warns(UserWarning, match="Last-Event-ID"):
reconnect = await _async_reconnect(
[b"id: 1\nevent: values\ndata: 1\n\n", empty_id], headers
)
assert "last-event-id" not in reconnect.headers
@pytest.mark.usefixtures("no_sleep")
def test_sync_stream_id_replaces_a_lowercase_caller_cursor():
reconnect = _sync_reconnect(
[b"id: 1\nevent: values\ndata: 1\n\n"], {"last-event-id": "5"}
@@ -405,6 +517,7 @@ def test_sync_stream_id_replaces_a_lowercase_caller_cursor():
assert reconnect.headers.get_list("last-event-id") == ["1"]
@pytest.mark.usefixtures("no_sleep")
async def test_async_stream_id_replaces_a_lowercase_caller_cursor():
reconnect = await _async_reconnect(
[b"id: 1\nevent: values\ndata: 1\n\n"], {"last-event-id": "5"}
@@ -413,12 +526,14 @@ async def test_async_stream_id_replaces_a_lowercase_caller_cursor():
assert reconnect.headers.get_list("last-event-id") == ["1"]
@pytest.mark.usefixtures("no_sleep")
def test_sync_stream_keeps_the_caller_cursor_until_the_stream_sends_an_id():
reconnect = _sync_reconnect([b"event: values\ndata: 1\n\n"], {"Last-Event-ID": "5"})
assert reconnect.headers.get_list("last-event-id") == ["5"]
@pytest.mark.usefixtures("no_sleep")
async def test_async_stream_keeps_the_caller_cursor_until_the_stream_sends_an_id():
reconnect = await _async_reconnect(
[b"event: values\ndata: 1\n\n"], {"Last-Event-ID": "5"}
@@ -427,6 +542,275 @@ async def test_async_stream_keeps_the_caller_cursor_until_the_stream_sends_an_id
assert reconnect.headers.get_list("last-event-id") == ["5"]
# A reconnect that sends no Last-Event-ID can't replay what was sent while it was
# disconnected, so it warns.
_RECONNECT_CURSORS = pytest.mark.parametrize(
("chunks", "headers", "warns"),
[
([b"event: values\ndata: 1\n\n"], None, True),
([b"id: 1\nevent: values\ndata: 1\n\n"], None, False),
([b"event: values\ndata: 1\n\n"], {"Last-Event-ID": "5"}, False),
],
ids=["no-cursor", "stream-cursor", "caller-cursor"],
)
@_RECONNECT_CURSORS
@pytest.mark.usefixtures("no_sleep")
def test_sync_reconnect_warns_only_without_a_cursor(
chunks: list[bytes],
headers: dict[str, str] | None,
warns: bool,
recwarn: pytest.WarningsRecorder,
):
_sync_reconnect(chunks, headers)
assert any("Last-Event-ID" in str(w.message) for w in recwarn) is warns
@_RECONNECT_CURSORS
@pytest.mark.usefixtures("no_sleep")
async def test_async_reconnect_warns_only_without_a_cursor(
chunks: list[bytes],
headers: dict[str, str] | None,
warns: bool,
recwarn: pytest.WarningsRecorder,
):
await _async_reconnect(chunks, headers)
assert any("Last-Event-ID" in str(w.message) for w in recwarn) is warns
# --- reconnects survive a server restart or a deploy ---
SSE = {"Content-Type": "text/event-stream"}
DROP = httpx.RemoteProtocolError("incomplete chunked read")
def _event(i: int) -> bytes:
return f"id: {i}\nevent: values\ndata: {i}\n\n".encode()
def _scripted(steps: list[Callable[[httpx.Request], httpx.Response]]):
"""Handler that plays one step per request and records each request."""
seen: list[httpx.Request] = []
def handler(request: httpx.Request) -> httpx.Response:
seen.append(request)
if len(seen) > len(steps):
raise AssertionError("unexpected request")
return steps[len(seen) - 1](request)
return handler, seen
def _ok(stream_cls, chunks, *, drop=False, location=None):
headers = dict(SSE)
if location:
headers["Location"] = location
def step(_request):
return httpx.Response(
200, headers=headers, stream=stream_cls(chunks, DROP if drop else None)
)
return step
def _status(code):
return lambda request: httpx.Response(
code, json={"detail": "upstream"}, request=request
)
def _connect_error(request):
raise httpx.ConnectError("[Errno 61] Connection refused", request=request)
def _not_sse(request):
return httpx.Response(
200, headers={"Content-Type": "text/html"}, text="<html>", request=request
)
def _transient_steps(stream_cls, failure):
"""POST drops after event 1; first reconnect fails; second succeeds."""
return [
_ok(stream_cls, [_event(1)], drop=True, location="/reconnect"),
failure,
_ok(stream_cls, [_event(2), b"event: end\ndata: null\n\n"]),
]
def _long_stream_steps(stream_cls, drops):
"""POST + `drops` reconnects, each delivering one event then dropping."""
steps = [_ok(stream_cls, [_event(0)], drop=True, location="/reconnect")]
for i in range(1, drops):
steps.append(_ok(stream_cls, [_event(i)], drop=True))
steps.append(_ok(stream_cls, [_event(drops), b"event: end\ndata: null\n\n"]))
return steps
def _run_sync(steps):
handler, seen = _scripted(steps)
with httpx.Client(
transport=httpx.MockTransport(handler), base_url="https://example.com"
) as client:
parts = list(SyncHttpClient(client).stream("/stream", "POST", json={}))
return parts, seen
async def _run_async(steps):
sync_handler, seen = _scripted(steps)
async def handler(request):
return sync_handler(request)
async with httpx.AsyncClient(
transport=httpx.MockTransport(handler), base_url="https://example.com"
) as client:
parts = [p async for p in HttpClient(client).stream("/stream", "POST", json={})]
return parts, seen
FAILURES = {
"429": _status(429),
"502": _status(502),
"503": _status(503),
"connect_error": _connect_error,
"not_sse": _not_sse,
}
# A reconnect that hits a 429 / 502 / 503 / connect error / non-SSE body, then
# succeeds, should recover.
@pytest.mark.usefixtures("no_sleep")
@pytest.mark.parametrize("failure", FAILURES.values(), ids=FAILURES.keys())
def test_sync_reconnect_recovers_from_transient_failure(failure):
parts, seen = _run_sync(_transient_steps(ListByteStream, failure))
assert [p.event for p in parts] == ["values", "values", "end"]
assert len(seen) == 3
@pytest.mark.usefixtures("no_sleep")
@pytest.mark.parametrize("failure", FAILURES.values(), ids=FAILURES.keys())
async def test_async_reconnect_recovers_from_transient_failure(failure):
parts, seen = await _run_async(_transient_steps(AsyncListByteStream, failure))
assert [p.event for p in parts] == ["values", "values", "end"]
assert len(seen) == 3
# Any other 4xx on a reconnect won't go away by retrying, so it raises.
def _client_error_steps(stream_cls):
return [
_ok(stream_cls, [_event(1)], drop=True, location="/reconnect"),
_status(404),
]
@pytest.mark.usefixtures("no_sleep")
def test_sync_reconnect_does_not_retry_a_client_error():
with pytest.raises(NotFoundError):
_run_sync(_client_error_steps(ListByteStream))
@pytest.mark.usefixtures("no_sleep")
async def test_async_reconnect_does_not_retry_a_client_error():
with pytest.raises(NotFoundError):
await _run_async(_client_error_steps(AsyncListByteStream))
# The first request isn't a reconnect, so it raises even when it names a Location.
def _first_request_503(request):
return httpx.Response(
503,
headers={"Location": "/reconnect"},
json={"detail": "upstream"},
request=request,
)
@pytest.mark.usefixtures("no_sleep")
def test_sync_first_request_failure_is_not_retried():
with pytest.raises(InternalServerError):
_run_sync([_first_request_503])
@pytest.mark.usefixtures("no_sleep")
async def test_async_first_request_failure_is_not_retried():
with pytest.raises(InternalServerError):
await _run_async([_first_request_503])
# A server that stays down gets five reconnects, backing off 1, 2, 4, 5 and 5s
# (each plus up to 25% jitter), so about 20s, before the stream gives up. The
# error it gives up with is caused by the last failure, so callers see why.
GIVE_UP = "Exceeded maximum SSE reconnection attempts"
def _server_down_steps(stream_cls):
return [
_ok(stream_cls, [_event(1)], drop=True, location="/reconnect"),
*[_status(502)] * 5,
]
def _assert_backed_off_for_about_20s(sleeps: list[float]) -> None:
assert len(sleeps) == 5
for slept, delay in zip(sleeps, [1, 2, 4, 5, 5], strict=True):
assert delay <= slept <= delay * 1.25
def test_sync_reconnect_gives_up_after_five_backed_off_attempts(no_sleep):
with pytest.raises(httpx.TransportError, match=GIVE_UP) as exc_info:
_run_sync(_server_down_steps(ListByteStream))
assert isinstance(exc_info.value.__cause__, InternalServerError)
_assert_backed_off_for_about_20s(no_sleep)
async def test_async_reconnect_gives_up_after_five_backed_off_attempts(no_sleep):
with pytest.raises(httpx.TransportError, match=GIVE_UP) as exc_info:
await _run_async(_server_down_steps(AsyncListByteStream))
assert isinstance(exc_info.value.__cause__, InternalServerError)
_assert_backed_off_for_about_20s(no_sleep)
# A long stream that drops 6 times, each reconnect succeeding and making
# progress, should not run out of attempts.
@pytest.mark.usefixtures("no_sleep")
def test_sync_long_stream_survives_many_spaced_out_drops():
parts, _ = _run_sync(_long_stream_steps(ListByteStream, drops=6))
assert [p.data for p in parts] == [0, 1, 2, 3, 4, 5, 6, None]
@pytest.mark.usefixtures("no_sleep")
async def test_async_long_stream_survives_many_spaced_out_drops():
parts, _ = await _run_async(_long_stream_steps(AsyncListByteStream, drops=6))
assert [p.data for p in parts] == [0, 1, 2, 3, 4, 5, 6, None]
# Reconnects should back off rather than hammer the server.
def test_sync_reconnect_backs_off(no_sleep):
_run_sync(_long_stream_steps(ListByteStream, drops=2))
assert len(no_sleep) == 2 and all(d > 0 for d in no_sleep)
async def test_async_reconnect_backs_off(no_sleep):
await _run_async(_long_stream_steps(AsyncListByteStream, drops=2))
assert len(no_sleep) == 2 and all(d > 0 for d in no_sleep)
# --- _sse_to_v2_dict conversion ---
+98 -3
View File
@@ -1,7 +1,8 @@
from __future__ import annotations
import json
from collections.abc import AsyncIterator, Iterator
from collections.abc import AsyncIterator, Callable, Iterator, Sequence
from typing import Any
import httpx
import pytest
@@ -24,7 +25,16 @@ class _BodyDropsMidRead(httpx.SyncByteStream, httpx.AsyncByteStream):
raise httpx.ReadError("connection dropped")
def _wait_handler(requests: list[httpx.Request]):
def _done(_request: httpx.Request) -> httpx.Response:
return httpx.Response(200, json={"done": True})
def _wait_handler(
requests: list[httpx.Request],
reconnects: Sequence[Callable[[httpx.Request], httpx.Response]] = (_done,),
):
"""POST drops mid-body; the reconnects answer from `reconnects` in order."""
def handler(request: httpx.Request) -> httpx.Response:
requests.append(request)
if request.method == "POST":
@@ -33,7 +43,7 @@ def _wait_handler(requests: list[httpx.Request]):
headers={"Location": "/threads/t1/runs/r1/join"},
stream=_BodyDropsMidRead(),
)
return httpx.Response(200, json={"done": True})
return reconnects[len(requests) - 2](request)
return handler
@@ -46,6 +56,7 @@ def _assert_bodyless_get(request: httpx.Request) -> None:
assert request.read() == b""
@pytest.mark.usefixtures("no_sleep")
def test_sync_wait_reconnects_with_a_bodyless_get():
requests: list[httpx.Request] = []
transport = httpx.MockTransport(_wait_handler(requests))
@@ -58,6 +69,7 @@ def test_sync_wait_reconnects_with_a_bodyless_get():
_assert_bodyless_get(requests[1])
@pytest.mark.usefixtures("no_sleep")
async def test_async_wait_reconnects_with_a_bodyless_get():
requests: list[httpx.Request] = []
transport = httpx.MockTransport(_wait_handler(requests))
@@ -72,6 +84,89 @@ async def test_async_wait_reconnects_with_a_bodyless_get():
_assert_bodyless_get(requests[1])
def _connect_error(request: httpx.Request) -> httpx.Response:
raise httpx.ConnectError("[Errno 61] Connection refused", request=request)
async def _wait(
handler: Callable[[httpx.Request], httpx.Response], *, asynchronous: bool
) -> Any:
transport = httpx.MockTransport(handler)
if asynchronous:
async with httpx.AsyncClient(
transport=transport, base_url="https://example.com"
) as client:
return await RunsClient(HttpClient(client)).wait("t1", "agent", input={})
with httpx.Client(transport=transport, base_url="https://example.com") as client:
return SyncRunsClient(SyncHttpClient(client)).wait("t1", "agent", input={})
# What a server restart or a deploy looks like to the reconnect GET: each one is
# retried after a backoff.
RECONNECT_FAILURES = {
"429": lambda request: httpx.Response(429, request=request),
"503": lambda request: httpx.Response(503, request=request),
"connect_error": _connect_error,
"body_drops": lambda _request: httpx.Response(
200,
headers={"Location": "/threads/t1/runs/r1/join"},
stream=_BodyDropsMidRead(),
),
}
@pytest.mark.parametrize("asynchronous", [False, True])
@pytest.mark.parametrize(
"failure", RECONNECT_FAILURES.values(), ids=RECONNECT_FAILURES.keys()
)
async def test_wait_retries_a_failed_reconnect_after_a_backoff(
failure, asynchronous, no_sleep
):
requests: list[httpx.Request] = []
with pytest.warns(UserWarning, match="attempting reconnect"):
result = await _wait(
_wait_handler(requests, [failure, _done]), asynchronous=asynchronous
)
assert result == {"done": True}
assert len(requests) == 3
assert len(no_sleep) == 2 and min(no_sleep) >= 1, no_sleep
@pytest.mark.usefixtures("no_sleep")
@pytest.mark.parametrize("asynchronous", [False, True])
async def test_wait_raises_after_five_failed_reconnects(asynchronous):
requests: list[httpx.Request] = []
with (
pytest.warns(UserWarning, match="attempting reconnect"),
pytest.raises(httpx.HTTPStatusError, match="503"),
):
await _wait(
_wait_handler(requests, [RECONNECT_FAILURES["503"]] * 5),
asynchronous=asynchronous,
)
assert len(requests) == 6
@pytest.mark.usefixtures("no_sleep")
@pytest.mark.parametrize("asynchronous", [False, True])
async def test_wait_does_not_retry_a_client_error(asynchronous):
requests: list[httpx.Request] = []
with (
pytest.warns(UserWarning, match="attempting reconnect"),
pytest.raises(httpx.HTTPStatusError, match="404"),
):
await _wait(
_wait_handler(
requests, [lambda request: httpx.Response(404, request=request)]
),
asynchronous=asynchronous,
)
assert len(requests) == 2
def test_sync_wait_raises_the_run_error():
requests: list[httpx.Request] = []