From a5391e8e2548d73c3ae009c922dc8d5647a5f5fb Mon Sep 17 00:00:00 2001 From: Chili Palmer Date: Thu, 11 Jun 2026 16:18:09 +0200 Subject: [PATCH] fix: implement TD-02. --- TECHDEBTS.md | 14 +- lib/bds/ai/http_client.ex | 120 +++++++++------ lib/bds/desktop/automation.ex | 17 +-- mix.exs | 3 +- mix.lock | 4 + test/bds/ai/http_client_test.exs | 144 ++++++++++++++++++ .../bds/ai/openai_compatible_runtime_test.exs | 70 +++++++++ 7 files changed, 315 insertions(+), 57 deletions(-) create mode 100644 test/bds/ai/http_client_test.exs create mode 100644 test/bds/ai/openai_compatible_runtime_test.exs diff --git a/TECHDEBTS.md b/TECHDEBTS.md index 337ff4a..bf0f332 100644 --- a/TECHDEBTS.md +++ b/TECHDEBTS.md @@ -82,10 +82,22 @@ for `BDS.Desktop.Endpoint`. --- -### TD-02: Replace the hand-rolled `:httpc` client with Req +### TD-02: Replace the hand-rolled `:httpc` client with Req ✅ DONE (2026-06-11) **Severity: High (reliability), enables TD-06 (streaming).** +**Status: implemented.** `BDS.AI.HttpClient` is a Req wrapper with explicit +connect/receive timeouts and constant-delay transient retries for GETs only +(POST completions are never retried), configurable under +`config :bds, BDS.AI.HttpClient` (`:connect_timeout_ms`, +`:receive_timeout_ms`, `:get_max_retries`, `:retry_delay_ms`). The legacy +`{:ok, %{status, headers, body}}` contract is preserved (raw binary body, +downcased single-valued headers), so runtime/catalog callers are unchanged. +The automation driver's health check also moved to Req, `:inets` left +`extra_applications`, and no `:httpc` remains under `lib/` +(test files still use `:httpc` as a client against local servers — they start +`:inets` themselves). TD-06 (SSE streaming via Req `into:`) is now unblocked. + **Context.** `BDS.AI.HttpClient` wraps `:httpc` with empty http options — the default timeout is **infinity**, so a hung LLM endpoint (Ollama, LM Studio, any OpenAI-compatible server) blocks the chat task forever (see TD-03 for the diff --git a/lib/bds/ai/http_client.ex b/lib/bds/ai/http_client.ex index 67ca673..7f46dc6 100644 --- a/lib/bds/ai/http_client.ex +++ b/lib/bds/ai/http_client.ex @@ -1,58 +1,86 @@ defmodule BDS.AI.HttpClient do - @moduledoc false + @moduledoc """ + Req-based HTTP client for AI endpoints. + Replaces the previous `:httpc` wrapper with explicit connect/receive + timeouts, TLS verification via Req's defaults, and transient retries for + idempotent GETs only — POSTs (chat completions) are never retried. + + The response contract is unchanged: `{:ok, %{status, headers, body}}` with + downcased single-valued header names and the body as a raw binary (callers + decode JSON themselves), or `{:error, reason}` where transport failures + surface as plain reason atoms such as `:timeout` or `:econnrefused`. + + Config (`config :bds, BDS.AI.HttpClient`): + + * `:connect_timeout_ms` — TCP/TLS connect budget (default 5_000) + * `:receive_timeout_ms` — response budget (default 120_000; generous + because local LLM completions are slow) + * `:get_max_retries` — transient retries for GETs (default 2) + * `:retry_delay_ms` — constant delay between retries (default 500) + """ + + @default_connect_timeout_ms 5_000 + @default_receive_timeout_ms 120_000 + @default_get_max_retries 2 + @default_retry_delay_ms 500 + + @spec get(String.t(), %{String.t() => String.t()}) :: + {:ok, %{status: non_neg_integer(), headers: map(), body: binary()}} | {:error, term()} def get(url, headers) when is_binary(url) and is_map(headers) do - request = - {String.to_charlist(url), - Enum.map(headers, fn {key, value} -> - {String.to_charlist(key), String.to_charlist(value)} - end)} - - :inets.start() - :ssl.start() - - case :httpc.request(:get, request, [], body_format: :binary) do - {:ok, {{_version, status, _reason}, response_headers, body}} -> - {:ok, - %{ - status: status, - headers: normalize_headers(response_headers), - body: body - }} - - {:error, reason} -> - {:error, reason} - end + [ + method: :get, + url: url, + headers: headers, + retry: :transient, + max_retries: config(:get_max_retries, @default_get_max_retries), + retry_delay: fn _retry_count -> config(:retry_delay_ms, @default_retry_delay_ms) end, + retry_log_level: false + ] + |> Keyword.merge(base_options()) + |> Req.request() + |> normalize_result() end + @spec post(String.t(), %{String.t() => String.t()}, binary()) :: + {:ok, %{status: non_neg_integer(), headers: map(), body: binary()}} | {:error, term()} def post(url, headers, body) when is_binary(url) and is_map(headers) and is_binary(body) do - request = - {String.to_charlist(url), - Enum.map(headers, fn {key, value} -> - {String.to_charlist(key), String.to_charlist(value)} - end), ~c"application/json", body} - - :inets.start() - :ssl.start() - - case :httpc.request(:post, request, [], body_format: :binary) do - {:ok, {{_version, status, _reason}, response_headers, response_body}} -> - {:ok, - %{ - status: status, - headers: normalize_headers(response_headers), - body: response_body - }} - - {:error, reason} -> - {:error, reason} - end + [ + method: :post, + url: url, + headers: headers, + body: body, + # Completions are not idempotent; a retry could bill or generate twice. + retry: false + ] + |> Keyword.merge(base_options()) + |> Req.request() + |> normalize_result() end + defp base_options do + [ + connect_options: [timeout: config(:connect_timeout_ms, @default_connect_timeout_ms)], + receive_timeout: config(:receive_timeout_ms, @default_receive_timeout_ms), + # Callers parse the body themselves; keep it a raw binary. + decode_body: false + ] + end + + defp normalize_result({:ok, %Req.Response{status: status, headers: headers, body: body}}) do + {:ok, %{status: status, headers: normalize_headers(headers), body: body}} + end + + defp normalize_result({:error, %Req.TransportError{reason: reason}}), do: {:error, reason} + defp normalize_result({:error, reason}), do: {:error, reason} + + # Req header names are already downcased; values arrive as lists. defp normalize_headers(headers) do - Enum.into(headers, %{}, fn {key, value} -> - {key |> to_string() |> String.downcase(), to_string(value)} - end) + Map.new(headers, fn {name, values} -> {name, Enum.join(List.wrap(values), ", ")} end) + end + + defp config(key, default) do + Application.get_env(:bds, __MODULE__, []) |> Keyword.get(key, default) end end diff --git a/lib/bds/desktop/automation.ex b/lib/bds/desktop/automation.ex index 24a689d..1d0644f 100644 --- a/lib/bds/desktop/automation.ex +++ b/lib/bds/desktop/automation.ex @@ -58,7 +58,6 @@ defmodule BDS.Desktop.Automation do base_url = BDS.Desktop.url(port) File.mkdir_p!(screenshot_dir) - ensure_http_client_started() app_port = start_app_process(project_root, port) :ok = wait_for_server(base_url) @@ -319,8 +318,14 @@ defmodule BDS.Desktop.Automation do end defp do_wait_for_server(base_url, deadline) do - case :httpc.request(:get, {String.to_charlist(base_url <> "health"), []}, [], []) do - {:ok, {{_, 200, _}, _headers, _body}} -> + case Req.request( + method: :get, + url: base_url <> "health", + retry: false, + connect_options: [timeout: 1_000], + receive_timeout: 1_000 + ) do + {:ok, %Req.Response{status: 200}} -> :ok _other -> @@ -340,12 +345,6 @@ defmodule BDS.Desktop.Automation do port end - defp ensure_http_client_started do - _ = Application.ensure_all_started(:inets) - _ = Application.ensure_all_started(:ssl) - :ok - end - defp await_port_exit(nil, _timeout), do: :ok defp await_port_exit(port, timeout) do diff --git a/mix.exs b/mix.exs index c5f8abc..dc0bd35 100644 --- a/mix.exs +++ b/mix.exs @@ -17,7 +17,7 @@ defmodule BDS.MixProject do def application do [ - extra_applications: [:logger, :wx, :inets, :ssl], + extra_applications: [:logger, :wx, :ssl], mod: {BDS.Application, []} ] end @@ -32,6 +32,7 @@ defmodule BDS.MixProject do {:liquex, "~> 0.13.1"}, {:plug, "~> 1.18"}, {:bandit, "~> 1.5"}, + {:req, "~> 0.5"}, {:desktop, "~> 1.5"}, {:image, "~> 0.67"}, {:nx, "~> 0.10"}, diff --git a/mix.lock b/mix.lock index 9506351..9869077 100644 --- a/mix.lock +++ b/mix.lock @@ -27,6 +27,7 @@ "exla": {:hex, :exla, "0.10.0", "93e7d75a774fbc06ce05b96de20c4b01bda413b315238cb3c727c09a05d2bc3a", [:make, :mix], [{:elixir_make, "~> 0.6", [hex: :elixir_make, repo: "hexpm", optional: false]}, {:fine, "~> 0.1.0", [hex: :fine, repo: "hexpm", optional: false]}, {:nimble_pool, "~> 1.0", [hex: :nimble_pool, repo: "hexpm", optional: false]}, {:nx, "~> 0.10.0", [hex: :nx, repo: "hexpm", optional: false]}, {:telemetry, "~> 0.4.0 or ~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}, {:xla, "~> 0.9.0", [hex: :xla, repo: "hexpm", optional: false]}], "hexpm", "16fffdb64667d7f0a3bc683fdcd2792b143a9b345e4b1f1d5cd50330c63d8119"}, "expo": {:hex, :expo, "1.1.1", "4202e1d2ca6e2b3b63e02f69cfe0a404f77702b041d02b58597c00992b601db5", [:mix], [], "hexpm", "5fb308b9cb359ae200b7e23d37c76978673aa1b06e2b3075d814ce12c5811640"}, "exqlite": {:hex, :exqlite, "0.36.0", "07b4f95d61cb82b8d52946d0639497fa7d32117e09b2c8d25e24a38723c295cb", [:make, :mix], [{:cc_precompiler, "~> 0.1", [hex: :cc_precompiler, repo: "hexpm", optional: false]}, {:db_connection, "~> 2.1", [hex: :db_connection, repo: "hexpm", optional: false]}, {:elixir_make, "~> 0.8", [hex: :elixir_make, repo: "hexpm", optional: false]}, {:table, "~> 0.1.0", [hex: :table, repo: "hexpm", optional: true]}], "hexpm", "cbeca3ce781f9ff07cfa9a87486f3ebd512a143ad6a14ed5c9fca21fe0bf3ae7"}, + "finch": {:hex, :finch, "0.22.0", "5c48fa6f9706a78eb9036cacb67b8b996b4e66d111c543f4c29bb0f879a6806b", [:mix], [{:mime, "~> 1.0 or ~> 2.0", [hex: :mime, repo: "hexpm", optional: false]}, {:mint, "~> 1.8", [hex: :mint, repo: "hexpm", optional: false]}, {:nimble_options, "~> 0.4 or ~> 1.0", [hex: :nimble_options, repo: "hexpm", optional: false]}, {:nimble_pool, "~> 1.1", [hex: :nimble_pool, repo: "hexpm", optional: false]}, {:telemetry, "~> 0.4 or ~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}], "hexpm", "b94e83c47780fc6813f746a1f1a34ee65cda42da4c5ea26a68f0acc4498e23dc"}, "fine": {:hex, :fine, "0.1.6", "4bf7151493443c454aac9f2fa2f34f5fefd0346a83fb5586a016c4a135c63247", [:mix], [], "hexpm", "5638eb4495488e885ebec167fa57973e5c35e1a50c344eb7666c90ec1c4e3b12"}, "gettext": {:hex, :gettext, "0.26.2", "5978aa7b21fada6deabf1f6341ddba50bc69c999e812211903b169799208f2a8", [:mix], [{:expo, "~> 0.5.1 or ~> 1.0", [hex: :expo, repo: "hexpm", optional: false]}], "hexpm", "aa978504bcf76511efdc22d580ba08e2279caab1066b76bb9aa81c4a1e0a32a5"}, "hnswlib": {:hex, :hnswlib, "0.1.7", "784afdbfbc9af53e64d4b6da3f685c07039e472636a98fa954ffae5292ad6cc4", [:make, :mix], [{:cc_precompiler, "~> 0.1.0", [hex: :cc_precompiler, repo: "hexpm", optional: false]}, {:elixir_make, "~> 0.8", [hex: :elixir_make, repo: "hexpm", optional: false]}, {:nx, "~> 0.5", [hex: :nx, repo: "hexpm", optional: false]}], "hexpm", "fb43bb675facc8bb1ef0f4f8fec92479fc23317ed0f35c7160b2f95aff3e4742"}, @@ -40,7 +41,9 @@ "liquex": {:hex, :liquex, "0.13.1", "49f90d0b85fb2908f2558f35cd49d78497fe77a895eb55b360889940e1d7afb9", [:mix], [{:date_time_parser, "~> 1.2", [hex: :date_time_parser, repo: "hexpm", optional: false]}, {:html_entities, "~> 0.5.2", [hex: :html_entities, repo: "hexpm", optional: false]}, {:html_sanitize_ex, "~> 1.4.3", [hex: :html_sanitize_ex, repo: "hexpm", optional: false]}, {:nimble_parsec, "~> 1.4", [hex: :nimble_parsec, repo: "hexpm", optional: false]}], "hexpm", "fbea5b9db264c1758a69bfafdcc8aaebcd56e168365bb9575392cd55d800108f"}, "luerl": {:hex, :luerl, "1.5.1", "f6700420950fc6889137e7a0c11c4a8467dea04a8c23f707a40d83566d14e786", [:rebar3], [], "hexpm", "abf88d849baa0d5dca93b245a8688d4de2ee3d588159bb2faf51e15946509390"}, "mime": {:hex, :mime, "2.0.7", "b8d739037be7cd402aee1ba0306edfdef982687ee7e9859bee6198c1e7e2f128", [:mix], [], "hexpm", "6171188e399ee16023ffc5b76ce445eb6d9672e2e241d2df6050f3c771e80ccd"}, + "mint": {:hex, :mint, "1.9.0", "d6f534c2a3e98b2a8cc749b4796eb77e9e3af79a76f96e4c74035a827de0d318", [:mix], [{:castore, "~> 0.1.0 or ~> 1.0", [hex: :castore, repo: "hexpm", optional: true]}, {:hpax, "~> 0.1.1 or ~> 0.2.0 or ~> 1.0", [hex: :hpax, repo: "hexpm", optional: false]}], "hexpm", "007154c7d8c43916aed3c93afd1f11aebbaa9c5ff4b7ba55ebe0d17ee0296042"}, "mochiweb": {:hex, :mochiweb, "3.3.0", "2898ad0bfeee234e4cbae623c7052abc3ff0d73d499ba6e6ffef445b13ffd07a", [:rebar3], [], "hexpm", "aa85b777fb23e9972ebc424e40b5d35106f19bc998873e026dedd876df8ee50c"}, + "nimble_options": {:hex, :nimble_options, "1.1.1", "e3a492d54d85fc3fd7c5baf411d9d2852922f66e69476317787a7b2bb000a61b", [:mix], [], "hexpm", "821b2470ca9442c4b6984882fe9bb0389371b8ddec4d45a9504f00a66f650b44"}, "nimble_parsec": {:hex, :nimble_parsec, "1.4.2", "8efba0122db06df95bfaa78f791344a89352ba04baedd3849593bfce4d0dc1c6", [:mix], [], "hexpm", "4b21398942dda052b403bbe1da991ccd03a053668d147d53fb8c4e0efe09c973"}, "nimble_pool": {:hex, :nimble_pool, "1.1.0", "bf9c29fbdcba3564a8b800d1eeb5a3c58f36e1e11d7b7fb2e084a643f645f06b", [:mix], [], "hexpm", "af2e4e6b34197db81f7aad230c1118eac993acc0dae6bc83bac0126d4ae0813a"}, "nx": {:hex, :nx, "0.10.0", "128e4a094cb790f663e20e1334b127c1f2a4df54edfb8b13c22757ec33133b4f", [:mix], [{:complex, "~> 0.6", [hex: :complex, repo: "hexpm", optional: false]}, {:telemetry, "~> 0.4.0 or ~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}], "hexpm", "3db8892c124aeee091df0e6fbf8e5bf1b81f502eb0d4f5ba63e6378ebcae7da4"}, @@ -56,6 +59,7 @@ "plug_crypto": {:hex, :plug_crypto, "2.1.1", "19bda8184399cb24afa10be734f84a16ea0a2bc65054e23a62bb10f06bc89491", [:mix], [], "hexpm", "6470bce6ffe41c8bd497612ffde1a7e4af67f36a15eea5f921af71cf3e11247c"}, "polaris": {:hex, :polaris, "0.1.0", "dca61b18e3e801ecdae6ac9f0eca5f19792b44a5cb4b8d63db50fc40fc038d22", [:mix], [{:nx, "~> 0.5", [hex: :nx, repo: "hexpm", optional: false]}], "hexpm", "13ef2b166650e533cb24b10e2f3b8ab4f2f449ba4d63156e8c569527f206e2c2"}, "progress_bar": {:hex, :progress_bar, "3.0.0", "f54ff038c2ac540cfbb4c2bfe97c75e7116ead044f3c2b10c9f212452194b5cd", [:mix], [{:decimal, "~> 2.0", [hex: :decimal, repo: "hexpm", optional: false]}], "hexpm", "6981c2b25ab24aecc91a2dc46623658e1399c21a2ae24db986b90d678530f2b7"}, + "req": {:hex, :req, "0.6.1", "7b904c8b42d0e08136a5c6aba024fd12fc79a1ed8856e7a3522b0917f7e75113", [:mix], [{:brotli, "~> 0.3.1", [hex: :brotli, repo: "hexpm", optional: true]}, {:ezstd, "~> 1.0", [hex: :ezstd, repo: "hexpm", optional: true]}, {:finch, "~> 0.21.0 or ~> 0.22.0", [hex: :finch, repo: "hexpm", optional: false]}, {:jason, "~> 1.0", [hex: :jason, repo: "hexpm", optional: false]}, {:mime, "~> 2.0.6 or ~> 2.1", [hex: :mime, repo: "hexpm", optional: false]}, {:nimble_csv, "~> 1.0", [hex: :nimble_csv, repo: "hexpm", optional: true]}, {:plug, "~> 1.0", [hex: :plug, repo: "hexpm", optional: true]}], "hexpm", "aaf11c9c80f2df2364630b3594e1857fe610d8ea7cb994e1ce3dcb55f204ff1c"}, "rustler": {:hex, :rustler, "0.32.1", "f4cf5a39f9e85d182c0a3f75fa15b5d0add6542ab0bf9ceac6b4023109ebd3fc", [:mix], [{:jason, "~> 1.0", [hex: :jason, repo: "hexpm", optional: false]}, {:toml, "~> 0.6", [hex: :toml, repo: "hexpm", optional: false]}], "hexpm", "b96be75526784f86f6587f051bc8d6f4eaff23d6e0f88dbcfe4d5871f52946f7"}, "rustler_precompiled": {:hex, :rustler_precompiled, "0.9.0", "3a052eda09f3d2436364645cc1f13279cf95db310eb0c17b0d8f25484b233aa0", [:mix], [{:rustler, "~> 0.23", [hex: :rustler, repo: "hexpm", optional: true]}], "hexpm", "471d97315bd3bf7b64623418b3693eedd8e47de3d1cb79a0ac8f9da7d770d94c"}, "safetensors": {:hex, :safetensors, "0.1.3", "7ff3c22391e213289c713898481d492c9c28a49ab1d0705b72630fb8360426b2", [:mix], [{:jason, "~> 1.4", [hex: :jason, repo: "hexpm", optional: false]}, {:nx, "~> 0.5", [hex: :nx, repo: "hexpm", optional: false]}], "hexpm", "fe50b53ea59fde4e723dd1a2e31cfdc6013e69343afac84c6be86d6d7c562c14"}, diff --git a/test/bds/ai/http_client_test.exs b/test/bds/ai/http_client_test.exs new file mode 100644 index 0000000..e88802b --- /dev/null +++ b/test/bds/ai/http_client_test.exs @@ -0,0 +1,144 @@ +defmodule BDS.AI.HttpClientTest do + use ExUnit.Case, async: false + + alias BDS.AI.HttpClient + + defmodule TestPlug do + import Plug.Conn + + def init(opts), do: opts + + def call(%Plug.Conn{path_info: ["ok"]} = conn, _opts) do + conn + |> put_resp_header("x-test-header", "Value-1") + |> put_resp_content_type("application/json") + |> send_resp(200, ~s({"hello":"world"})) + end + + def call(%Plug.Conn{path_info: ["echo"], method: "POST"} = conn, _opts) do + {:ok, body, conn} = read_body(conn) + send(test_pid(), {:echo_request, conn.req_headers, body}) + send_resp(conn, 200, body) + end + + def call(%Plug.Conn{path_info: ["slow"]} = conn, _opts) do + Process.sleep(1_000) + send_resp(conn, 200, "late") + end + + def call(%Plug.Conn{path_info: ["flaky-get"]} = conn, _opts) do + send(test_pid(), :flaky_hit) + + if :ets.update_counter(:bds_http_client_test, :flaky_get, 1) == 1 do + send_resp(conn, 500, "boom") + else + send_resp(conn, 200, "recovered") + end + end + + def call(%Plug.Conn{path_info: ["fail-post"], method: "POST"} = conn, _opts) do + send(test_pid(), :post_hit) + send_resp(conn, 500, "boom") + end + + defp test_pid, do: Application.get_env(:bds, :http_client_test_pid) + end + + setup do + if :ets.whereis(:bds_http_client_test) == :undefined do + :ets.new(:bds_http_client_test, [:named_table, :public, :set]) + end + + :ets.insert(:bds_http_client_test, {:flaky_get, 0}) + Application.put_env(:bds, :http_client_test_pid, self()) + + original_config = Application.fetch_env(:bds, HttpClient) + + # Fast retries so retry tests don't slow the suite down. + Application.put_env(:bds, HttpClient, retry_delay_ms: 10) + + on_exit(fn -> + case original_config do + {:ok, value} -> Application.put_env(:bds, HttpClient, value) + :error -> Application.delete_env(:bds, HttpClient) + end + end) + + server = start_supervised!({Bandit, plug: TestPlug, port: 0, startup_log: false}) + {:ok, {_address, port}} = ThousandIsland.listener_info(server) + + {:ok, base_url: "http://127.0.0.1:#{port}"} + end + + defp put_config(extra) do + Application.put_env(:bds, HttpClient, Keyword.merge([retry_delay_ms: 10], extra)) + end + + test "GET returns status, body, and downcased single-valued headers", %{base_url: base_url} do + assert {:ok, response} = HttpClient.get(base_url <> "/ok", %{"accept" => "application/json"}) + + assert response.status == 200 + assert response.body == ~s({"hello":"world"}) + assert response.headers["x-test-header"] == "Value-1" + assert response.headers["content-type"] =~ "application/json" + refute Map.has_key?(response.headers, "X-Test-Header") + end + + test "POST sends the raw body and headers and returns the raw response body", %{ + base_url: base_url + } do + payload = ~s({"model":"gpt-test"}) + + assert {:ok, response} = + HttpClient.post( + base_url <> "/echo", + %{"content-type" => "application/json", "authorization" => "Bearer sk-123"}, + payload + ) + + assert response.status == 200 + assert response.body == payload + + assert_received {:echo_request, req_headers, ^payload} + assert {"authorization", "Bearer sk-123"} in req_headers + assert {"content-type", "application/json"} in req_headers + end + + test "a hung endpoint times out within the configured budget", %{base_url: base_url} do + put_config(receive_timeout_ms: 100, get_max_retries: 0) + + started_at = System.monotonic_time(:millisecond) + assert {:error, :timeout} = HttpClient.post(base_url <> "/slow", %{}, "{}") + assert {:error, :timeout} = HttpClient.get(base_url <> "/slow", %{}) + elapsed = System.monotonic_time(:millisecond) - started_at + + assert elapsed < 2_000 + end + + test "a refused connection returns an error instead of raising" do + put_config(get_max_retries: 0) + + # An ephemeral port nothing listens on. + {:ok, socket} = :gen_tcp.listen(0, []) + {:ok, port} = :inet.port(socket) + :ok = :gen_tcp.close(socket) + + assert {:error, :econnrefused} = HttpClient.get("http://127.0.0.1:#{port}/ok", %{}) + end + + test "GET retries transient server errors", %{base_url: base_url} do + assert {:ok, %{status: 200, body: "recovered"}} = + HttpClient.get(base_url <> "/flaky-get", %{}) + + assert_received :flaky_hit + assert_received :flaky_hit + end + + test "POST is never retried, even on transient server errors", %{base_url: base_url} do + assert {:ok, %{status: 500, body: "boom"}} = + HttpClient.post(base_url <> "/fail-post", %{}, "{}") + + assert_received :post_hit + refute_received :post_hit + end +end diff --git a/test/bds/ai/openai_compatible_runtime_test.exs b/test/bds/ai/openai_compatible_runtime_test.exs new file mode 100644 index 0000000..f0b8698 --- /dev/null +++ b/test/bds/ai/openai_compatible_runtime_test.exs @@ -0,0 +1,70 @@ +defmodule BDS.AI.OpenAICompatibleRuntimeTest do + use ExUnit.Case, async: false + + alias BDS.AI.HttpClient + alias BDS.AI.OpenAICompatibleRuntime + + defmodule SlowPlug do + import Plug.Conn + + def init(opts), do: opts + + def call(conn, _opts) do + Process.sleep(1_000) + send_resp(conn, 200, ~s({"choices":[],"data":[]})) + end + end + + setup do + :ok = Ecto.Adapters.SQL.Sandbox.checkout(BDS.Repo) + + original_config = Application.fetch_env(:bds, HttpClient) + + Application.put_env(:bds, HttpClient, + receive_timeout_ms: 100, + get_max_retries: 0, + retry_delay_ms: 10 + ) + + on_exit(fn -> + case original_config do + {:ok, value} -> Application.put_env(:bds, HttpClient, value) + :error -> Application.delete_env(:bds, HttpClient) + end + end) + + server = start_supervised!({Bandit, plug: SlowPlug, port: 0, startup_log: false}) + {:ok, {_address, port}} = ThousandIsland.listener_info(server) + + {:ok, url: "http://127.0.0.1:#{port}/v1"} + end + + test "generate returns a structured timeout error within the configured budget", %{url: url} do + endpoint = %{url: url, api_key: "sk-test"} + + request = %{ + operation: :chat, + model: "gpt-test", + max_output_tokens: 16, + messages: [%{"role" => "user", "content" => "hello"}] + } + + started_at = System.monotonic_time(:millisecond) + + assert {:error, %{kind: :http_error, reason: :timeout}} = + OpenAICompatibleRuntime.generate(endpoint, request, []) + + assert System.monotonic_time(:millisecond) - started_at < 2_000 + end + + test "list_models returns a structured timeout error within the configured budget", %{url: url} do + endpoint = %{url: url, api_key: "sk-test"} + + started_at = System.monotonic_time(:millisecond) + + assert {:error, %{kind: :http_error, reason: :timeout}} = + OpenAICompatibleRuntime.list_models(endpoint) + + assert System.monotonic_time(:millisecond) - started_at < 2_000 + end +end