Repository navigation
Run Python SDK handlers concurrently, not inline on the dispatch stream - #146
Merged
Merged
Conversation
aszarama
previously approved these changes
Sep 14, 2026
The dispatch loop called each handler inline:
for response in stub.Dispatch(...):
result = handler_to_invoke(ctx)
self.agent_stub.ReportInvocation(invoke_info)
so the next message was not read until the current handler returned. One
slow handler stalled every invocation behind it. There is no threading
anywhere else in the SDK, so the limit was exactly one at a time.
Webhooks make that visible. The agent accepts a webhook, returns 2xx,
queues it, sends it, and starts a timeout timer. While the SDK is busy on
the previous invocation the queued ones time out, and the agent logs
"invocation error:" or "context deadline exceeded". The sender was told
2xx, so the webhook is simply lost. Paychex hit this on xld_deploy_sync,
which syncs deployments from XL Deploy and is slow enough to fall behind.
Run handlers on a bounded thread pool instead, which is what the Go SDK
already does with a goroutine per invocation. A semaphore stops the loop
reading once every worker is busy, so the agent sees its own queue fill
rather than this process growing without bound.
Reporting now happens on the worker, so a failure there can no longer
propagate into the dispatch loop and force a reconnect. It is caught and
logged, otherwise the future would swallow it and the invocation would
look like it vanished.
Two test fixes come along with it. The mock server was not held anywhere,
so it could be garbage collected mid-test and stop listening, and a
MagicMock cannot serve a streaming RPC at all, which is why no test had
ever exercised dispatch. The new test uses a real servicer and fails
against the serial version.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
keithfz
force-pushed
the
keithzeto/cd-596-python-sdk-concurrent-handlers
branch
from
September 30, 2026 14:46
74098e6 to
44845fe
Compare
keithfz
added a commit
that referenced
this pull request
Sep 30, 2026
The PR scan on #146 flagged 52 fixable HIGH CVEs. 47 are OS packages: libssl3t64, openssl and openssl-provider-legacy at 3.5.7-1~deb13u2, and linux-libc-dev at 6.12.107-1. The cached apt layer is stale again. A fresh apt layer built today gets openssl 3.5.7-1~deb13u3 and linux-libc-dev 6.12.111-1, which are the fixed versions. The other 5 findings (engine.io and brace-expansion) come from the snyk-broker fork and are fixed in cortexapps/snyk-broker#30. Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
aszarama
approved these changes
Sep 30, 2026
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Problem
The Python SDK ran each handler inline on the dispatch stream loop:
The next message was not read until the current handler returned, so one slow handler stalled everything behind it. There is no threading anywhere else in the SDK, so the concurrency limit was exactly one.
Webhooks make this visible, and it is what was reported in CD-596:
invocation error:orcontext deadline exceededThe Go SDK does not have this problem — it already runs a goroutine per invocation.
Change
Handlers now run on a bounded
ThreadPoolExecutor, eight at a time by default and configurable withmax_concurrent_handlers.A semaphore stops the loop reading once every worker is busy. That is deliberate: the agent then sees its own queue fill, instead of this process queueing without limit.
Reporting moved onto the worker, so a failure there can no longer propagate into the dispatch loop and force a reconnect. It is caught and logged — otherwise the future would swallow it and the invocation would look like it vanished.
Note for handler authors
Two invocations of the same handler can now run at the same time, so handlers must be thread safe.
max_concurrent_handlers=1restores the old behaviour. Documented in the SDK README.Tests
test_handlers_run_concurrentlydispatches four invocations that block on a barrier. It passes only if all four are in flight at once, and fails against the serial version — verified by temporarily reverting the submit.Two test-infrastructure fixes came with it:
MagicMockcannot serve a streaming RPC, so no existing test had ever exercised dispatch — the stream always failed and the client retried. The new test uses a real servicer.Full suite: 9 passed, ruff clean.
🤖 Generated with Claude Code