Early HTTP/2 response during an upload is lost until the read timeout

Open
#1,294 0 comments 0 reactions 0 assignees View on GitHub

Maintainers usually reply within 1 day

@Kludex is already working on this.

Since Oct 23, 2026.

  • #1295 by @Kludex — closed without merging
  • #1298 by @Kludex — open

Assessment

Difficulty
4/5
Estimated time
3-5 days
Newbie friendliness
35/100
Issue type
Bug
Clarity
Clearly specified
Activity status
Stale
Tech stack
python

Research direction

Start with _send_request_body and _receive_response in the HTTP/2 connection implementation, then trace how flow-control waits queue stream events. Reproduce the issue with the supplied script, which expects status 413 but times out. Done means the client stops uploading on StreamEnded or StreamReset, handles the reset correctly, and returns the queued response; linked pull requests 1295 and 1298 indicate work is already underway.

Written by the indexing model from the issue text.

Description

bug

When a server answers an HTTP/2 upload before the body is complete, the client keeps uploading. If the window is exhausted it waits for a WINDOW_UPDATE that never comes and fails with ReadTimeout; if the server reset the stream, the next DATA frame raises LocalProtocolError. The response is lost either way.

RFC 9113 §8.1 covers this case: a server "MAY request that the client abort transmission of a request without error by sending a RST_STREAM with an error code of NO_ERROR after sending a complete response", and "Clients MUST NOT discard responses as a result of receiving such a RST_STREAM". RFC 9112 §9.5 asks the same of HTTP/1.1 clients, and the HTTP/1.1 implementation already handles it.

Typical trigger: a 413 Content Too Large, 401, or 403 sent as soon as the request headers arrive, while the client is still streaming a large body.

Reproduction

import asyncio

import h2.errors
import h2.settings
import hpack
import hyperframe.frame

import httpcore2


async def body():
    for _ in range(10):
        yield b"x" * 20_000


class QuietAfterScript(httpcore2.AsyncMockStream):
    async def read(self, max_bytes, timeout=None):
        if not self._buffer:
            await asyncio.sleep(10)  # a real server has nothing more to say
        return await super().read(max_bytes, timeout)


async def main():
    stream = QuietAfterScript(
        [
            hyperframe.frame.SettingsFrame(settings={h2.settings.SettingCodes.INITIAL_WINDOW_SIZE: 1000}).serialize(),
            hyperframe.frame.HeadersFrame(
                stream_id=1,
                data=hpack.Encoder().encode([(b":status", b"413")]),
                flags=["END_HEADERS", "END_STREAM"],
            ).serialize(),
            hyperframe.frame.RstStreamFrame(stream_id=1, error_code=h2.errors.ErrorCodes.NO_ERROR).serialize(),
        ]
    )
    origin = httpcore2.Origin(b"https", b"example.com", 443)
    async with httpcore2.AsyncHTTP2Connection(origin=origin, stream=stream) as conn:
        response = await asyncio.wait_for(
            conn.request("POST", "https://example.com/", headers={"content-length": "200000"}, content=body()),
            timeout=2,
        )
        print(response.status)


asyncio.run(main())

Expected: 413. Actual: TimeoutError (ReadTimeout under httpx2 defaults) after 4 of 10 chunks.

Cause

_send_request_body iterates the whole body before _receive_response is called. The server's frames are read and queued into the stream's event list by the flow-control wait, but the send loop only ever looks at the flow-control window.

Fix

Check the stream's queued events in the send loop. On StreamEnded or StreamReset, stop uploading, send RST_STREAM(NO_ERROR) unless the server already reset the stream (what Go's net/http does), and return the queued response.

Dominant language
Python
Stars
1.6k
Forks
103
Avg merge
1d 19h
Merged PRs (30d)
25

Getting set up

First steps

  1. Read the whole issue, then the project's contributing guide.
  2. Comment on the issue to say you are picking it up — it saves two people doing the same work.
  3. Fork the repository and make your change on a branch.
  4. Open a pull request that references the issue number.

More from pydantic/httpx2

All issues in pydantic/httpx2

Similar issues

More Python issues

Get new issues in your inbox

A short digest of beginner-friendly GitHub issues.