From bd893b076f0427522ac679ae9fb1380919dd52ae Mon Sep 17 00:00:00 2001 From: developers-nightmare Date: Thu, 27 Nov 2025 21:06:31 +0530 Subject: [PATCH 1/3] feat: add timeout support for function invocations - Add timeout option to Supabase.Functions.invoke/3 - Support for custom timeout values (defaults to 15 seconds) - Pass timeout to underlying HTTP client via receive_timeout option - Maintain backward compatibility with existing API - Add comprehensive tests for timeout functionality - Update documentation with timeout examples Resolves #4 --- README.md | 35 ++++++++++++++ lib/supabase/functions.ex | 36 ++++++++++++-- test/supabase/functions_test.exs | 81 ++++++++++++++++++++++++++++---- 3 files changed, 139 insertions(+), 13 deletions(-) diff --git a/README.md b/README.md index c7ede86..eef8eb1 100644 --- a/README.md +++ b/README.md @@ -88,3 +88,38 @@ 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 +- **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..67547ff 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,34 @@ 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: + + # 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 +100,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 +128,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] + 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..dbbecf3 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,70 @@ 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 + + {: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 + + {: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 + + 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 From 9d0389d932fc5c310cca3b537a3a752e9942224f Mon Sep 17 00:00:00 2001 From: developers-nightmare Date: Fri, 28 Nov 2025 22:11:08 +0530 Subject: [PATCH 2/3] o --- README.md | 1 + lib/supabase/functions.ex | 5 +++-- test/supabase/functions_test.exs | 3 +++ 3 files changed, 7 insertions(+), 2 deletions(-) diff --git a/README.md b/README.md index eef8eb1..b5f7d71 100644 --- a/README.md +++ b/README.md @@ -122,4 +122,5 @@ end 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 67547ff..01c1a28 100644 --- a/lib/supabase/functions.ex +++ b/lib/supabase/functions.ex @@ -64,7 +64,8 @@ defmodule Supabase.Functions do ## Timeout Support - You can set a timeout for function invocations using the `timeout` option: + 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) @@ -129,7 +130,7 @@ defmodule Supabase.Functions do defp raw_binary?(bin), do: not String.printable?(bin) defp execute_request(req, on_response, timeout) do - opts = [receive_timeout: timeout] + opts = [receive_timeout: timeout, request_timeout: timeout] if on_response do Fetcher.stream(req, on_response, opts) diff --git a/test/supabase/functions_test.exs b/test/supabase/functions_test.exs index dbbecf3..a619ff5 100644 --- a/test/supabase/functions_test.exs +++ b/test/supabase/functions_test.exs @@ -192,6 +192,7 @@ defmodule Supabase.FunctionsTest do 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{ @@ -210,6 +211,7 @@ defmodule Supabase.FunctionsTest do 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{ @@ -230,6 +232,7 @@ defmodule Supabase.FunctionsTest do 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]}) From 3efbbe40ca11b1834c9eb6f91c8cb6057f19fdcf Mon Sep 17 00:00:00 2001 From: developers-nightmare Date: Sun, 30 Nov 2025 13:33:13 +0530 Subject: [PATCH 3/3] Add dynamic auth token updates Adds update_auth/2 and :auth option for invoke/3 to support token rotation like JS client's setAuth(). Closes #3 --- CHANGELOG.md | 11 +++ README.md | 38 ++++++++ lib/supabase/functions.ex | 45 +++++++++- test/supabase/functions_test.exs | 143 +++++++++++++++++++++++++++++++ 4 files changed, 236 insertions(+), 1 deletion(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index e69de29..35bdca8 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -0,0 +1,11 @@ +# Changelog + +## [Unreleased] + +### Added +- Dynamic auth token updates feature for better token rotation support + - `Supabase.Functions.update_auth/2` function for functional client updates + - `:auth` option in `Supabase.Functions.invoke/3` for per-request auth token overrides + - Feature parity with JavaScript client's `setAuth(token)` method + - Comprehensive test coverage for both update methods +- Enhanced documentation with examples for both auth update approaches diff --git a/README.md b/README.md index b5f7d71..fcb602f 100644 --- a/README.md +++ b/README.md @@ -124,3 +124,41 @@ This feature provides: - **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 + +## Dynamic Auth Token Updates + +You can update authorization tokens dynamically using two approaches, providing feature parity with the JavaScript client's `setAuth(token)` method: + +### Option 1: Functional Update + +Create a new client with an updated auth token: + +```elixir +client = Supabase.init_client!("SUPABASE_URL", "SUPABASE_KEY") + +# Update auth token functionally +updated_client = Supabase.Functions.update_auth(client, "new_jwt_token") +{:ok, response} = Supabase.Functions.invoke(updated_client, "my-function") +``` + +### Option 2: Per-Request Override + +Override the authorization token for a specific request: + +```elixir +client = Supabase.init_client!("SUPABASE_URL", "SUPABASE_KEY") + +# Use a different token for this request only +{:ok, response} = Supabase.Functions.invoke(client, "my-function", auth: "user_jwt_token") + +# Original client remains unchanged for subsequent calls +{:ok, response2} = Supabase.Functions.invoke(client, "another-function") +``` + +### Benefits + +- **Token rotation support**: Easily update tokens without recreating clients +- **Better performance**: Avoid the overhead of creating new client instances +- **Flexibility**: Use different tokens per request or update client globally +- **Feature parity**: Matches the JavaScript client's `setAuth()` functionality +- **Immutability**: Original client instances remain unchanged with functional updates diff --git a/lib/supabase/functions.ex b/lib/supabase/functions.ex index 01c1a28..854a81f 100644 --- a/lib/supabase/functions.ex +++ b/lib/supabase/functions.ex @@ -25,6 +25,7 @@ defmodule Supabase.Functions do - `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. + - `auth`: Override the authorization token for this request. """ @type opt :: {:body, Fetcher.body()} @@ -33,6 +34,7 @@ defmodule Supabase.Functions do | {:region, region} | {:on_response, on_response} | {:timeout, pos_integer()} + | {:auth, String.t()} @type on_response :: ({Fetcher.status(), Fetcher.headers(), body :: Enumerable.t()} -> Supabase.result(Response.t())) @@ -54,6 +56,24 @@ defmodule Supabase.Functions do | :"ca-central-1" | :"eu-central-1" + @doc """ + Updates the access token for a client + + Creates a new client instance with the updated access token. This provides + feature parity with the JavaScript client's `setAuth(token)` method. + + ## Examples + + # Update auth token functionally + new_client = Supabase.Functions.update_auth(client, "new_token") + {:ok, response} = Supabase.Functions.invoke(new_client, "my-function") + + """ + @spec update_auth(Client.t(), String.t()) :: Client.t() + def update_auth(%Client{} = client, token) when is_binary(token) do + %{client | access_token: token} + end + @doc """ Invokes a function @@ -62,6 +82,19 @@ 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. + ## Authentication + + You can override the authorization token for a specific request using the `auth` option: + + # Use a different token for this request + {:ok, response} = Supabase.Functions.invoke(client, "my-function", auth: "new_token") + + Alternatively, you can update the client's auth token functionally: + + # Update the client's token + new_client = Supabase.Functions.update_auth(client, "new_token") + {:ok, response} = Supabase.Functions.invoke(new_client, "my-function") + ## Timeout Support You can set a timeout for function invocations using the `timeout` option. This sets both the @@ -84,6 +117,9 @@ defmodule Supabase.Functions do {:ok, response} = Supabase.Functions.invoke(client, "my-function", body: %{data: "value"}, timeout: 30_000) + + # With custom auth token + {:ok, response} = Supabase.Functions.invoke(client, "my-function", auth: "custom_token") """ @spec invoke(Client.t(), function :: String.t(), opts) :: Supabase.result(Response.t()) def invoke(%Client{} = client, name, opts \\ []) when is_binary(name) do @@ -91,7 +127,14 @@ defmodule Supabase.Functions do custom_headers = opts[:headers] || %{} timeout = opts[:timeout] || 15_000 - client + # Handle auth token override + effective_client = + case opts[:auth] do + nil -> client + auth_token when is_binary(auth_token) -> update_auth(client, auth_token) + end + + effective_client |> Request.new(decode_body?: false) |> Request.with_functions_url(name) |> Request.with_method(method) diff --git a/test/supabase/functions_test.exs b/test/supabase/functions_test.exs index a619ff5..1667b2a 100644 --- a/test/supabase/functions_test.exs +++ b/test/supabase/functions_test.exs @@ -257,4 +257,147 @@ defmodule Supabase.FunctionsTest do assert response == "chunk1chunk2" end end + + describe "update_auth/2" do + test "returns a new client with updated access token", %{client: client} do + new_token = "new_test_token" + updated_client = Functions.update_auth(client, new_token) + + assert updated_client.access_token == new_token + # Original client should remain unchanged + assert client.access_token != new_token + # All other fields should remain the same + assert updated_client.base_url == client.base_url + assert updated_client.api_key == client.api_key + end + + test "updated client works with invoke/3", %{client: client} do + new_token = "updated_token" + + expect(@mock, :stream, fn request, _opts -> + assert Request.get_header(request, "authorization") == "Bearer #{new_token}" + + {:ok, + %Finch.Response{ + status: 200, + headers: %{"content-type" => "application/json"}, + body: ~s({"success": true}) + }} + end) + + updated_client = Functions.update_auth(client, new_token) + + assert {:ok, response} = + Functions.invoke(updated_client, "test-function", http_client: @mock) + + assert response.body == %{"success" => true} + end + end + + describe "auth option in invoke/3" do + test "overrides authorization header with custom token", %{client: client} do + custom_token = "custom_auth_token" + + expect(@mock, :stream, fn request, _opts -> + assert Request.get_header(request, "authorization") == "Bearer #{custom_token}" + + {:ok, + %Finch.Response{ + status: 200, + headers: %{"content-type" => "application/json"}, + body: ~s({"authorized": true}) + }} + end) + + assert {:ok, response} = + Functions.invoke(client, "test-function", auth: custom_token, http_client: @mock) + + assert response.body == %{"authorized" => true} + end + + test "works with other options combined", %{client: client} do + custom_token = "combined_auth_token" + custom_headers = %{"x-custom" => "value"} + body_data = %{test: "data"} + + expect(@mock, :stream, fn request, _opts -> + assert Request.get_header(request, "authorization") == "Bearer #{custom_token}" + assert Request.get_header(request, "x-custom") == "value" + assert Request.get_header(request, "content-type") == "application/json" + + {:ok, + %Finch.Response{ + status: 200, + headers: %{"content-type" => "application/json"}, + body: ~s({"success": true}) + }} + end) + + assert {:ok, response} = + Functions.invoke(client, "test-function", + auth: custom_token, + headers: custom_headers, + body: body_data, + http_client: @mock + ) + + assert response.body == %{"success" => true} + end + + test "original client remains unchanged after auth override", %{client: client} do + original_token = client.access_token + custom_token = "temporary_override_token" + + expect(@mock, :stream, fn request, _opts -> + assert Request.get_header(request, "authorization") == "Bearer #{custom_token}" + + {:ok, + %Finch.Response{ + status: 200, + headers: %{"content-type" => "application/json"}, + body: ~s({"success": true}) + }} + end) + + assert {:ok, _response} = + Functions.invoke(client, "test-function", auth: custom_token, http_client: @mock) + + # Original client should be unchanged + assert client.access_token == original_token + end + + test "nil auth option uses original client token", %{client: client} do + expect(@mock, :stream, fn request, _opts -> + assert Request.get_header(request, "authorization") == "Bearer #{client.access_token}" + + {:ok, + %Finch.Response{ + status: 200, + headers: %{"content-type" => "application/json"}, + body: ~s({"success": true}) + }} + end) + + assert {:ok, response} = + Functions.invoke(client, "test-function", auth: nil, http_client: @mock) + + assert response.body == %{"success" => true} + end + + test "empty auth option uses original client token", %{client: client} do + expect(@mock, :stream, fn request, _opts -> + assert Request.get_header(request, "authorization") == "Bearer #{client.access_token}" + + {: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 + end end