Skip to content

fix(r): report a response that fails before it streams anything - #314

Open
nbenn wants to merge 3 commits into
posit-dev:mainfrom
nbenn:fix/304-stream-fails-before-yield
Open

fix(r): report a response that fails before it streams anything#314
nbenn wants to merge 3 commits into
posit-dev:mainfrom
nbenn:fix/304-stream-fails-before-yield

Conversation

@nbenn

@nbenn nbenn commented Aug 14, 2026

Copy link
Copy Markdown

Fixes #304.

The error path in chat_append_stream() never runs for a response that fails before it streams anything. Its stream is consumed inside a coroutine ahead of the first await, so the failure is raised synchronously rather than rejecting, skips both promises::catch() calls, and ends up in the chat_server() stream task whose result nothing reads. The browser keeps the loading indicator it raised on submit, with the composer disabled. That is the shape of every turn a provider rejects outright, since ellmer::Chat$stream_async() performs the request on the generator's first advance.

Commit 1, fix(r) — catch the synchronous throw and turn it into a rejection, which routes it into the handling already there. The error then renders through sanitized_chat_error() and the composer is released.

Commit 2, feat(r) — expose the condition as last_error on the chat_server() return, derived from the task rather than stored. The Python package already reads _stream_task.error() in an effect, so R was the outlier. Drop this commit if you would rather take the fix alone.

Calls I made

Some judgement calls you might want to weigh in on:

  • The last_error value is additive, rather than making status() three-valued. An "error" state is more honest but breaks callers testing status() == "idle" for completion. Exposing the task itself, as Python's latest_message_stream does, is a third option.
  • It stores nothing and re-raises nothing. Python raises NotifyException from an effect; the R equivalent is an observe(), where a raised error takes the session down. Control over presentation belongs in R pkg: argument to control how errors are displayed in the chat #276.
  • A failed turn now warns twice — Shiny's plus chat_append_stream()'s. Reading $result() does not mark the task handled. A mid-stream failure already does this, so the two classes now match.
  • The last_error test keeps a MockShinySession open rather than using testServer(), which leaks an unhandled rejection when an ExtendedTask settles after teardown. Happy to use a house pattern if there is one.

@nbenn
nbenn marked this pull request as ready for review August 14, 2026 10:48
@gadenbuie
gadenbuie self-requested a review August 14, 2026 14:11

@gadenbuie gadenbuie left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Two test-hardening notes on an otherwise solid fix, both minor. The chat_append_stream() change correctly routes the synchronous throw into the existing rejection handling, and the last_error wiring reads cleanly. Comments are inline.

Comment thread pkg-r/tests/testthat/test-chat.R Outdated
Comment thread pkg-r/tests/testthat/test-chat.R
Comment thread pkg-r/tests/testthat/test-chat.R
@gadenbuie

gadenbuie commented Aug 14, 2026

Copy link
Copy Markdown
Collaborator

Weighing in on the "Calls I made" — this is a well-reasoned change, and last_error is the right shape. Notes below.

  • The last_error value is additive, rather than making status() three-valued. An "error" state is more honest but breaks callers testing status() == "idle" for completion. Exposing the task itself, as Python's latest_message_stream does, is a third option.

I think keeping it additive is the right call and it's not worth adding a new "error" state at this point. It's also consistent with a decision the R API already made: status is exposed as a derived scalar ("streaming"/"idle"), not the raw task, and last_turn already exposes the successful result. last_error completes that same decomposition — status / last_turn / last_error — rather than introducing a competing idiom.

On the third option — exposing the task. I took a look at this and landed on last_error() being the right call; recording why, mostly just for future reference:

  • R's ExtendedTask public surface is initialize / invoke / status / result. invoke here is function(client, ui_id, user_input, controller) — handing back the raw task lets an app author fire a turn directly and bypass the whole on_chat_user_input orchestration (input tracking, the shared controller, the running-state guard, pending_swap). So the task can't be exposed as-is; it would need an invoke-stripped, read-only proxy.
  • Once stripped, that proxy would expose status() + result(), which would collide with the existing status reactive (two vocabularies — running/success/error vs streaming/idle) and with last_turn. And result() would re-raise on error / req(FALSE) on idle, pushing reactive-only semantics onto every consumer. The coherent version of "expose the task" is really a curated handle (status()/result()/error()) — a new API to name and stabilize, not "surface the ExtendedTask."
  • Crucially, last_error doesn't preclude that. Because the turn stays internally orchestrated, if a real need for the live handle shows up later (a partial-result reader, a cancel affordance), it can be added as another purpose-built accessor beside last_turn/last_error without breaking anyone. Exposing the task would have been the hard-to-reverse move.

So: last_error as implemented. 👍

  • The last_error test keeps a MockShinySession open rather than using testServer(), which leaks an unhandled rejection when an ExtendedTask settles after teardown. Happy to use a house pattern if there is one.

Good news! Driving a MockShinySession directly rather than testServer() is the house pattern. I left a comment above that fixes the timeout problem and uses more in-house helpers.

  • A failed turn now warns twice — Shiny's plus chat_append_stream()'s. Reading $result() does not mark the task handled. A mid-stream failure already does this, so the two classes now match.

At this point, I'm okay with the duplication. If it ends up being annoying, we can chase down a resolution later.

nbenn added 3 commits August 14, 2026 19:33
chat_append_stream_impl() is a coroutine, so the loop consuming the
stream runs eagerly, ahead of its first await(). A stream that fails
before it yields anything therefore raised its error synchronously
rather than rejecting, which skipped the promises::catch() handling in
chat_append_stream() entirely -- so nothing was shown in the chat and
the browser kept the loading indicator it raised on submit, with the
composer left disabled.

ellmer::Chat$stream_async() returns a synchronous generator that
performs the request on its first advance, so this is the shape of
every turn a provider rejects outright: an exhausted quota, an
over-long context, a dropped connection. Only a mid-stream failure
took the path that reports the error.

Catch the synchronous throw and turn it into a rejection, which routes
it into the handling already there.
The stream task's result was never read, so a rejection was captured by
the ExtendedTask and dropped. on_stream_complete() branches only on
"success", and status() reports both a finished and a failed response
as "idle", leaving a caller no way to tell them apart.

Expose last_error on the chat_server() return, derived from the task
rather than stored: it reads the condition when the task is in "error"
and NULL otherwise, so it follows the task to "running" on the next
turn and to "success" when one lands. A stored copy would need
resetting in both places and would read stale in between.

This stays observation-only. chat_append_stream() already shows the
error in the chat once the failure reaches it, so surfacing it here as
well would report the same failure twice.
Both from review. The wait loop had no upper bound, and testthat applies
no per-test timeout, so a regression that stopped the rejection reaching
the task would have spun until the CI job itself timed out. Bound it with
the deadline idiom flush_promises() already uses, keeping the flushReact()
interleave this test needs, and add an expect_false() so a missing error
reads as a failure rather than a hang.

Assert the fail-before-yield rejection is a shiny.silent.error, matching
the sibling mid-stream test. That the two paths converge on the same
handling is the point of the fix, so the class is what proves it.
@nbenn
nbenn force-pushed the fix/304-stream-fails-before-yield branch from 904f095 to 14d77d4 Compare August 14, 2026 19:35
@nbenn

nbenn commented Aug 14, 2026

Copy link
Copy Markdown
Author

Rebased onto main to clear a NEWS.md conflict — both entries wanted the top of ## Bug fixes. Kept yours from #275 unmodified and put this one above it, following the newest-landed-first ordering the section already uses. Say the word if you would rather they were the other way round.

Nothing else drifted: air format --check is clean, docs regenerate to the same output under the pinned roxygen, and the suite is green at 869. The one failure, test-chat_history_integration.R:972 on @finish_reason, reproduces on a clean checkout of current main here too, so it is unrelated to this branch.

The workflow runs are sitting at action_required — they need an approval to start, since this is a first contribution from a fork.

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.

A stream that fails before its first yield is dropped, leaving a spinner that never resolves

2 participants