Skip to content

feat(functions): refactor sync and async methods - #1338

Merged
o-santi merged 31 commits into
v3from
sync-async-refactor
Feb 3, 2026
Merged

feat(functions): refactor sync and async methods#1338
o-santi merged 31 commits into
v3from
sync-async-refactor

Conversation

@o-santi

@o-santi o-santi commented Dec 17, 2025

Copy link
Copy Markdown
Contributor

What kind of change does this PR introduce?

Preview for the sync vs async rewrite proposal, using functions as the first example. The idea for this rewrite is that we can refactor the methods in such a way to only write the core logic once, and derive both sync and async implementations out of the same client.

To understand the rewrite, we should study the following hypothetical method, and its async counterpart:

class SyncSupabaseClient:
    def list_something(self, **options) -> Something:
        http_request = request_from_options(options)
        response = self.client.send(http_request) # <<<<<<<
        return Something.from_response(response)

class AsyncSupabaseClient:
    async def list_something(self, **options) -> Something:
        http_request = request_from_options(options)
        response = await self.client.send(http_request) # <<<<<<<
        return Something.from_response(response)

We see that the only differences between the sync and async implementation is the async and await keywords. In this PR, I propose to rewrite these two into a single implementation, by removing the IO out of the methods:

class SupabaseClient:
    def list_something(self, **options) -> ServerEndpoint[Something]:
        return ServerEndpoint(
            request=request_from_options(options)
            on_success=Something.from_response(response)
        )

class SyncExecutor:
    def communicate(self, endpoint: ServerEndpoint[Something]) -> Something:
      response = self.client.send(endpoint.request)
      return endpoint.on_success(response)

class AsyncExecutor:
    async def communicate(self, endpoint: ServerEndpoint[Something]) -> Something:
        response = await self.client.send(endpoint.request)
        return endpoint.on_success(response)

With this, we can write the list_something method only once, as a description for how to send a request and how to parse a response, and execute it both ways, either sync or async.

However, in order to execute it in this way, we'd need to externally call the client method list_something in the executor:

AsyncExecutor().communicate(client.list_something())
SyncExecutor().communicate(client.list_something())

This would work, but would be cumbersome and tiring to use. Instead, we can a store an executor inside the client, and use a decorator to blindly call communicate on the endpoint. Regardless of which kind of IO, this will return the correct object, and will work correctly.

class SupabaseClient:
    @http_endpoint
    def list_something(self, **options) -> ServerEndpoint[Something]:
        return ServerEndpoint(
            request=request_from_options(options)
            on_success=Something.from_response(response)
        )

def http_endpoint(method):
    def inner(client, **options):
        endpoint = client.method(options)
        return client.executor.communicate(endpoint)
    return inner

In order to derive the sync and async clients, all we'd need to do is pass the executor we want:

sync_client = SupabaseClient(SyncExecutor())
async_client = SupabaseClient(AsyncExecutor())
sync_client.list_something() # this is sync
# Something
await async_client.list_something() # this is async, and when awaited returns the same thing
# Awaitable[Something]

However, how do we convince mypy that this is the case?

Typing

In order to show mypy that the type of list_something() depends on the executor kind, we need to add the executor as a generic type parameter of SupabaseClient, and use @overloads on the http_endpoint to decide whether it should be sync or async:

Params = ParamSpec("Params")
Executor = TypeVar("Executor", SyncExecutor, AsyncExecutor)

class HasExecutor(Protocol[Executor]):
    executor: Executor
    base_url: URL

@dataclass
class http_endpoint(Generic[Params, Success]):
    method: Callable[Concatenate[Any, Params], ServerEndpoint[Success]]

    @overload
    def __get__(
        self, obj: HasExecutor[SyncExecutor], objtype: type | None = None
    ) -> Callable[Params, Success]: ...

    @overload
    def __get__(
        self, obj: HasExecutor[AsyncExecutor], objtype: type | None = None
    ) -> Callable[Params, Awaitable[Success]]: ...

    def __get__(
        self, obj: HasExecutor[Executor], objtype: type | None = None
    ) -> Callable[Params, Success | Awaitable[Success]]:
        def bound_method(
            *args: Params.args, **kwargs: Params.kwargs
        ) -> Success | Awaitable[Success]:
            endpoint = self.method(obj, *args, **kwargs)
            return obj.executor.communicate(obj.base_url, endpoint)

        return bound_method

Even though this seems complex, its not. Because the typing is not easy to write, I rewrote the http_endpoint decorator to be a dataclass class, so that I could annotate it more easily. Thus, you can interpret it in the following way:

  1. If we don't know which executor is being used, all we can say is that it returns Success | Awaitable[Sucess]
  2. If we know that the obj that is calling this method is a client with SyncExecutor, the return is the sync version, Success
  3. If we know that the obj that is calling this method is a client with AsyncExecutor, the return is the sync version, Awaitable[Sucess]

