we-promise/sure · error · Assistant::Error

External assistant connection was interrupted.

Error message

External assistant connection was interrupted.

What it means

Raised by Assistant::External::Client#chat when a TRANSIENT_ERRORS network failure (Net::OpenTimeout, Net::ReadTimeout, Errno::ECONNREFUSED, Errno::ECONNRESET, Errno::EHOSTUNREACH, SocketError) hits AFTER at least one SSE content chunk was already yielded to the caller's block. Because partial output has already streamed, the client deliberately skips its retry loop and wraps the failure as Assistant::Error so a retry cannot duplicate already-delivered text.

Source

Thrown at app/models/assistant/external/client.rb:51

  #
  # Returns the model identifier string from the response.
  def chat(messages:, user: nil, &block)
    uri = URI(@url)
    request = build_request(uri, messages, user)
    retries = 0
    streaming_started = false

    begin
      http = build_http(uri)
      model = stream_response(http, request) do |content|
        streaming_started = true
        block.call(content)
      end
      model
    rescue *TRANSIENT_ERRORS => e
      if streaming_started
        Rails.logger.warn("[External::Client] Stream interrupted: #{e.class} - #{e.message}")
        raise Assistant::Error, "External assistant connection was interrupted."
      end

      retries += 1
      if retries <= MAX_RETRIES
        Rails.logger.warn("[External::Client] Transient error (attempt #{retries}/#{MAX_RETRIES}): #{e.class} - #{e.message}")
        sleep(RETRY_DELAY * retries)
        retry
      end
      Rails.logger.error("[External::Client] Unreachable after #{MAX_RETRIES + 1} attempts: #{e.class} - #{e.message}")
      raise Assistant::Error, "External assistant is temporarily unavailable."
    end
  end

  private

    def stream_response(http, request, &block)
      model = nil
      buffer = +""

View on GitHub (pinned to e69894adb9)

Solutions

  1. Raise idle/read timeouts on every proxy between the app and the upstream above the client's 120s read_timeout (e.g. nginx proxy_read_timeout 300s), and disable SSE buffering (proxy_buffering off or X-Accel-Buffering: no).
  2. Reproduce with curl -N -H 'Accept: text/event-stream' against the same URL/token to confirm where the stream drops.
  3. In the caller, rescue Assistant::Error matching 'interrupted' and restart the whole request once with a fresh buffer (chat has no resume; partial chunks must be discarded, not appended to).
  4. Check upstream logs for restarts/OOM kills that abort long-running streamed requests.

Example fix

# before
buffer = +""
client.chat(messages: messages) { |chunk| buffer << chunk }

# after — discard partial output and restart the turn once on interruption
buffer = +""
begin
  attempts = (attempts || 0) + 1
  client.chat(messages: messages) { |chunk| buffer << chunk }
rescue Assistant::Error => e
  raise if e.message.exclude?("interrupted") || attempts > 1
  buffer.clear
  retry
end
Defensive patterns

Strategy: try-catch

Validate before calling

# Optional preflight: fail fast if the endpoint is dead before streaming
uri = URI(external_assistant_url)
Net::HTTP.start(uri.host, uri.port, use_ssl: uri.scheme == "https", open_timeout: 5) do |http|
  http.head(uri.request_uri)
end

Try / catch

buffer = +""
begin
  client.chat(messages: messages) { |chunk| buffer << chunk }
rescue Assistant::Error => e
  raise unless e.message.include?("interrupted")
  # chunks already streamed: discard partial output, restart the turn at most once
  buffer.clear
  (retries = (retries || 0) + 1) == 1 ? retry : raise
end

Prevention

When it happens

Trigger: Calling client.chat(messages:, user:) against an OpenAI-compatible SSE endpoint where the first chunks arrive, then read_body dies mid-stream: a Net::ReadTimeout after 120 idle seconds, an ECONNRESET from a proxy/load balancer that kills the connection, or an upstream restart dropping the socket. streaming_started is true, so the rescue at app/models/assistant/external/client.rb:48-52 raises this instead of retrying.

Common situations: Self-hosted agent behind nginx/ALB whose proxy_read_timeout or idle timeout is shorter than generation time; SSE buffering enabled on the proxy so chunks queue and idle timers fire; upstream deploys that recycle workers mid-request; flaky VPN/tailscale link between app and agent.

Related errors


AI-assisted analysis of we-promise/sure@e69894adb9 (2026-08-21). Data as JSON: /api/errors/06eb81e80f9b2940. Report an issue: GitHub.