The error path in chat_append_stream() catches the rejection and appends sanitized_chat_error(reason) (chat.R#L863-L887). That only covers an error raised after chat_append_stream_impl() suspends at its first await. A provider that rejects the request outright fails before that, synchronously, so those handlers are never attached and nothing reaches the UI.
Mechanism
The ellmer::Chat$stream_async(stream = "content") call returns a synchronous coro_generator_instance, and the request runs on its first advance — surfacing as a plain R error, not a rejected promise:
client <- ellmer::chat_openai(
base_url = "http://127.0.0.1:1/v1",
model = "gpt-4.1-nano",
credentials = function() list(Authorization = "Bearer x")
)
gen <- client$stream_async("hi", stream = "content")
gen()
#> Error in `req_perform_connection()`: Failed to perform HTTP request.
#> Caused by error in `open.connection()`: ! cannot open the connection
The consuming loop (chat.R#L924) sits in the segment coro::async() runs eagerly, ahead of the first await. So the error propagates synchronously out of the first statement of chat_append_stream() (chat.R#L856), skipping everything after it — both promises::catch() calls included. Tracing confirms it: chat_append_message is entered exactly once, for the chunk = "start", and the rlang::warn() in the error handler never fires.
The error then lands in append_stream_task, whose result nothing reads — on_stream_complete branches only on "success", and status() collapses "success" and "error" alike to "idle", so a consumer cannot see it either:
task$status(): error | mod$status(): idle | result: httr2_failure: Failed to perform HTTP request.
The browser therefore keeps the loading bubble it raised on submit, with an empty .shiny-chat-message-content and .shiny-chat-input still carrying disabled — so the turn cannot be retried. Nothing later clears either: the chunk = "end" that would is in the handler that never runs.
This is the shape of every provider-side rejection — exhausted quota, over-long context, dropped connection, bad key. None yield a chunk, so none are the mid-stream case that is handled. A rejected turn is indistinguishable from a slow one, and the context-length case never recovers for the rest of the session.
Reproducing
Nothing listens on port 1, so the first request is refused. Type anything and submit.
library(shiny)
library(shinychat)
client <- ellmer::chat_openai(
base_url = "http://127.0.0.1:1/v1",
model = "gpt-4.1-nano",
credentials = function() list(Authorization = "Bearer x"),
echo = "none"
)
ui <- bslib::page_fluid(
chat_ui("chat"),
verbatimTextOutput("probe")
)
server <- function(input, output, session) {
mod <- chat_server("chat", client, history = FALSE)
task <- get("append_stream_task", envir = environment(mod$append))
output$probe <- renderText({
err <- tryCatch(task$result(), error = conditionMessage)
paste0(
"task$status(): ", task$status(),
" | mod$status(): ", mod$status(),
" | result: ", err
)
})
}
shinyApp(ui, server)
Permalinks are pinned to 64330fc, which is what I ran; main is unchanged in all three respects (chat.R#L991, chat_app.R#L714). Versions: shinychat 0.4.0.9000 built from source, ellmer 0.4.1.9000, R 4.5.3.
Fix
Turning the synchronous throw into a rejection routes these into the handling that already exists, since everything downstream of that line is promise-shaped:
result <- tryCatch(
chat_append_stream_impl(id, stream, role, icon, session),
error = function(e) promises::promise_reject(e)
)
Applied to chat.R:856 in a source build of 64330fc and re-running the app above:
|
before |
after |
| assistant bubble text |
'' |
An error occurred: + the httr2 message |
.shiny-chat-input has disabled |
TRUE |
FALSE |
chat_append_stream() warning |
absent |
fires |
Note it deliberately leaves chat_append_stream() returning the rejected promise, so append_stream_task$status() stays "error". Reading append_stream_task$result() in on_stream_complete is therefore still worth doing on its own — it would catch anything else escaping the append path and let a consumer tell a failed turn from a finished one, which status() currently cannot.
Happy to open a PR for either or both if that's useful — just say which you'd prefer.
Related to #276, but distinct: that asks for control over how a displayed error renders, and presumes it is displayed. This class is not displayed at all.
The error path in
chat_append_stream()catches the rejection and appendssanitized_chat_error(reason)(chat.R#L863-L887). That only covers an error raised afterchat_append_stream_impl()suspends at its firstawait. A provider that rejects the request outright fails before that, synchronously, so those handlers are never attached and nothing reaches the UI.Mechanism
The
ellmer::Chat$stream_async(stream = "content")call returns a synchronouscoro_generator_instance, and the request runs on its first advance — surfacing as a plain R error, not a rejected promise:The consuming loop (chat.R#L924) sits in the segment
coro::async()runs eagerly, ahead of the firstawait. So the error propagates synchronously out of the first statement ofchat_append_stream()(chat.R#L856), skipping everything after it — bothpromises::catch()calls included. Tracing confirms it:chat_append_messageis entered exactly once, for thechunk = "start", and therlang::warn()in the error handler never fires.The error then lands in
append_stream_task, whose result nothing reads —on_stream_completebranches only on"success", andstatus()collapses"success"and"error"alike to"idle", so a consumer cannot see it either:The browser therefore keeps the loading bubble it raised on submit, with an empty
.shiny-chat-message-contentand.shiny-chat-inputstill carryingdisabled— so the turn cannot be retried. Nothing later clears either: thechunk = "end"that would is in the handler that never runs.This is the shape of every provider-side rejection — exhausted quota, over-long context, dropped connection, bad key. None yield a chunk, so none are the mid-stream case that is handled. A rejected turn is indistinguishable from a slow one, and the context-length case never recovers for the rest of the session.
Reproducing
Nothing listens on port 1, so the first request is refused. Type anything and submit.
Permalinks are pinned to 64330fc, which is what I ran;
mainis unchanged in all three respects (chat.R#L991, chat_app.R#L714). Versions: shinychat 0.4.0.9000 built from source, ellmer 0.4.1.9000, R 4.5.3.Fix
Turning the synchronous throw into a rejection routes these into the handling that already exists, since everything downstream of that line is promise-shaped:
Applied to
chat.R:856in a source build of 64330fc and re-running the app above:''An error occurred:+ the httr2 message.shiny-chat-inputhasdisabledTRUEFALSEchat_append_stream()warningNote it deliberately leaves
chat_append_stream()returning the rejected promise, soappend_stream_task$status()stays"error". Readingappend_stream_task$result()inon_stream_completeis therefore still worth doing on its own — it would catch anything else escaping the append path and let a consumer tell a failed turn from a finished one, whichstatus()currently cannot.Happy to open a PR for either or both if that's useful — just say which you'd prefer.
Related to #276, but distinct: that asks for control over how a displayed error renders, and presumes it is displayed. This class is not displayed at all.