fix: implement TD-02.
This commit is contained in:
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user