{"record":{"id":"6cdbb7cb8baff78d","repo":"we-promise/sure","slug":"external-assistant-returned-http-response-code","errorCode":null,"errorMessage":"External assistant returned HTTP #{response.code}.","messagePattern":"External assistant returned HTTP #(.+?)\\.","errorType":"exception","errorClass":"Assistant::Error","httpStatus":null,"severity":"error","filePath":"app/models/assistant/external/client.rb","lineNumber":75,"sourceCode":"        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 = +\"\"\n      done = false\n\n      http.request(request) do |response|\n        unless response.is_a?(Net::HTTPSuccess)\n          Rails.logger.warn(\"[External::Client] Upstream HTTP #{response.code}: #{response.body.to_s.truncate(500)}\")\n          raise Assistant::Error, \"External assistant returned HTTP #{response.code}.\"\n        end\n\n        response.read_body do |chunk|\n          break if done\n          buffer << chunk\n\n          if buffer.bytesize > MAX_SSE_BUFFER\n            raise Assistant::Error, \"External assistant stream exceeded maximum buffer size.\"\n          end\n\n          while (line_end = buffer.index(\"\\n\"))\n            line = buffer.slice!(0..line_end).strip\n            next if line.empty?\n            next unless line.start_with?(\"data:\")\n\n            data = line.delete_prefix(\"data:\")\n            data = data.delete_prefix(\" \") # SSE spec: strip one optional leading space\n","sourceCodeStart":57,"sourceCodeEnd":93,"githubUrl":"https://github.com/we-promise/sure/blob/e69894adb92547273377398c15f45c979cd9416a/app/models/assistant/external/client.rb#L57-L93","documentation":"Raised by Assistant::External::Client#stream_response when the upstream OpenAI-compatible endpoint answers with any non-2xx status. The response code is interpolated into the message and the first 500 bytes of the body are logged as a warning before the raise. Assistant::Error is not in TRANSIENT_ERRORS, so this is never retried.","triggerScenarios":"POST to the configured URL returns 401 (wrong/missing Bearer token passed to Client.new(url:, token:)), 404 (wrong URL path or model/@agent_id), 400 (malformed messages payload), 429 (rate limited), or 500/502/503 (upstream crash or maintenance). Any of these hits the `unless response.is_a?(Net::HTTPSuccess)` branch at app/models/assistant/external/client.rb:73-76.","commonSituations":"Expired or rotated API token in env; pointing the client at a base URL that already includes /v1/chat/completions or misses it; upstream deployed behind a gateway returning 502 during restarts; hitting provider rate limits under load.","solutions":["Read the warn log line '[External::Client] Upstream HTTP <code>: <body>' — the truncated body names the real cause (invalid api key, model not found, quota exceeded).","For 401/403, fix the token supplied to Assistant::External::Client.new (request['Authorization'] = \"Bearer #{@token}\") — re-issue or rotate the credential in env.","For 404, correct the URL or agent id: the client POSTs to uri.request_uri with model: @agent_id, so both must match the upstream's routes.","For 429/503, add caller-side backoff and retry — the client treats these as permanent for this request."],"exampleFix":"# before\nclient = Assistant::External::Client.new(url:, token:)\nmodel = client.chat(messages: msgs) { |c| print c }\n\n# after — surface the upstream status instead of an opaque failure\nbegin\n  model = client.chat(messages: msgs) { |c| print c }\nrescue Assistant::Error => e\n  if (m = e.message.match(/HTTP (\\d{3})/))\n    case m[1]\n    when /40[13]/ then raise \"External assistant auth rejected: check token\"\n    when \"429\", \"503\" then raise \"External assistant busy, retry later\"\n    else raise\n    end\n  else\n    raise\n  end\nend","handlingStrategy":"try-catch","validationCode":"# Fail fast on obviously bad config before spending a request\nraise ArgumentError, \"token missing\" if token.blank?\nuri = URI(url)\nraise ArgumentError, \"bad URL\" unless uri.host.present?","typeGuard":null,"tryCatchPattern":"begin\n  model = client.chat(messages: msgs) { |c| print c }\nrescue Assistant::Error => e\n  code = e.message[/HTTP (\\d{3})/, 1]\n  case code\n  when \"401\", \"403\" then raise \"External assistant rejected credentials\"\n  when \"429\", \"503\" then raise \"External assistant busy — retry later\"\n  else raise\n  end\nend","preventionTips":["Smoke-test the exact URL + Bearer token with curl before deploying config changes.","Alert on the '[External::Client] Upstream HTTP' warn log — it carries the status and body snippet needed to diagnose 401/404/429 immediately.","Treat 429/503 as retryable at the caller; the client itself never retries HTTP-status failures."],"tags":["http","assistant","api-status","upstream","net-http"],"backgroundTag":"upstream-http-error","analyzedSha":"e69894adb92547273377398c15f45c979cd9416a","analyzedAt":"2026-08-21T18:22:41.165Z","schemaVersion":2},"datasetVersion":"2026-08-21T23:17:16.201Z"}