Skip to content

[Backport release-1.19] Return the next message after a streaming subscription reconnects - #1269

Open
dapr-bot wants to merge 1 commit into
release-1.19from
backport-1231-to-release-1.19
Open

dapr-bot wants to merge 1 commit into
release-1.19from
backport-1231-to-release-1.19

Conversation

@dapr-bot

Copy link
Copy Markdown
Collaborator

Backport 77beac2 from #1231.

)

* Return the next message after a streaming subscription reconnects

After a transient stream error (UNAVAILABLE, UNKNOWN, INTERNAL),
next_message() reconnected and then returned None instead of reading
from the new stream. It now reads from the new stream, and raises after
MAX_RECONNECT_ATTEMPTS reconnects in one call. The async client does the
same and now also retries INTERNAL.

The request iterator of a failed stream kept waiting on the shared send
queue, so after a reconnect it took the next ack and sent it to the dead
stream. Each stream now has its own send queue, and closing a stream
ends its request iterator.

close() now stays closed: a reconnect already in progress no longer
reopens the stream. The close function from subscribe_with_handler waits
up to SUBSCRIPTION_CLOSE_TIMEOUT_SECONDS for the handler thread, so
test_subscribe_topic_with_handler no longer leaves that thread running
and its coverage no longer changes between runs.

Fixes #1230

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Signed-off-by: Casper Nielsen <casper@diagrid.io>

* Address review: end of stream, stale acks and reconnect backoff

A stream the server ends cleanly (StopIteration in the sync client,
grpc.aio.EOF in the async one) now goes through the same bounded
reconnect as UNAVAILABLE, UNKNOWN and INTERNAL, instead of failing with
"Error while fetching message". The async docstring no longer promises
None at end of stream.

Reconnects inside one next_message() call now wait a short jittered
exponential backoff between attempts. close() ends that wait at once.
After MAX_RECONNECT_ATTEMPTS the clients raise the new
StreamReconnectError, a subclass of Exception.

The async start() now raises StreamInactiveError when close() runs
during the initial read, as the sync one does. A cancellation while the
subscription is still open is re-raised.

Each message remembers the send queue of the stream it came from, and
respond() drops a response to a message from a replaced stream, with a
debug log. The runtime only logs an error for such an ack, but it never
has to see it now.

The sync close function logs a warning when the handler thread is still
running after SUBSCRIPTION_CLOSE_TIMEOUT_SECONDS. The async subscription
logs through a module logger instead of print(). The unreachable
queue.Empty handler is gone.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Signed-off-by: Casper Nielsen <casper@diagrid.io>

* fix: retry failed reconnects and release the request iterator on start failure

- Sync start() opens the RPC outside the stream lock and re-checks close()
  afterwards, cancelling the new stream if the subscription was closed.
- A failed initial read now puts the None sentinel on the stream's send
  queue, so gRPC's request-consumer thread exits (sync and async), and the
  async client clears _stream_active like the sync client does.
- A reconnect whose new stream fails with UNAVAILABLE/UNKNOWN/INTERNAL now
  counts as an attempt and waits for the backoff, instead of escaping
  next_message().
- Async start() re-raises CancelledError when the caller's own task is being
  cancelled (Python 3.11+), instead of turning it into StreamInactiveError.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Signed-off-by: Casper Nielsen <casper@diagrid.io>

* docs: describe streaming subscription reconnects and StreamReconnectError

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Signed-off-by: Casper Nielsen <casper@diagrid.io>

* fix: use a general, lazily formatted warning when a subscription response fails

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Signed-off-by: Casper Nielsen <casper@diagrid.io>

---------

Signed-off-by: Casper Nielsen <casper@diagrid.io>
Co-authored-by: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Co-authored-by: Sam Kendziora <sam@diagrid.io>
(cherry picked from commit 77beac2)
Signed-off-by: dapr-bot <dapr-bot@users.noreply.github.com>
@dapr-bot
dapr-bot requested review from a team as code owners October 12, 2026 22:39
@codecov

codecov Bot commented Oct 12, 2026 •

Copy link
Copy Markdown

Codecov Report

❌ Patch coverage is 96.04520% with 7 lines in your changes missing coverage. Please review.
⚠️ Please upload report for BASE (release-1.19@f94bdd5). Learn more about missing BASE report.

Files with missing lines Patch % Lines
dapr/aio/clients/grpc/subscription.py 95.45% 4 Missing ⚠️
dapr/clients/grpc/subscription.py 96.15% 3 Missing ⚠️
Additional details and impacted files
@@               Coverage Diff               @@
##             release-1.19    #1269   +/-   ##
===============================================
  Coverage                ?   85.83%           
===============================================
  Files                   ?      163           
  Lines                   ?    12908           
  Branches                ?        0           
===============================================
  Hits                    ?    11080           
  Misses                  ?     1828           
  Partials                ?        0           

☔ View full report in Codecov by Harness.
📢 Have feedback on the report? Share it here.

🚀 New features to boost your workflow:
  • ❄️ Test Analytics: Detect flaky tests, report on failures, and find test suite problems.

@CasperGN CasperGN self-assigned this Oct 12, 2026
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants