{"record":{"id":"06eb81e80f9b2940","repo":"we-promise/sure","slug":"external-assistant-connection-was-interrupted","errorCode":null,"errorMessage":"External assistant connection was interrupted.","messagePattern":"External assistant connection was interrupted\\.","errorType":"exception","errorClass":"Assistant::Error","httpStatus":null,"severity":"error","filePath":"app/models/assistant/external/client.rb","lineNumber":51,"sourceCode":"  #\n  # Returns the model identifier string from the response.\n  def chat(messages:, user: nil, &block)\n    uri = URI(@url)\n    request = build_request(uri, messages, user)\n    retries = 0\n    streaming_started = false\n\n    begin\n      http = build_http(uri)\n      model = stream_response(http, request) do |content|\n        streaming_started = true\n        block.call(content)\n      end\n      model\n    rescue *TRANSIENT_ERRORS => e\n      if streaming_started\n        Rails.logger.warn(\"[External::Client] Stream interrupted: #{e.class} - #{e.message}\")\n        raise Assistant::Error, \"External assistant connection was interrupted.\"\n      end\n\n      retries += 1\n      if retries <= MAX_RETRIES\n        Rails.logger.warn(\"[External::Client] Transient error (attempt #{retries}/#{MAX_RETRIES}): #{e.class} - #{e.message}\")\n        sleep(RETRY_DELAY * retries)\n        retry\n      end\n      Rails.logger.error(\"[External::Client] Unreachable after #{MAX_RETRIES + 1} attempts: #{e.class} - #{e.message}\")\n      raise Assistant::Error, \"External assistant is temporarily unavailable.\"\n    end\n  end\n\n  private\n\n    def stream_response(http, request, &block)\n      model = nil\n      buffer = +\"\"","sourceCodeStart":33,"sourceCodeEnd":69,"githubUrl":"https://github.com/we-promise/sure/blob/e69894adb92547273377398c15f45c979cd9416a/app/models/assistant/external/client.rb#L33-L69","documentation":"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.","triggerScenarios":"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.","commonSituations":"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.","solutions":["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).","Reproduce with curl -N -H 'Accept: text/event-stream' against the same URL/token to confirm where the stream drops.","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).","Check upstream logs for restarts/OOM kills that abort long-running streamed requests."],"exampleFix":"# before\nbuffer = +\"\"\nclient.chat(messages: messages) { |chunk| buffer << chunk }\n\n# after — discard partial output and restart the turn once on interruption\nbuffer = +\"\"\nbegin\n  attempts = (attempts || 0) + 1\n  client.chat(messages: messages) { |chunk| buffer << chunk }\nrescue Assistant::Error => e\n  raise if e.message.exclude?(\"interrupted\") || attempts > 1\n  buffer.clear\n  retry\nend","handlingStrategy":"try-catch","validationCode":"# Optional preflight: fail fast if the endpoint is dead before streaming\nuri = URI(external_assistant_url)\nNet::HTTP.start(uri.host, uri.port, use_ssl: uri.scheme == \"https\", open_timeout: 5) do |http|\n  http.head(uri.request_uri)\nend","typeGuard":null,"tryCatchPattern":"buffer = +\"\"\nbegin\n  client.chat(messages: messages) { |chunk| buffer << chunk }\nrescue Assistant::Error => e\n  raise unless e.message.include?(\"interrupted\")\n  # chunks already streamed: discard partial output, restart the turn at most once\n  buffer.clear\n  (retries = (retries || 0) + 1) == 1 ? retry : raise\nend","preventionTips":["Keep proxy/LB idle timeouts (nginx proxy_read_timeout, ALB idle timeout) above the client's 120s read_timeout and disable SSE buffering so chunks flow continuously.","Never append retried output onto a partially-filled buffer — the client cannot resume a stream, only restart it.","Health-check the external agent and alert on interruption rate spikes to catch flaky upstream links early."],"tags":["network","streaming","sse","assistant","net-http"],"backgroundTag":"connection-reset","analyzedSha":"e69894adb92547273377398c15f45c979cd9416a","analyzedAt":"2026-08-21T18:22:41.165Z","schemaVersion":2},"datasetVersion":"2026-08-21T23:17:16.201Z"}