The ParamSpec part is used to be generic over all the possible args and kwargs of any method passed in. Similarly, the HasExecutor protocol is used to be generic over all the possible clients that can be passed in, so that we can share this decorator over multiple clients -- eg. SupabaseVectorClient and GoTrueClient.

With this, mypy is satisfied, and http_endpoint type checks for both the sync and async clients. Using the functions example in this PR:

sync_client = SyncFunctionsClient("", {})
reveal_type(sync_client.invoke("my-function"))
# Union[JSON, bytes]
async_client = AsyncFunctionsClient("", {})
reveal_type(async_client.invoke("my-function"))
# Awaitable[Union[JSON, bytes]]

[In fact, this is a form of Higher Kinded Types in Python! HasExecutor is a family of functor types. If you're interested, you can read more here: https://sobolevn.me/2020/10/higher-kinded-types-in-python].

Summary by CodeRabbit

  • Refactor

    • Consolidated functions client implementation into a unified codebase for improved maintainability.
    • Extracted shared HTTP utilities into a new utilities module for broader reuse.
  • Chores

    • Simplified build configuration by removing code generation workflow.
    • Updated workspace configuration to support new utilities module.

✏️ Tip: You can customize this high-level summary in your review settings.

this way, we only need to write the whole client once, and we can
derive both IO implementations from a single source of truth, reducing
the amount of code needed for each package in half.
@o-santi
o-santi force-pushed the sync-async-refactor branch from ee7748e to 49f0014 Compare December 17, 2025 15:38
@coveralls

coveralls commented Dec 23, 2025

Copy link
Copy Markdown

Pull Request Test Coverage Report for Build 21220319510

Details

  • 0 of 0 changed or added relevant lines in 0 files are covered.
  • No unchanged relevant lines lost coverage.
  • Overall coverage decreased (-0.05%) to 91.088%

Totals Coverage Status
Change from base Build 21175256490: -0.05%
Covered Lines: 8872
Relevant Lines: 9740

💛 - Coveralls

@o-santi
o-santi changed the base branch from main to v3 January 21, 2026 18:12
this is because, inside the `http_endpoint` class definition, we have
the equivalent of the following assignment:
```
s : ServerEndpoint[Success, Failure]
a : ServerEndpoint[Awaitable[Success] | Success, Failure] = s
```
as such, we need to make `Awaitable[Success] | Success` a subtype of
`Success`, which means that it needs to be covariant!
I'm currently experimenting with other type checkers, and seeing if
they agree on the type stuff done in http. pyrefly accepts all of it,
but complains about the TypeAlias annotation in the json part. it isnt
necessary so I just removed it.
… TypedDicts

this is a partial commit, and is not fully working yet.
this ideally makes it more general, and lets endpoints specify how
they want to send information through in multiple ways. this is needed
to handle the case where we send bytes through edge functions directly

addionally, we're not using json.loads anymore, and relying solely on
pydantic's `to_json` machinery, which should be miles faster (written
in rust!)
@supabase supabase deleted a comment from coderabbitai Bot Jan 23, 2026
@coderabbitai

coderabbitai Bot commented Jan 23, 2026

Copy link
Copy Markdown

Important

Review skipped

Auto reviews are disabled on base/target branches other than the default branch.

Please check the settings in the CodeRabbit UI or the .coderabbit.yaml file in this repository. To trigger a single review, invoke the @coderabbitai review command.

You can disable this status message by setting the reviews.review_status to false in the CodeRabbit configuration file.

  • 🔍 Trigger a full review
✨ Finishing touches
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Post copyable unit tests in a comment
  • Commit unit tests in branch sync-async-refactor

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands and usage tips.

@grdsdev
grdsdev marked this pull request as ready for review January 28, 2026 09:56
Comment thread src/functions/src/supabase_functions/client.py Outdated
Comment thread src/functions/src/supabase_functions/errors.py

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 6

🤖 Fix all issues with AI agents
In `@src/functions/src/supabase_functions/client.py`:
- Around line 220-230: create_client currently passes verify as the third
positional argument to AsyncFunctionsClient/SyncFunctionsClient, but those
constructors treat the third positional parameter as timeout; change the calls
in create_client so verify is passed as a named keyword (e.g., verify=verify) or
use the correct parameter ordering expected by
AsyncFunctionsClient/SyncFunctionsClient; update both return paths in
create_client to call AsyncFunctionsClient(url, headers, verify=verify) and
SyncFunctionsClient(url, headers, verify=verify).

In `@src/functions/src/supabase_functions/errors.py`:
- Around line 38-42: In on_error_response, normalize the x-relay-header value
before comparing so casing/whitespace don't misclassify relay errors: retrieve
response.headers.get("x-relay-header"), guard for None, and compare its
stripped, lowercased form to "true" (use that normalized value in the
is_relay_error check) so FunctionsRelayError is returned correctly when the
header contains variants like " True " or "TRUE".

