Current section

Files

Jump to
nous lib nous http backend hackney.ex
Raw

lib/nous/http/backend/hackney.ex

defmodule Nous.HTTP.Backend.Hackney do
@moduledoc """
`Nous.HTTP.Backend` implementation backed by `:hackney`.
Uses `:hackney.request/5` synchronously — hackney 4 returns the full
response body inline as `{:ok, status, headers, body}` (the legacy
`hackney.body/1` follow-up call from hackney 1.x was removed in v4).
Hackney 4 is already in the dependency tree from 0.15.0 (used for
streaming) — this backend lets users consolidate non-streaming HTTP
onto the same library without keeping Finch/Mint in the hot path.
TLS verification mirrors the streaming side: explicit
`verify: :verify_peer` with system CAs from `:public_key.cacerts_get/0`.
Hackney's default is `:verify_none`, which would silently accept
MITM'd connections — do not regress this.
Pool: uses hackney's `:default` pool (50 conns, 2s idle keepalive).
Apps that want isolation can pass `pool: :my_pool` per call after
starting the pool with `:hackney_pool.start_pool/2`.
"""
@behaviour Nous.HTTP.Backend
require Logger
# 3 minutes — LLMs with reasoning/long completions routinely exceed
# the previous 60s default. Per-call `:timeout` opt overrides.
@default_timeout 180_000
@default_connect_timeout 30_000
@impl Nous.HTTP.Backend
def post(url, body, headers, opts \\ [])
when is_binary(url) and is_map(body) and is_list(headers) do
timeout = Keyword.get(opts, :timeout, @default_timeout)
connect_timeout = Keyword.get(opts, :connect_timeout, @default_connect_timeout)
pool = Keyword.get(opts, :pool, :default)
headers = ensure_content_type(headers)
hackney_headers = Enum.map(headers, fn {k, v} -> {to_charlist(k), to_charlist(v)} end)
try do
json_body = JSON.encode!(body)
do_request(url, hackney_headers, json_body, timeout, connect_timeout, pool)
rescue
error ->
Logger.error("Failed to encode request body: #{inspect(error)}")
{:error, %{reason: :json_encode_error, details: error}}
end
end
# Hackney 4 returns the body inline: `{:ok, status, headers, body}`. The
# legacy hackney.body/1 follow-up call from hackney 1.x is gone — the
# `with_body` option is now the default and ignored.
defp do_request(url, headers, body, timeout, connect_timeout, pool) do
hackney_opts = [
{:pool, pool},
{:recv_timeout, timeout},
{:connect_timeout, connect_timeout},
{:ssl_options, [verify: :verify_peer, cacerts: :public_key.cacerts_get()]}
]
case :hackney.request(:post, url, headers, body, hackney_opts) do
{:ok, status, _resp_headers, body_bin} when status in 200..299 ->
{:ok, decode_body(body_bin)}
{:ok, status, resp_headers, body_bin} ->
decoded = decode_body(body_bin)
Logger.warning("HTTP request failed with status #{status}: #{truncate_for_log(decoded)}")
# Headers surfaced for Nous.Errors.RetryInfo. Hackney returns names
# and values as charlists; convert to strings for downstream parsing.
{:error, %{status: status, body: decoded, headers: stringify_headers(resp_headers)}}
{:error, reason} = err ->
Logger.error("Hackney request error: #{inspect(reason)}")
err
end
end
defp stringify_headers(headers) when is_list(headers) do
Enum.map(headers, fn {k, v} -> {to_string(k), to_string(v)} end)
end
defp decode_body(""), do: %{}
defp decode_body(bin) when is_binary(bin) do
case JSON.decode(bin) do
{:ok, value} -> value
{:error, _} -> bin
end
end
defp ensure_content_type(headers) do
has_ct? =
Enum.any?(headers, fn {k, _} -> String.downcase(to_string(k)) == "content-type" end)
if has_ct?, do: headers, else: [{"content-type", "application/json"} | headers]
end
defp truncate_for_log(data) when is_binary(data) do
if byte_size(data) > 500 do
String.slice(data, 0, 500) <> "... (truncated)"
else
data
end
end
defp truncate_for_log(data), do: inspect(data, limit: 500)
end