Skip to content

feat(utils): refactor supabase_utils.http to be client agnostic#1433

Merged
o-santi merged 20 commits intov3from
utils/http-client-agnostic
Apr 8, 2026
Merged

feat(utils): refactor supabase_utils.http to be client agnostic#1433
o-santi merged 20 commits intov3from
utils/http-client-agnostic

Conversation

@o-santi
Copy link
Copy Markdown
Contributor

@o-santi o-santi commented Mar 30, 2026

What kind of change does this PR introduce?

Remove HTTP dependence on httpx. The idea is to be able to plug in any http session and have the client send it's requests to it, instead of hard coding the calls to httpx's http session.

In order to be completely free from httpx, it's also needed to introduce alternatives to Headers, Query, Request and Response; so they were created inside supabase_utils.http and all references to these httpx types were refactored.

Some tests relied heavily upon the way httpx strinfigies the Headers or Query types (looking at you, postgrest), and so I had to fix them in order to be a little bit less reliant on that.

Fixes: #1412.

@grdsdev
Copy link
Copy Markdown
Contributor

grdsdev commented Apr 8, 2026

Code review

Found 6 issues:

  1. Debug print(content) left in MultipartFormDataRequest.finalize() — prints raw binary multipart data to stdout on every file upload in production.

https://github.com/supabase/supabase-py/blob/ae9959534bfd3261527c62ccdd257ac0456c36e7/src/utils/src/supabase_utils/http/request.py#L196-L198

  1. Prefer header fragmented into multiple separate headerspre_insert, pre_upsert, pre_update build the Prefer header by calling .set("Prefer", value) multiple times. Because Headers.set() appends to a PVector and iter_items() yields one tuple per value, httpx receives e.g. [("Prefer", "return=representation"), ("Prefer", "count=exact")] rather than a single Prefer: return=representation,count=exact header. RFC 7240 expects comma-separated preferences in a single header; PostgREST may reject or misinterpret split headers.

if count:
prefer_headers.append(f"count={count}")
if upsert:
prefer_headers.append("resolution=merge-duplicates")
if not default_to_null:
prefer_headers.append("missing=default")
headers = Headers({"Prefer": ",".join(prefer_headers)})
# Adding 'columns' query parameters
query_params = {}

  1. Response.is_error excludes HTTP 501–599 — defined as 400 <= self.status <= 500, so any 5xx status above 500 (501 Not Implemented, 502 Bad Gateway, 503 Service Unavailable, etc.) returns False. The original httpx.Response.is_error uses status >= 400. Auth helpers branch on response.is_error to distinguish retryable from API errors; 5xx codes above 500 will silently fall into the wrong branch.

content: bytes | None
class ToRequest(Protocol):
def finalize(self, base_url: URL, default_headers: Headers) -> Request: ...
@dataclass

  1. Regression of fix 50b099f — commit 50b099f ("fix(storage3): replace print() with warnings.warn() for trailing slash notice") deliberately changed the trailing-slash warning to warnings.warn(). The refactor into a single client.py reverts this back to print(), and the warnings import is also missing.

print("Storage endpoint URL should have a trailing slash.")
url += "/"
self.base_url = URL(url)

  1. SyncClientOptions.replace() silently drops the realtime field — the realtime parameter and the line client_options.realtime = realtime or self.realtime are present in AsyncClientOptions.replace() but missing from the sync counterpart, so sync clients lose realtime configuration on any replace() call.

] = DEFAULT_POSTGREST_CLIENT_TIMEOUT,
storage_client_timeout: Optional[int] = None,
flow_type: Optional[AuthFlowType] = None,
) -> "AsyncClientOptions":
"""Create a new SupabaseClientOptions with changes"""
client_options = AsyncClientOptions()
client_options.schema = schema or self.schema
client_options.headers = headers or self.headers
client_options.auto_refresh_token = (
auto_refresh_token or self.auto_refresh_token
)
client_options.persist_session = persist_session or self.persist_session
client_options.storage = storage or self.storage
client_options.realtime = realtime or self.realtime
client_options.httpx_client = httpx_client or self.httpx_client
client_options.postgrest_client_timeout = (
postgrest_client_timeout or self.postgrest_client_timeout
)
client_options.storage_client_timeout = (
storage_client_timeout or self.storage_client_timeout
)

  1. URLQuery.from_mapping() lowercases query parameter keyskey.lower() is applied in from_mapping() but not in set(), creating an inconsistency. HTTP query parameter names are case-sensitive; user-supplied params with mixed case (e.g. OAuth display_language, custom params) passed through from_mapping are silently downcased while the same keys passed through set() are preserved, producing different query strings depending on the code path.

map: PMap[str, PVector[QueryValue]] = PMap()
for key, val in mapping.items():
map = map.set(key.lower(), Vec(val))
return URLQuery(pmap=map)

🤖 Generated with Claude Code

- If this code review was useful, please react with 👍. Otherwise, react with 👎.

@o-santi o-santi merged commit bfc2bc8 into v3 Apr 8, 2026
34 checks passed
@o-santi o-santi deleted the utils/http-client-agnostic branch April 8, 2026 14:41
@o-santi o-santi mentioned this pull request Apr 8, 2026
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