aio-libs/aiohttp · error · HTTPBadRequest

No WebSocket UPGRADE hdr

Error message

No WebSocket UPGRADE hdr: {headers.get(hdrs.UPGRADE)}\n Can "Upgrade" only to "WebSocket".

What it means

During the WebSocket handshake, _handshake() verifies the HTTP UPGRADE header equals 'websocket' (case-insensitive, trimmed). If a non-WebSocket request reaches a WebSocketResponse.prepare() (or the route handler invokes the WS handshake on a regular HTTP request), aiohttp returns HTTP 400 with this message. The handshake is invoked from prepare() via _pre_start, so the error surfaces as an HTTPException that aiohttp renders as a 400 response.

Solutions

  1. Call ws.can_prepare(request) first and branch: if not ws.can_prepare(request).ok: return web.Response(...).
  2. Ensure proxies forward Upgrade/Connection ('Connection' header passthrough) — e.g. nginx: proxy_set_header Upgrade $http_upgrade;.
  3. Point the client at ws:// or wss:// URLs so it sends the correct Upgrade header.

Example fix

// before
ws = web.WebSocketResponse()
await ws.prepare(request)  # 400 if not a WS upgrade
// after
ws = web.WebSocketResponse()
if not ws.can_prepare(request).ok:
    return web.Response(status=400, text='WebSocket connection required')
await ws.prepare(request)
Defensive patterns

Strategy: validation

Validate before calling

async def ws_handler(request):
    ws = web.WebSocketResponse()
    if not ws.can_prepare(request).ok:
        return web.Response(status=400, text='WebSocket upgrade required')
    await ws.prepare(request)
    # ... ws loop ...

Type guard

def is_websocket_upgrade(request) -> bool:
    return request.headers.get('Upgrade', '').lower().strip() == 'websocket'

Try / catch

ws = web.WebSocketResponse()
try:
    await ws.prepare(request)
except web.HTTPBadRequest as e:
    # client didn't send a valid WS upgrade
    return web.Response(status=400, text='upgrade required')

Prevention

When it happens

Trigger: A browser issues a normal GET to a path whose handler unconditionally calls await ws.prepare(request); a reverse proxy strips the Upgrade header; the client used a different upgrade token (e.g. 'h2c' for HTTP/2 upgrade); a curl request without -H 'Upgrade: websocket'.

Common situations: Sharing one route between WS and HTTP without checking; proxies/load balancers (nginx) not forwarding Upgrade; clients connecting with the wrong library or a plain HTTP request; WebSocket endpoint hit by a health-check probe.

Related errors


AI-assisted analysis of aio-libs/aiohttp@d041d4d0fd (2026-08-11). Data as JSON: /api/errors/ebd2ee51dcda576b. Report an issue: GitHub.

Appendix: source

Thrown at aiohttp/web_ws.py:275

    async def prepare(self, request: BaseRequest) -> AbstractStreamWriter:
        # make pre-check to don't hide it by do_handshake() exceptions
        if self._payload_writer is not None:
            return self._payload_writer

        protocol, writer = self._pre_start(request)
        payload_writer = await super().prepare(request)
        assert payload_writer is not None
        self._post_start(request, protocol, writer)
        await payload_writer.drain()
        return payload_writer

    def _handshake(
        self, request: BaseRequest
    ) -> tuple["CIMultiDict[str]", str | None, int, bool]:
        headers = request.headers
        if "websocket" != headers.get(hdrs.UPGRADE, "").lower().strip():
            raise HTTPBadRequest(
                text=(
                    f"No WebSocket UPGRADE hdr: {headers.get(hdrs.UPGRADE)}\n Can "
                    '"Upgrade" only to "WebSocket".'
                )
            )

        if not request._message.upgrade:
            raise HTTPBadRequest(
                text=f"No CONNECTION upgrade hdr: {headers.get(hdrs.CONNECTION)}"
            )

        # find common sub-protocol between client and server
        protocol: str | None = None
        if hdrs.SEC_WEBSOCKET_PROTOCOL in headers:
            req_protocols = [
                str(proto.strip())
                for proto in headers[hdrs.SEC_WEBSOCKET_PROTOCOL].split(",")
            ]

View on GitHub (pinned to d041d4d0fd)