Summary
A FastAPI response succeeds with HTTP 200, but a Pyodide borrowed-proxy error
is logged immediately afterward when the application uses FastAPI/Starlette
HTTP middleware.
The Workers ASGI adapter passes a still-running Python Future directly to
JavaScript waitUntil(). The implicit borrowed PyProxy is destroyed when the
Python call returns, while JavaScript still needs its .then property.
Minimal reproduction
worker.py:
from fastapi import FastAPI
from workers import asgi
app = FastAPI()
@app.middleware("http")
async def passthrough(request, call_next):
return await call_next(request)
@app.get("/health")
async def health():
return {"status": "ok"}
Default = asgi.entrypoint(app)
wrangler.jsonc:
Dependencies:
[project]
requires-python = ">=3.13"
dependencies = ["fastapi==0.112.2"]
Deploy, tail the Worker, and request /health:
wrangler deploy
wrangler tail workers-py-borrowed-proxy-repro --format pretty
curl -i https://workers-py-borrowed-proxy-repro.<subdomain>.workers.dev/health
A live reproduction is currently available at
https://workers-py-borrowed-proxy-repro.hoddy355.workers.dev/health.
Actual behavior
The endpoint returns successfully:
HTTP/2 200
content-type: application/json
{"status":"ok"}
The tail then reports:
GET https://workers-py-borrowed-proxy-repro.hoddy355.workers.dev/health - Ok
Error: This borrowed proxy was automatically destroyed at the end of a function call. Try using create_proxy or create_once_callable.
For more information about the cause of this error, use `pyodide.setDebug(true)`
at _getAttrs (pyodideRuntime-internal:emscriptenSetup:21802:17)
at Proxy._ensure_future (pyodideRuntime-internal:emscriptenSetup:22731:16)
at Proxy.then (pyodideRuntime-internal:emscriptenSetup:22745:23)
Expected behavior
The successful response should complete without a borrowed-proxy lifecycle
error.
Environment
- Wrangler:
4.132.0
- Compatibility date:
2026-09-13
- Compatibility flags:
python_workers, disable_python_external_sdk
- Python:
3.14.2
workers-runtime-sdk: 1.8.4 (injected by Pywrangler)
- FastAPI:
0.112.2
- Starlette:
0.38.6
- AnyIO:
4.15.1
- Deployed Worker version:
899b007d-f0fd-45e2-8841-d305f27059f5
The project does not declare workers-runtime-sdk as a dependency. Pywrangler
injects version 1.8.4 into python_modules during deployment.
Root cause
FastAPI's @app.middleware("http") uses Starlette BaseHTTPMiddleware, which
turns this otherwise buffered JSON response into an ASGI streaming response.
The Workers adapter can therefore return the JavaScript Response before the
ASGI request task has finished.
In src/workers/asgi.py, the unfinished finalizer task crosses the
Python/JavaScript boundary without an owned proxy:
wait_until(run_in_background(finalize_request()))
Pyodide creates a borrowed proxy for that Python Future. The borrowed proxy
is automatically destroyed at the end of the wait_until() call, but the
JavaScript Promise machinery subsequently accesses .then, producing the
reported stack.
Three isolation checks consistently reproduced the cause:
- Removing the HTTP middleware makes the warning disappear.
- Adding only the no-op middleware shown above makes it appear after every
successful response.
- Giving the finalizer an explicit
create_proxy() lifetime removes the
warning for repeated requests.
The WebSocket path in the same module already owns and destroys its task proxy
explicitly, so the HTTP finalizer can use the same pattern.
Verified fix
- wait_until(run_in_background(finalize_request()))
+ from pyodide.ffi import create_proxy
+
+ finalize_task = run_in_background(finalize_request())
+ finalize_proxy = create_proxy(finalize_task)
+
+ def destroy_finalize_proxy(_):
+ finalize_proxy.destroy()
+
+ finalize_task.add_done_callback(destroy_finalize_proxy)
+ wait_until(finalize_proxy)
I deployed this patch against the injected SDK and made three consecutive
requests. All returned HTTP 200 and none emitted the borrowed-proxy error.
Current main branch
The current main branch has since reworked process_request() and now creates
an explicit proxy for the ASGI application task before calling wait_until().
That code is not present in the latest published runtime SDK, 1.8.4, which is
what Pywrangler injected into this deployment on 2026-09-16.
Could you confirm whether the current main implementation is intended to fix
this borrowed-proxy path, and whether it can be included in the next runtime SDK
release with a regression test using a streaming ASGI response or Starlette
BaseHTTPMiddleware?
Summary
A FastAPI response succeeds with HTTP 200, but a Pyodide borrowed-proxy error
is logged immediately afterward when the application uses FastAPI/Starlette
HTTP middleware.
The Workers ASGI adapter passes a still-running Python
Futuredirectly toJavaScript
waitUntil(). The implicit borrowedPyProxyis destroyed when thePython call returns, while JavaScript still needs its
.thenproperty.Minimal reproduction
worker.py:wrangler.jsonc:{ "name": "workers-py-borrowed-proxy-repro", "main": "worker.py", "compatibility_date": "2026-09-13", "compatibility_flags": [ "python_workers", "disable_python_external_sdk" ] }Dependencies:
Deploy, tail the Worker, and request
/health:A live reproduction is currently available at
https://workers-py-borrowed-proxy-repro.hoddy355.workers.dev/health.
Actual behavior
The endpoint returns successfully:
The tail then reports:
Expected behavior
The successful response should complete without a borrowed-proxy lifecycle
error.
Environment
4.132.02026-09-13python_workers,disable_python_external_sdk3.14.2workers-runtime-sdk:1.8.4(injected by Pywrangler)0.112.20.38.64.15.1899b007d-f0fd-45e2-8841-d305f27059f5The project does not declare
workers-runtime-sdkas a dependency. Pywranglerinjects version 1.8.4 into
python_modulesduring deployment.Root cause
FastAPI's
@app.middleware("http")uses StarletteBaseHTTPMiddleware, whichturns this otherwise buffered JSON response into an ASGI streaming response.
The Workers adapter can therefore return the JavaScript
Responsebefore theASGI request task has finished.
In
src/workers/asgi.py, the unfinished finalizer task crosses thePython/JavaScript boundary without an owned proxy:
Pyodide creates a borrowed proxy for that Python
Future. The borrowed proxyis automatically destroyed at the end of the
wait_until()call, but theJavaScript Promise machinery subsequently accesses
.then, producing thereported stack.
Three isolation checks consistently reproduced the cause:
successful response.
create_proxy()lifetime removes thewarning for repeated requests.
The WebSocket path in the same module already owns and destroys its task proxy
explicitly, so the HTTP finalizer can use the same pattern.
Verified fix
I deployed this patch against the injected SDK and made three consecutive
requests. All returned HTTP 200 and none emitted the borrowed-proxy error.
Current
mainbranchThe current
mainbranch has since reworkedprocess_request()and now createsan explicit proxy for the ASGI application task before calling
wait_until().That code is not present in the latest published runtime SDK,
1.8.4, which iswhat Pywrangler injected into this deployment on 2026-09-16.
Could you confirm whether the current
mainimplementation is intended to fixthis borrowed-proxy path, and whether it can be included in the next runtime SDK
release with a regression test using a streaming ASGI response or Starlette
BaseHTTPMiddleware?