Files
bDS2/lib/bds/ai/http_client.ex
2026-06-11 16:18:09 +02:00

87 lines
3.2 KiB
Elixir

defmodule BDS.AI.HttpClient do
@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
[
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
[
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
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