Skip to content

workers-runtime-sdk 1.8.4: ASGI streaming responses pass a borrowed Future proxy to waitUntil #262

Description

@HoddyChen

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:

{
  "name": "workers-py-borrowed-proxy-repro",
  "main": "worker.py",
  "compatibility_date": "2026-09-13",
  "compatibility_flags": [
    "python_workers",
    "disable_python_external_sdk"
  ]
}

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:

  1. Removing the HTTP middleware makes the warning disappear.
  2. Adding only the no-op middleware shown above makes it appear after every
    successful response.
  3. 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?

Activity

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    No type

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions