From 1640417cb5d842519aaacdfcf25c934d431dfc7c Mon Sep 17 00:00:00 2001 From: Moises Macia Date: Wed, 20 May 2026 21:51:20 +0000 Subject: [PATCH] add req transport to fetch remote feeds --- lib/feed_reader/req_transport.ex | 103 +++++++++++++++++++++++++++++++ lib/feed_reader/transport.ex | 26 ++++++++ 2 files changed, 129 insertions(+) create mode 100644 lib/feed_reader/req_transport.ex create mode 100644 lib/feed_reader/transport.ex diff --git a/lib/feed_reader/req_transport.ex b/lib/feed_reader/req_transport.ex new file mode 100644 index 0000000..e80da03 --- /dev/null +++ b/lib/feed_reader/req_transport.ex @@ -0,0 +1,103 @@ +defmodule FeedReader.ReqTransport do + @moduledoc """ + Req implementation of `FeedReader.Transport`. + + GET responses are returned as raw, lazy chunks. Automatic Req decompression + is disabled so the later download pipeline can enforce decoded-byte limits. + Status 400 and 500 responses and transport failures are retried; redirect + failures and TLS alerts are terminal. The default is five retries after the + initial request and five redirects. + """ + + @behaviour FeedReader.Transport + + @default_headers [ + {"user-agent", "feedex/1"}, + {"accept-encoding", "gzip, deflate, identity"} + ] + + @impl true + @spec request(String.t(), keyword()) :: + {:ok, FeedReader.Transport.response()} | {:error, term()} + def request(url, opts \\ []) + + def request(url, opts) when is_binary(url) do + http_options = Keyword.get(opts, :http_options, []) + + request_options = + http_options + |> Keyword.put_new(:headers, @default_headers) + |> Keyword.put(:into, :self) + |> Keyword.put(:raw, true) + |> Keyword.put(:decode_body, false) + |> Keyword.put(:compressed, false) + |> Keyword.put(:max_redirects, 5) + |> Keyword.put(:max_retries, retry_attempts(opts)) + |> Keyword.put(:retry, &retry?/2) + |> Keyword.put(:retry_delay, retry_delay(opts)) + + case Req.run([url: url], request_options) do + {request, %Req.Response{} = response} -> + handle_response(request, response) + + {_request, error} -> + {:error, error_reason(error)} + end + end + + def request(_url, _opts), do: {:error, :invalid_url} + + defp handle_response(request, response) do + case response.status do + status when status in 200..299 -> + {:ok, + %{ + status: status, + headers: Req.get_headers_list(response), + final_url: URI.to_string(request.url), + body: response.body + }} + + 403 -> + {:error, :forbidden} + + 404 -> + {:error, :not_found} + + 429 -> + {:error, :too_many_requests} + + 500 -> + {:error, :internal_server_error} + + status -> + {:error, {:http_status, status}} + end + end + + defp retry_attempts(opts) do + case Keyword.get(opts, :http_retry_attempts, 5) do + attempts when is_integer(attempts) and attempts >= 0 -> attempts + _ -> 5 + end + end + + defp retry_delay(opts) do + case Keyword.get(opts, :retry_delay, 0) do + delay when is_integer(delay) and delay >= 0 -> fn _ -> delay end + delay when is_function(delay, 1) -> delay + _ -> fn _ -> 0 end + end + end + + defp retry?(_request, %Req.Response{status: status}), do: status in [400, 500] + defp retry?(_request, %Req.TransportError{reason: {:tls_alert, _}}), do: false + defp retry?(_request, %Req.TransportError{}), do: true + defp retry?(_request, %Req.TooManyRedirectsError{}), do: false + defp retry?(_request, _error), do: false + + defp error_reason(%Req.TransportError{reason: {:tls_alert, {reason, _message}}}), do: reason + defp error_reason(%Req.TransportError{reason: reason}), do: reason + defp error_reason(%Req.TooManyRedirectsError{} = error), do: error + defp error_reason(error), do: error +end diff --git a/lib/feed_reader/transport.ex b/lib/feed_reader/transport.ex new file mode 100644 index 0000000..6c26265 --- /dev/null +++ b/lib/feed_reader/transport.ex @@ -0,0 +1,26 @@ +defmodule FeedReader.Transport do + @moduledoc """ + Injectable HTTP transport boundary used by remote feed operations. + + Implementations return raw response chunks as an enumerable. The response is + intentionally not decompressed here; the download layer owns decoded-body + checksums and decompression. + """ + + @type response :: %{ + required(:status) => pos_integer(), + required(:headers) => [{String.t(), String.t()}], + required(:final_url) => String.t(), + required(:body) => Enumerable.t() + } + + @callback request(url :: String.t(), opts :: keyword()) :: + {:ok, response()} | {:error, term()} + + @doc "Dispatches a request to the configured or default HTTP client." + @spec request(String.t(), keyword()) :: {:ok, response()} | {:error, term()} + def request(url, opts \\ []) do + client = Keyword.get(opts, :http_client, FeedReader.ReqTransport) + client.request(url, opts) + end +end