diff --git a/README.md b/README.md index c7ede86..b5f7d71 100644 --- a/README.md +++ b/README.md @@ -88,3 +88,39 @@ end Supabase.Functions.invoke(client, "stream-data", on_response: on_response) # :ok ``` + +## Timeout Support + +You can control the timeout for function invocations using the `timeout` option. If no timeout is specified, requests will timeout after 15 seconds by default. + +```elixir +client = Supabase.init_client!("SUPABASE_URL", "SUPABASE_KEY") + +# Basic invocation with default timeout (15 seconds) +{:ok, response} = Supabase.Functions.invoke(client, "my-function") + +# Custom timeout (5 seconds) +{:ok, response} = Supabase.Functions.invoke(client, "my-function", timeout: 5_000) + +# Timeout with body and headers +{:ok, response} = Supabase.Functions.invoke(client, "my-function", + body: %{data: "value"}, + headers: %{"x-custom" => "header"}, + timeout: 30_000) + +# Streaming with timeout +on_response = fn {status, headers, body} -> + # Handle streaming response + {:ok, body} +end + +{:ok, response} = Supabase.Functions.invoke(client, "my-function", + on_response: on_response, + timeout: 10_000) +``` + +This feature provides: +- **Request cancellation**: Long-running requests will timeout and be cancelled +- **Better resource management**: Prevents hanging connections +- **Comprehensive timeout coverage**: Sets both receive timeout (per-chunk) and request timeout (complete response) +- **Feature parity with JS client**: Matches timeout functionality in the JavaScript SDK diff --git a/lib/supabase/functions.ex b/lib/supabase/functions.ex index e3865b4..01c1a28 100644 --- a/lib/supabase/functions.ex +++ b/lib/supabase/functions.ex @@ -24,6 +24,7 @@ defmodule Supabase.Functions do - `method`: The HTTP method of the request. - `region`: The Region to invoke the function in. - `on_response`: The custom response handler for response streaming. + - `timeout`: The timeout in milliseconds for the request. Defaults to 15 seconds. """ @type opt :: {:body, Fetcher.body()} @@ -31,6 +32,7 @@ defmodule Supabase.Functions do | {:method, Fetcher.method()} | {:region, region} | {:on_response, on_response} + | {:timeout, pos_integer()} @type on_response :: ({Fetcher.status(), Fetcher.headers(), body :: Enumerable.t()} -> Supabase.result(Response.t())) @@ -59,11 +61,35 @@ defmodule Supabase.Functions do - When you pass in a body to your function, we automatically attach the `Content-Type` header automatically. If it doesn't match any of these types we assume the payload is json, serialize it and attach the `Content-Type` header as `application/json`. You can override this behavior by passing in a `Content-Type` header of your own. - Responses are automatically parsed as json depending on the Content-Type header sent by your function. Responses are parsed as text by default. + + ## Timeout Support + + You can set a timeout for function invocations using the `timeout` option. This sets both the + receive timeout (for individual chunks) and request timeout (for the complete response): + + # Timeout after 5 seconds + Supabase.Functions.invoke(client, "my-function", timeout: 5_000) + + If no timeout is specified, requests will timeout after 15 seconds by default. + + ## Examples + + # Basic invocation + {:ok, response} = Supabase.Functions.invoke(client, "my-function") + + # With timeout + {:ok, response} = Supabase.Functions.invoke(client, "my-function", timeout: 10_000) + + # With body and timeout + {:ok, response} = Supabase.Functions.invoke(client, "my-function", + body: %{data: "value"}, + timeout: 30_000) """ @spec invoke(Client.t(), function :: String.t(), opts) :: Supabase.result(Response.t()) def invoke(%Client{} = client, name, opts \\ []) when is_binary(name) do method = opts[:method] || :post custom_headers = opts[:headers] || %{} + timeout = opts[:timeout] || 15_000 client |> Request.new(decode_body?: false) @@ -75,7 +101,7 @@ defmodule Supabase.Functions do |> Request.with_body_decoder(nil) |> maybe_define_content_type(opts[:body]) |> Request.with_headers(custom_headers) - |> execute_request(opts[:on_response]) + |> execute_request(opts[:on_response], timeout) |> maybe_decode_body() |> handle_response() end @@ -103,12 +129,13 @@ defmodule Supabase.Functions do defp raw_binary?(bin), do: not String.printable?(bin) - defp execute_request(req, on_response) do + defp execute_request(req, on_response, timeout) do + opts = [receive_timeout: timeout, request_timeout: timeout] + if on_response do - Fetcher.stream(req, on_response) + Fetcher.stream(req, on_response, opts) else - # consume all the response, answers eagerly - Fetcher.stream(req) + Fetcher.stream(req, nil, opts) end end diff --git a/test/supabase/functions_test.exs b/test/supabase/functions_test.exs index d32c011..a619ff5 100644 --- a/test/supabase/functions_test.exs +++ b/test/supabase/functions_test.exs @@ -34,7 +34,7 @@ defmodule Supabase.FunctionsTest do end test "handles text response content type", %{client: client} do - expect(@mock, :stream, fn _request, _ -> + expect(@mock, :stream, fn _request, _opts -> {:ok, %Finch.Response{ status: 200, @@ -50,7 +50,7 @@ defmodule Supabase.FunctionsTest do test "sets appropriate content-type for binary data", %{client: client} do binary_data = <<0, 1, 2, 3>> - expect(@mock, :stream, fn request, _ -> + expect(@mock, :stream, fn request, _opts -> assert Request.get_header(request, "content-type") == "application/octet-stream" assert request.body == binary_data @@ -71,7 +71,7 @@ defmodule Supabase.FunctionsTest do test "sets appropriate content-type for JSON data", %{client: client} do json_data = %{test: "data"} - expect(@mock, :stream, fn request, _ -> + expect(@mock, :stream, fn request, _opts -> assert Request.get_header(request, "content-type") == "application/json" # fetcher will io encode it assert {:ok, _} = Jason.decode(request.body) @@ -93,7 +93,7 @@ defmodule Supabase.FunctionsTest do test "handles custom headers", %{client: client} do custom_headers = %{"x-custom-header" => "test-value"} - expect(@mock, :stream, fn request, _ -> + expect(@mock, :stream, fn request, _opts -> assert Request.get_header(request, "x-custom-header") == "test-value" {:ok, @@ -116,7 +116,7 @@ defmodule Supabase.FunctionsTest do test "handles streaming responses with custom handler", %{client: client} do chunks = ["chunk1", "chunk2", "chunk3"] - expect(@mock, :stream, fn _request, on_response, _ -> + expect(@mock, :stream, fn _request, on_response, _opts -> Enum.each(chunks, fn chunk -> on_response.({200, %{"content-type" => "text/plain"}, [chunk]}) end) @@ -141,7 +141,7 @@ defmodule Supabase.FunctionsTest do end test "handles error responses", %{client: client} do - expect(@mock, :stream, fn _request, _ -> + expect(@mock, :stream, fn _request, _opts -> {:ok, %Finch.Response{ status: 404, @@ -156,7 +156,7 @@ defmodule Supabase.FunctionsTest do end test "uses custom HTTP method when specified", %{client: client} do - expect(@mock, :stream, fn request, _ -> + expect(@mock, :stream, fn request, _opts -> assert request.method == :get {:ok, @@ -174,7 +174,7 @@ defmodule Supabase.FunctionsTest do end test "handles relay errors", %{client: client} do - expect(@mock, :stream, fn _request, _ -> + expect(@mock, :stream, fn _request, _opts -> {:ok, %Finch.Response{ status: 200, @@ -188,5 +188,73 @@ defmodule Supabase.FunctionsTest do assert error.code == :relay_error end + + test "passes timeout option to underlying HTTP client", %{client: client} do + expect(@mock, :stream, fn _request, opts -> + assert Keyword.get(opts, :receive_timeout) == 5_000 + assert Keyword.get(opts, :request_timeout) == 5_000 + + {:ok, + %Finch.Response{ + status: 200, + headers: %{"content-type" => "application/json"}, + body: ~s({"success": true}) + }} + end) + + assert {:ok, response} = + Functions.invoke(client, "test-function", timeout: 5_000, http_client: @mock) + + assert response.body == %{"success" => true} + end + + test "uses default timeout when not specified", %{client: client} do + expect(@mock, :stream, fn _request, opts -> + assert Keyword.get(opts, :receive_timeout) == 15_000 + assert Keyword.get(opts, :request_timeout) == 15_000 + + {:ok, + %Finch.Response{ + status: 200, + headers: %{"content-type" => "application/json"}, + body: ~s({"success": true}) + }} + end) + + assert {:ok, response} = + Functions.invoke(client, "test-function", http_client: @mock) + + assert response.body == %{"success" => true} + end + + test "timeout works with streaming response", %{client: client} do + chunks = ["chunk1", "chunk2"] + + expect(@mock, :stream, fn _request, on_response, opts -> + assert Keyword.get(opts, :receive_timeout) == 2_000 + assert Keyword.get(opts, :request_timeout) == 2_000 + + Enum.each(chunks, fn chunk -> + on_response.({200, %{"content-type" => "text/plain"}, [chunk]}) + end) + + {:ok, Enum.join(chunks)} + end) + + on_response = fn {status, headers, body} -> + assert status == 200 + assert headers["content-type"] == "text/plain" + {:ok, body} + end + + assert {:ok, response} = + Functions.invoke(client, "test-function", + on_response: on_response, + timeout: 2_000, + http_client: @mock + ) + + assert response == "chunk1chunk2" + end end end