In `@src/functions/tests/_async/test_function_client.py`:
- Around line 58-71: In test_invoke_success_json update the mocked httpx
Response to mirror real behavior by setting mock_response.content to bytes
(e.g., b'{"message": "success"}') instead of a string; locate the mock_response
object in the test_invoke_success_json function and change its content
assignment, and make the same change in the test_invoke_with_region test so both
mocks use bytes for Response.content when exercising AsyncFunctionsClient.invoke
via mock_request (AsyncMock).

In `@src/functions/tests/_sync/test_function_client.py`:
- Around line 58-71: The mock Response in test_invoke_success_json uses a string
for mock_response.content but should use bytes to mirror httpx.Response; update
mock_response.content to b'{"message": "success"}' (and make the same change in
test_invoke_with_region) so client.invoke receives bytes like a real response.

In `@src/utils/Makefile`:
- Around line 1-23: The Makefile is missing .PHONY declarations and the
conventional all/test targets which causes checkmake warnings; add a .PHONY line
listing help tests mypy clean build all test and create an all target that
depends on help (or your intended default) and a test target that depends on
tests (or runs the same commands as tests), ensuring the existing targets (help,
tests, mypy, clean, build) remain unchanged and referenced by these aliases so
Make treats them as phony rather than file names.

In `@src/utils/pyproject.toml`:
- Around line 24-28: The documentation URL under [project.urls] is currently
non-browsable; update the documentation value so it points to the browsable
GitHub tree (use /tree/main/) — i.e., replace the existing documentation =
"https://github.com/supabase/supabase-py/src/utils" with a proper tree path such
as documentation = "https://github.com/supabase/supabase-py/tree/main/src/utils"
(or the correct docs directory) so the documentation link is valid; edit the
documentation key in the [project.urls] section to make this change.
🧹 Nitpick comments (1)
src/functions/src/supabase_functions/client.py (1)

75-108: Unused response_type parameter.

The response_type parameter is declared but never used in _invoke_options_to_request. Either implement the response type handling or remove the parameter to avoid confusion.

Comment thread src/functions/src/supabase_functions/client.py Outdated
Comment thread src/functions/src/supabase_functions/errors.py
Comment thread src/functions/tests/_async/test_function_client.py
Comment thread src/functions/tests/_sync/test_function_client.py
Comment thread src/utils/Makefile
Comment thread src/utils/pyproject.toml
Comment on lines +24 to +28
[project.urls]
homepage = "https://github.com/supabase/supabase-py"
repository = "https://github.com/supabase/supabase-py"
documentation = "https://github.com/supabase/supabase-py/src/utils"
changelog = "https://github.com/supabase/supabase-py/tree/main/CHANGELOG.md"

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟡 Minor

Fix the documentation URL path (currently non-browsable).

GitHub expects /tree/main/ for repository paths; the current value likely 404s.

🔗 Suggested fix
-documentation = "https://github.com/supabase/supabase-py/src/utils"
+documentation = "https://github.com/supabase/supabase-py/tree/main/src/utils"
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
[project.urls]
homepage = "https://github.com/supabase/supabase-py"
repository = "https://github.com/supabase/supabase-py"
documentation = "https://github.com/supabase/supabase-py/src/utils"
changelog = "https://github.com/supabase/supabase-py/tree/main/CHANGELOG.md"
[project.urls]
homepage = "https://github.com/supabase/supabase-py"
repository = "https://github.com/supabase/supabase-py"
documentation = "https://github.com/supabase/supabase-py/tree/main/src/utils"
changelog = "https://github.com/supabase/supabase-py/tree/main/CHANGELOG.md"
🤖 Prompt for AI Agents
In `@src/utils/pyproject.toml` around lines 24 - 28, The documentation URL under
[project.urls] is currently non-browsable; update the documentation value so it
points to the browsable GitHub tree (use /tree/main/) — i.e., replace the
existing documentation = "https://github.com/supabase/supabase-py/src/utils"
with a proper tree path such as documentation =
"https://github.com/supabase/supabase-py/tree/main/src/utils" (or the correct
docs directory) so the documentation link is valid; edit the documentation key
in the [project.urls] section to make this change.

@grdsdev
grdsdev self-requested a review January 28, 2026 17:27
rename classes with `endpoint` in their name. given that this is a
client, and not a server, these might not make sense

the initial idea was that these were supposed to hit a 'server
endpoint', but I think that the name is confusing after trying to use
it elsewhere

i've also split the one request class into multiple, because it makes
more sense in the context of the other packages
@o-santi
o-santi merged commit f818a1b into v3 Feb 3, 2026
35 checks passed
@o-santi
o-santi deleted the sync-async-refactor branch February 3, 2026 18:14
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants