Current section
Files
Jump to
Current section
Files
lib/gemini/client/http_streaming.ex
defmodule Gemini.Client.HTTPStreaming do
@moduledoc """
HTTP client for streaming Server-Sent Events (SSE) from Gemini API.
Provides proper streaming support with:
- Incremental SSE parsing
- Connection management
- Error handling and retries
- Backpressure support
"""
alias Gemini.SSE.Parser
alias Gemini.Error
alias Gemini.Telemetry
alias Gemini.Config
require Logger
@default_max_backoff_ms 10_000
@default_connect_timeout_ms 5_000
@type stream_event :: %{
type: :data | :error | :complete,
data: map() | nil,
error: term() | nil
}
@type stream_callback :: (stream_event() -> :ok | :stop)
@doc """
Start an SSE stream with a callback function.
## Parameters
- `url` - Full URL for the streaming endpoint
- `headers` - HTTP headers including authentication
- `body` - Request body (will be JSON encoded)
- `callback` - Function called for each event
- `opts` - Options including timeout, retry settings
- `:timeout` - Receive timeout per attempt (default: `Gemini.Config.timeout/0`)
- `:max_retries` - Number of retry attempts (default: 3)
- `:max_backoff_ms` - Max backoff between retries (default: 10_000)
- `:connect_timeout` - Finch connect timeout (default: 5_000)
## Examples
callback = fn
%{type: :data, data: data} ->
IO.puts("Received data")
:ok
%{type: :complete} ->
IO.puts("Stream complete")
:ok
%{type: :error, error: _error} ->
IO.puts("Stream error")
:stop
end
HTTPStreaming.stream_sse(url, headers, body, callback)
"""
@spec stream_sse(String.t(), [{String.t(), String.t()}], map(), stream_callback(), keyword()) ::
{:ok, :completed} | {:error, term()}
def stream_sse(url, headers, body, callback, opts \\ []) do
timeout = Keyword.get(opts, :timeout, Config.timeout())
max_retries = Keyword.get(opts, :max_retries, 3)
max_backoff_ms = Keyword.get(opts, :max_backoff_ms, @default_max_backoff_ms)
connect_timeout = Keyword.get(opts, :connect_timeout, @default_connect_timeout_ms)
stream_id = Telemetry.generate_stream_id()
metadata = Telemetry.build_stream_metadata(url, :post, stream_id, opts)
measurements = %{system_time: System.system_time()}
Telemetry.execute([:gemini, :stream, :start], measurements, metadata)
try do
# Wrap the callback to emit telemetry for chunks
telemetry_callback = fn event ->
case event do
%{type: :data, data: data} ->
chunk_measurements = %{
chunk_size: calculate_chunk_size(data),
system_time: System.system_time()
}
Telemetry.execute([:gemini, :stream, :chunk], chunk_measurements, metadata)
_ ->
:ok
end
callback.(event)
end
result =
stream_with_retries(
url,
headers,
body,
telemetry_callback,
timeout,
max_retries,
0,
max_backoff_ms,
connect_timeout
)
case result do
{:ok, :completed} ->
# Emit stream completion event
Telemetry.execute([:gemini, :stream, :stop], %{}, metadata)
result
{:error, error} ->
Telemetry.execute(
[:gemini, :stream, :exception],
measurements,
Map.put(metadata, :reason, error)
)
result
end
rescue
exception ->
Telemetry.execute(
[:gemini, :stream, :exception],
measurements,
Map.put(metadata, :reason, exception)
)
reraise exception, __STACKTRACE__
end
end
@doc """
Start an SSE stream that sends events to a GenServer process.
Events are sent as messages: {:stream_event, stream_id, event}
"""
@spec stream_to_process(
String.t(),
[{String.t(), String.t()}],
map(),
String.t(),
pid(),
keyword()
) ::
{:ok, pid()} | {:error, term()}
def stream_to_process(url, headers, body, stream_id, target_pid, opts \\ []) do
callback = fn event ->
send(target_pid, {:stream_event, stream_id, event})
:ok
end
# Start streaming in a separate process
stream_pid =
spawn(fn ->
case stream_sse(url, headers, body, callback, opts) do
{:ok, :completed} ->
send(target_pid, {:stream_complete, stream_id})
{:error, error} ->
send(target_pid, {:stream_error, stream_id, error})
end
end)
{:ok, stream_pid}
end
# Private implementation
@spec stream_with_retries(
String.t(),
list(),
map(),
stream_callback(),
integer(),
integer(),
integer(),
integer(),
integer()
) ::
{:ok, :completed} | {:error, term()}
defp stream_with_retries(
url,
headers,
body,
callback,
timeout,
max_retries,
attempt,
max_backoff_ms,
connect_timeout
) do
case do_stream(url, headers, body, callback, timeout, connect_timeout) do
{:ok, :completed} ->
{:ok, :completed}
{:error, error} when attempt < max_retries ->
Logger.warning("Stream attempt #{attempt + 1} failed: #{inspect(error)}, retrying...")
# Exponential backoff
delay = min(1000 * :math.pow(2, attempt), max_backoff_ms) |> round()
Process.sleep(delay)
stream_with_retries(
url,
headers,
body,
callback,
timeout,
max_retries,
attempt + 1,
max_backoff_ms,
connect_timeout
)
{:error, error} ->
Logger.error("Stream failed after #{max_retries} retries: #{inspect(error)}")
{:error, error}
end
end
@spec do_stream(String.t(), list(), map(), stream_callback(), integer(), integer()) ::
{:ok, :completed} | {:error, term()}
defp do_stream(url, headers, body, callback, timeout, connect_timeout) do
# Add SSE parameters to URL
sse_url = add_sse_params(url)
# Use a more direct approach with custom HTTP handling
case stream_with_finch(sse_url, headers, body, callback, timeout, connect_timeout) do
{:ok, :completed} ->
{:ok, :completed}
{:error, error} ->
{:error, error}
end
end
@spec stream_with_finch(String.t(), list(), map(), stream_callback(), integer(), integer()) ::
{:ok, :completed} | {:error, term()}
defp stream_with_finch(url, headers, body, callback, timeout, connect_timeout) do
Logger.debug("Starting real-time streaming with Req to #{url}")
# Use Req's `:self` option for real-time streaming
req_opts = [
method: :post,
url: url,
headers: add_sse_headers(headers),
json: body,
receive_timeout: timeout,
connect_options: [timeout: connect_timeout],
# Use :self to get messages as they arrive
into: :self
]
try do
case Req.request(req_opts) do
{:ok, response} ->
# Check for HTTP errors before starting to stream
if response.status >= 400 do
# For error responses, we need to collect the body from streaming messages
error_body = collect_error_body(response, timeout)
error_msg = extract_error_message(error_body) || "HTTP #{response.status}"
error = Error.http_error(response.status, error_msg)
error_event = %{type: :error, data: nil, error: error}
callback.(error_event)
{:error, error}
else
# Process streaming messages in real-time
parser = Parser.new()
stream_loop(response, parser, callback, timeout)
end
{:error, %{reason: reason}} ->
error = Error.network_error("Transport error: #{inspect(reason)}")
error_event = %{type: :error, data: nil, error: error}
callback.(error_event)
{:error, error}
{:error, reason} ->
error = Error.network_error("Request failed: #{inspect(reason)}")
error_event = %{type: :error, data: nil, error: error}
callback.(error_event)
{:error, reason}
end
catch
{:stop_stream, :completed} ->
{:ok, :completed}
{:stop_stream, :requested} ->
{:ok, :completed}
{:stop_stream, error} ->
{:error, error}
end
end
# Collect error response body from streaming messages
defp collect_error_body(response, timeout) do
collect_error_body(response, timeout, "")
end
defp collect_error_body(response, timeout, acc) do
receive do
message ->
case Req.parse_message(response, message) do
{:ok, [{:data, chunk}]} ->
collect_error_body(response, timeout, acc <> chunk)
{:ok, [:done]} ->
acc
{:ok, other} ->
Logger.debug("Received other message during error collection: #{inspect(other)}")
collect_error_body(response, timeout, acc)
:unknown ->
Logger.debug("Received unknown message during error collection: #{inspect(message)}")
collect_error_body(response, timeout, acc)
end
after
timeout ->
Logger.warning("Timeout collecting error response body")
acc
end
end
# Process streaming messages in real-time
defp stream_loop(response, parser, callback, timeout) do
receive do
message ->
case Req.parse_message(response, message) do
{:ok, [{:data, chunk}]} ->
Logger.debug("Received streaming chunk of size #{byte_size(chunk)}")
case Parser.parse_chunk(chunk, parser) do
{:ok, events, new_parser} ->
case deliver_events(events, new_parser, callback) do
{:completed, _next_parser} ->
{:ok, :completed}
{:continue, next_parser} ->
stream_loop(response, next_parser, callback, timeout)
end
{:error, error} ->
error_event = %{type: :error, data: nil, error: error}
callback.(error_event)
stream_loop(response, parser, callback, timeout)
end
{:ok, [:done]} ->
Logger.debug("Stream completed")
case Parser.finalize(parser) do
{:ok, remaining_events} ->
Enum.each(remaining_events, fn event ->
stream_event = %{type: :data, data: event.data, error: nil}
callback.(stream_event)
end)
end
# Send final completion event
completion_event = %{type: :complete, data: nil, error: nil}
callback.(completion_event)
{:ok, :completed}
{:ok, other} ->
Logger.debug("Received other message: #{inspect(other)}")
stream_loop(response, parser, callback, timeout)
:unknown ->
Logger.debug("Received unknown message: #{inspect(message)}")
stream_loop(response, parser, callback, timeout)
end
after
timeout ->
error = Error.network_error("Stream timeout after #{timeout}ms")
error_event = %{type: :error, data: nil, error: error}
callback.(error_event)
{:error, :timeout}
end
end
defp deliver_events(events, parser, callback) do
Enum.reduce_while(events, {:continue, parser}, fn event, {_status, current_parser} ->
stream_event = %{type: :data, data: event.data, error: nil}
case callback.(stream_event) do
:stop ->
{:halt, {:completed, current_parser}}
_ ->
if Parser.stream_done?(event) do
completion_event = %{type: :complete, data: nil, error: nil}
callback.(completion_event)
{:halt, {:completed, current_parser}}
else
{:cont, {:continue, current_parser}}
end
end
end)
end
@spec add_sse_params(String.t()) :: String.t()
defp add_sse_params(url) do
separator = if String.contains?(url, "?"), do: "&", else: "?"
url <> separator <> "alt=sse"
end
@spec add_sse_headers([{String.t(), String.t()}]) :: [{String.t(), String.t()}]
defp add_sse_headers(headers) do
sse_headers = [
{"Accept", "text/event-stream"},
{"Cache-Control", "no-cache"}
]
# Merge with existing headers, avoiding duplicates
existing_keys = Enum.map(headers, fn {key, _} -> String.downcase(key) end)
new_headers =
sse_headers
|> Enum.reject(fn {key, _} -> String.downcase(key) in existing_keys end)
headers ++ new_headers
end
@spec extract_error_message(binary()) :: String.t() | nil
defp extract_error_message(body) when is_binary(body) do
case Jason.decode(body) do
{:ok, %{"error" => %{"message" => message}}} when is_binary(message) -> message
{:ok, %{"error" => error}} when is_binary(error) -> error
_ -> nil
end
end
# Helper functions for telemetry
defp calculate_chunk_size(data) when is_map(data) do
data
|> Jason.encode()
|> case do
{:ok, json} -> byte_size(json)
_ -> 0
end
end
defp calculate_chunk_size(data) when is_binary(data), do: byte_size(data)
defp calculate_chunk_size(_), do: 0
end