Skip to content

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

Description

@nbenn

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.

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    No type

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions