Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
36 changes: 36 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
37 changes: 32 additions & 5 deletions lib/supabase/functions.ex
Original file line number Diff line number Diff line change
Expand Up @@ -24,13 +24,15 @@ 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()}
| {:headers, Fetcher.headers()}
| {: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()))
Expand Down Expand Up @@ -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)
Expand All @@ -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
Expand Down Expand Up @@ -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

Expand Down
84 changes: 76 additions & 8 deletions test/supabase/functions_test.exs
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand All @@ -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

Expand All @@ -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)
Expand All @@ -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,
Expand All @@ -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)
Expand All @@ -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,
Expand All @@ -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,
Expand All @@ -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,
Expand All @@ -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