Skip to content

fix: resume interrupted file downloads with byte-range retries - #3764

Open
CacinieP wants to merge 10 commits into
openai:mainfrom
CacinieP:fix/resumable-file-download
Open

fix: resume interrupted file downloads with byte-range retries#3764
CacinieP wants to merge 10 commits into
openai:mainfrom
CacinieP:fix/resumable-file-download

Conversation

@CacinieP

@CacinieP CacinieP commented Aug 31, 2026

Copy link
Copy Markdown

Maintainer summary (30s TL;DR)

Non-streamed GET file downloads (e.g. client.files.content()) now resume automatically with byte-range requests when the origin supports it, fixing #2959. Servers without Accept-Ranges: bytes, streamed responses, and non-GET requests keep today's behavior byte-for-byte. Seven rounds of automated review drove the edge cases below; each is covered by a focused test:

Edge case / risk How it is handled Test
Resource changed between attempts Resume only with a strong ETag (If-Range); weak ETags / Last-Modified / no validator restart from byte 0 test_resume_sends_if_range_with_strong_etag, test_weak_etag_is_not_used_as_validator, test_last_modified_alone_is_not_a_validator
Compressed bodies Only Content-Encoding: identity resumes; gzip/br restart cleanly test_encoded_bodies_are_not_resumed
Connection cut at the very end A 416 whose Content-Range total matches the received bytes completes without a re-download test_completes_download_that_was_cut_at_the_very_end
Truncated or lying 206s Delivered length must equal end - start + 1; short segments resume from the new offset; unknown * totals restart test_short_partial_segments_resume_until_complete, test_content_range_body_mismatch_restarts
Misaligned or multi-unit ranges A 206 not starting at the requested offset restarts; Accept-Ranges: bytes, other-unit is recognized test_restarts_when_content_range_does_not_match_offset, test_accept_ranges_with_multiple_units
Task cancellation / failed reads aclose() under anyio.CancelScope(shield=True); failed streams are closed before retrying test_real_task_cancellation_closes_the_response
Provider & hook compatibility Bodies are consumed via a body_reader before normalize_response; retained responses keep their own exchange's body test_provider_normalization_sees_read_response, test_completing_response_stays_readable
Large payloads Buffer is handed off rather than retained twice; 32 MiB+ in-memory probes, sync and async, per the repo's large-payload contract test_large_resumable_download

As noted in the comments, I'm happy to move the helpers out of _base_client.py into SDK-owned files under lib/ (cf. #3712/#3713) or otherwise reshape the patch — whatever minimizes template intrusion.


Fixes #2959

Problem

Downloading large Batch API result files (the report is about >200–300 MB .jsonl outputs) fails with httpcore.RemoteProtocolError: peer closed connection without sending complete message body. The cutoff reproduces with plain requests against the same URL, so it is the long-lived single connection that dies, not the library — but the SDK's retries currently make the situation impossible to recover from: client.files.content() reads the body inside the retry loop, and each retry restarts the download from byte 0. When a transfer is cut at a deterministic size/duration threshold, every retry attempt dies at (roughly) the same point and max_retries just repeats the failure. The reporter's workaround — a manual chunked Range download with backoff — confirms the origin serves byte ranges happily.

The previous attempt in #2985 looped iter_bytes() in 1 MB chunks inside LegacyAPIResponse.content, which doesn't change the network behaviour (httpx already reads incrementally) and broke re-accessing .content on a consumed response.

Change

GET retries in _base_client.py (sync + async) now resume an interrupted response body instead of restarting it, when the server allows it:

  • attempt 1 is byte-for-byte today's behaviour, except that the body is read through iter_bytes() so whatever arrives before a transport error is kept;
  • if the body read dies mid-transfer with a retryable error, the next attempt sends Range: bytes=<received>-; a 206 appends the missing tail to the partial body, a 200 from a range-ignoring server replaces the buffer;
  • a 416 whose Content-Range: bytes */<total> total equals the bytes already received means the previous attempt actually received everything and only the terminating chunks went missing — the response is assembled from what we have;
  • an unusable 416 (mismatched total, e.g. the object changed between attempts) clears the partial buffer and raises a ReadError so the normal retry machinery restarts from scratch;
  • once complete, the response handed to response processing is rebuilt with the full body (original headers minus content-range/content-length, which is re-derived), status 200.

Scope is deliberately conservative:

  • only applies to non-streamed GETs whose response advertises Accept-Ranges: bytes; JSON endpoints and every other origin keep the current full-restart behaviour (verified by test_restarts_from_scratch_when_server_has_no_ranges and test_plain_get_is_unaffected);
  • streamed responses (stream=True, with_streaming_response) are untouched;
  • no public API changes.

Tests

tests/test_resumable_downloads.py uses a stateful mock transport whose first response dies mid-body, covering sync + async resume, no-accept-ranges fallback, range-ignored servers, the cut-at-the-very-end 416 case, exhausted retries, and the untouched plain-JSON GET path. ruff check, ruff format, and mypy pass on the changed files; tests/test_client.py, tests/test_httpx2.py, tests/test_legacy_response.py, and tests/test_resumable_downloads.py pass together (235 passed, 2 skipped).

Notes for reviewers

  • This adds ~145 lines to the template-owned _base_client.py, so it will show up in the custom-code report; happy to restructure (e.g. move the helpers into a separate handwritten module) if that helps the budget, or to coordinate a template-level change if cross-SDK consistency is preferred.
  • Security-wise this does not touch auth headers, redirects, or proxy handling; the only new outbound header is Range, and only for GETs that already received an Accept-Ranges: bytes response from the same origin.

Downloading large Batch API result files (>200-300 MB) can fail because
long-lived single connections are cut off mid-body; retries restart the
download from the first byte, so they can never make progress when the
transfer dies at a deterministic size or duration threshold.

GET retries now resume an interrupted body with Range requests when the
server advertises Accept-Ranges: bytes:

- the first attempt behaves exactly as before (full download);
- if the body read dies mid-transfer with a retryable error, later
  attempts request only the missing bytes (206 appends, a 200 from a
  range-ignoring server replaces the buffer);
- a 416 whose Content-Range total matches the bytes already received
  means the download completed and only the terminating chunks went
  missing, so the response is assembled from what we have;
- servers without Accept-Ranges: bytes keep today's full-restart
  behaviour, and streamed responses are untouched.

Fixes openai#2959
@CacinieP
CacinieP requested a review from a team as a code owner August 31, 2026 04:39
@chatgpt-codex-connector

chatgpt-codex-connector Bot commented Aug 31, 2026

Copy link
Copy Markdown

Codex Review Summary

This comment shows the latest Codex review activity on this pull request.

Review Status Commit Review trigger
📝 Code Review Completed 2026-09-03T16:42:01.421398Z 836e3a4 New commits
🔒 Security Review Completed 2026-09-03T16:38:55.673471Z 836e3a4 New commits
ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review" or "@codex security review".

Codex reacts with 👀 while any review is running, comments if it has suggestions, and reacts with 👍 once all reviews finish with no findings.

@chatgpt-codex-connector chatgpt-codex-connector Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: 0f1f691280

ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".

Comment thread src/openai/_base_client.py Outdated
Comment on lines +1069 to +1071
if resuming and response.status_code == 206:
# partial content; append the missing tail to what we already have
accumulate = True

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 Badge Validate the representation before appending a 206

When the resource changes between the interrupted attempt and the retry, this accepts any 206 and appends it without checking the prior/current ETag or that Content-Range begins at len(partial). A same-sized replacement therefore produces a successful 200 containing the old prefix and new suffix; the 416 total check does not protect this case. Preserve a strong validator and use If-Range, and validate the returned range before appending.

Useful? React with 👍 / 👎.

Comment thread src/openai/_base_client.py Outdated
Comment on lines +1081 to +1082
for chunk in response.iter_bytes():
partial.extend(chunk)

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Request identity encoding before computing byte ranges

When an origin returns Content-Encoding: gzip or br together with Accept-Ranges, iter_bytes() yields decoded bytes, so len(partial) is not an offset in the encoded representation to which HTTP byte ranges apply. The retry consequently requests the wrong offset, and an encoded slice generally cannot be decoded independently, leaving compressed large downloads unrecoverable or incorrectly assembled. Force identity encoding for resumable requests or accumulate raw representation bytes and decode only after reassembly; the async aiter_bytes() path has the same issue.

Useful? React with 👍 / 👎.

Comment thread src/openai/_base_client.py Outdated

for chunk in response.iter_bytes():
partial.extend(chunk)
return _reassembled_download_response(request, response, bytes(partial))

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Avoid retaining two copies of every range-capable body

On every successful non-streamed GET advertising byte ranges, including transfers that never fail, bytes(partial) copies the entire accumulated bytearray, while partial_download remains referenced until request() finishes response processing. For the targeted 200–300 MB downloads this retains roughly two complete body buffers and can cause avoidable OOMs; release or transfer the mutable buffer promptly and avoid the full-size copy on the normal success path.

AGENTS.md reference: AGENTS.md:L114-L119

Useful? React with 👍 / 👎.

Comment thread src/openai/_base_client.py Outdated
Comment on lines +1175 to +1176
if resumable_attempt:
response = self._read_resumable_body(request, response, partial_download)

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Close streamed responses when body reads fail

Because resumable attempts now call the HTTP client with stream=True, a timeout or protocol error raised while _read_resumable_body() iterates the body occurs after Client.send() has returned and therefore bypasses the client's exception cleanup. HTTPX leaves both the response and its stream open when iter_bytes() raises, and these exception handlers retry or raise without closing response, so repeated interrupted downloads can leak transport resources; close/aclose the failed response before retrying or propagating. The async path has the same problem.

AGENTS.md reference: AGENTS.md:L114-L119

Useful? React with 👍 / 👎.

Comment thread src/openai/_base_client.py Outdated
Comment on lines +1057 to +1060
if total is not None and total == len(partial):
# the earlier attempt already received every byte; only the
# terminating chunks went missing
return _reassembled_download_response(request, response, bytes(partial))

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Preserve the original headers when completing via 416

When the first attempt delivered every byte and the retry receives 416, this rebuilds the successful response from the 416 response headers rather than the original representation headers. A normal 416 need only provide Content-Range, so the returned full body can lose Content-Type, Content-Disposition, ETag, and other metadata that callers of files.content() previously received. Retain the initial response headers and use them for this completion path.

Useful? React with 👍 / 👎.

Comment thread src/openai/_base_client.py Outdated
Comment on lines +153 to +154
return httpx2.Response(
200,

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Rebuild responses with the active HTTPX implementation

When a caller uses the supported legacy httpx.Client, the response passed here is a legacy httpx.Response, but reconstruction always creates an httpx2.Response. Thus a range-capable GET requested with cast_to=httpx.Response returns an object of the wrong runtime class after this change, despite the base client explicitly accepting both response types. Construct the replacement using the incoming response's implementation or otherwise preserve the configured client's response type.

Useful? React with 👍 / 👎.

Comment thread src/openai/_base_client.py Outdated
200,
headers=headers,
content=content,
request=request,

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Preserve the final request when rebuilding the response

For a range-capable GET that follows a redirect, response.request identifies the final redirected request, but reconstruction replaces it with the originally built request passed into this helper. The returned raw/binary response therefore reports the wrong URL and request headers, and its request is inconsistent with the preserved redirect history. Use response.request when constructing the assembled response; this also preserves request replacements made by authentication flows.

Useful? React with 👍 / 👎.

…anup

Addresses the Codex review findings on the resume logic:

- send If-Range with the interrupted response's strong validator (ETag,
  falling back to Last-Modified) so a changed resource is re-downloaded
  in full instead of being spliced from two different versions
- only resume identity-encoded bodies: iter_bytes() yields decoded bytes
  while byte ranges address the encoded representation, so gzip/br
  downloads restart from scratch instead of resuming at a wrong offset
- verify a 206's Content-Range starts at len(partial); a misaligned or
  missing range drops the partial state and restarts
- close the response (aclose on the async path) when a body read fails
  mid-stream instead of leaking the connection across retries
- rebuild the completed response from the interrupted attempt's headers
  and its final (post-redirect, post-auth) request, keeping the original
  Content-Type/Content-Disposition on the 416 completion path and the
  response class of whichever httpx implementation served the request
- release the accumulated bytearray as soon as the body is reassembled
  so a 200-300 MB download doesn't hold two full buffers
@CacinieP

Copy link
Copy Markdown
Author

Thanks for the thorough review — all seven findings are addressed in 222e2df:

  • P1 (validate the representation before appending a 206): resume attempts now send If-Range with the interrupted response's strong validator (ETag, falling back to Last-Modified; weak W/ ETags are skipped per RFC 9110 §13.1.5), so a changed resource comes back as a full 200 which the existing reset path replaces. Independently, a 206 is only appended when its Content-Range starts at exactly len(partial); anything else drops the partial state and restarts from scratch. Covered by test_resume_sends_if_range_with_strong_etag, test_weak_etag_is_not_used_as_validator, and test_restarts_when_content_range_does_not_match_offset.
  • Encoded representations: the first attempt only becomes resumable when the response is identity-encoded (Content-Encoding absent or identity); gzip/br downloads keep today's full-restart behaviour, since ranges address the encoded representation while iter_bytes() counts decoded bytes. Covered by test_encoded_bodies_are_not_resumed.
  • Double buffering: the accumulated bytearray is released as soon as the final response is assembled, before response processing continues.
  • Stream leaks on failed reads: the failed response is closed (aclosed on the async path) before the read error propagates to the retry handlers, on the mid-body failure path and both 416/misaligned-range paths.
  • 416 completion headers: the assembled response is now always rebuilt from the interrupted attempt's headers — never the 416's — so Content-Type, Content-Disposition, etc. survive; covered by test_416_completion_preserves_original_response_headers.
  • Response class: reconstruction uses the class of the representation response, so legacy httpx clients get a legacy httpx.Response back.
  • Final request: reassembly uses the representation's own request (post-redirect, post-auth) rather than the locally built one.

Happy to iterate further if anything still looks off.

@chatgpt-codex-connector chatgpt-codex-connector Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: 222e2dfc24

ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".

Comment on lines +1240 to +1242
validator = _resume_validator(representation)
if validator is not None:
request.headers["If-Range"] = validator

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 Badge Require a strong validator before resuming

When the interrupted response has no strong validator—such as no ETag or the weak ETag explicitly covered by the new test—this still sends Range without If-Range and accepts an aligned 206. If the resource changes between attempts, the returned body silently combines the old prefix with the new suffix. Fresh evidence beyond the earlier comment is that the updated code makes the validator optional and test_weak_etag_is_not_used_as_validator confirms this unguarded path; restart from byte zero unless a genuinely strong validator is available.

Useful? React with 👍 / 👎.

Comment thread src/openai/_base_client.py Outdated
Comment on lines +1145 to +1147
if not accumulate:
response.read()
return response

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Close failed fallback body reads

When a non-streamed GET does not advertise byte ranges, or uses non-identity encoding, resumable_attempt still sends it with stream=True and this unguarded response.read() can raise while leaving the response stream open. Fresh evidence beyond the earlier cleanup comment is that only the accumulating iter_bytes() branch was wrapped; this fallback and its async counterpart at line 1856 still bypass cleanup, so repeated interrupted ordinary or compressed GETs can leak transport resources. Wrap these reads with the same close-on-failure handling.

AGENTS.md reference: AGENTS.md:L114-L119

Useful? React with 👍 / 👎.

Comment thread src/openai/_base_client.py Outdated
Comment on lines +201 to +204
return response_cls(
200,
headers=headers,
content=content,

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Preserve elapsed timing on rebuilt responses

For every successful 200 GET that advertises byte ranges—even when no retry occurs—this constructs a fresh response around a plain in-memory byte stream without copying or setting the original response's elapsed timing. Consequently, callers using a raw response or cast_to=httpx.Response now get a RuntimeError when accessing .elapsed, although the body has been fully read and closed. Preserve the served response's elapsed value on the replacement response.

Useful? React with 👍 / 👎.

Comment thread src/openai/_base_client.py Outdated
Comment on lines +1122 to +1125
start = _range_start_bytes(response)
if start is not None and start == len(partial.data):
# partial content; append the missing tail to what we already have
accumulate = True

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Verify that a 206 reaches the representation end

When an origin or intermediary returns a cleanly framed 206 whose Content-Range begins at the requested offset but ends before the advertised total, this start-only check accepts the segment and immediately finishes the download. Because the shorter segment can terminate normally with a matching Content-Length, no transport error triggers another retry, and the SDK returns a truncated body as a successful 200. Parse the range end and total and continue or reject unless the assembled bytes reach the complete representation length; the async branch has the same issue.

AGENTS.md reference: AGENTS.md:L114-L121

Useful? React with 👍 / 👎.

Copy link
Copy Markdown
Author

@codex address that feedback

Please address all four findings from the latest review on 222e2dfc: require a genuinely strong validator before resuming, close failed fallback read()/aread() responses, preserve elapsed on rebuilt responses, and fully validate/continue partial 206 ranges until the complete representation length is reached. Add focused sync and async regression coverage, and keep the change scoped to the existing resumable-download implementation.

@chatgpt-codex-connector

Copy link
Copy Markdown

Note

To use Codex here, create an environment for this repo.

…leteness

Second round of Codex review hardening:

- only resume when the interrupted response carries a strong validator
  (strong ETag or Last-Modified); without one the download restarts from
  byte zero instead of risking a silent splice of two resource versions
- accept a 206 only when its Content-Range covers the representation
  total: a satisfying-but-short segment keeps its bytes and the next
  attempt resumes from the new offset, so intermediaries that truncate
  ranged responses can no longer produce a truncated 'successful' body
- wrap the non-accumulating response.read()/aread() fallback with the
  same close-on-failure handling as the streaming path
- copy the serving response's elapsed timing onto the rebuilt response
  so raw-response callers keep a working .elapsed
@CacinieP

Copy link
Copy Markdown
Author

Thanks — the second round is addressed in a39c2e6:

  • Require a strong validator before resuming: a response without a strong validator (strong ETag, or Last-Modified per RFC 9110 §13.1.5) is no longer eligible for resuming at all — the interrupted download restarts from byte zero rather than risk splicing two versions. The accumulation gate now includes _resume_validator(response) is not None, so Range/If-Range are only ever sent when the validator that guards them exists. Covered by test_no_resume_without_any_validator and the updated test_weak_etag_is_not_used_as_validator.
  • Verify a 206 reaches the representation end: Content-Range is now parsed for start/end/total; a satisfying-but-short segment keeps the bytes it delivered and the next retry resumes from the new offset (test_short_partial_segments_resume_until_complete), so a truncated segment can no longer complete the download silently. Segments whose total is unknown/unparseable keep the restart behaviour.
  • Close failed fallback body reads: the non-accumulating read()/aread() fallback is wrapped with the same close-on-failure handling as the streaming path, sync and async.
  • Preserve elapsed timing: the rebuilt response copies the serving response's elapsed when the transport recorded one (verified against a real local HTTP server that the stock send() path sets it and the rebuilt response previously lost it); when unavailable it stays unset exactly like an unread response.

One note on the strict validator gate: origins that serve Accept-Ranges: bytes but never provide an ETag/Last-Modified will now keep today's full-restart behaviour rather than resuming — trading that resumability for not having to guess whether the bytes still belong to the same representation.

@chatgpt-codex-connector chatgpt-codex-connector Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: a39c2e6758

ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".

Comment thread src/openai/_base_client.py Outdated
Comment on lines +196 to +197
last_modified = str(response.headers.get("last-modified", "")).strip()
return last_modified or None

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Require a demonstrably strong Last-Modified validator

When a range-capable response lacks a strong ETag but includes an ordinary Last-Modified, this treats the date as strong without checking whether it meets the strong-validator timing requirements. If the resource changes within the header's timestamp granularity, the retry's If-Range can still receive a 206, causing the old prefix and new suffix to be silently combined. Fresh evidence beyond the earlier validator comment is that _resume_validator() now unconditionally falls back to any nonempty Last-Modified; restart unless the date can actually be established as strong, or require a strong ETag.

Useful? React with 👍 / 👎.

Comment thread src/openai/_base_client.py Outdated
Comment on lines +1191 to +1192
bounds = _content_range_bounds(response)
if bounds is not None and resuming and bounds[2] is not None and bounds[2] != len(partial.data):

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Keep resuming partial segments with unknown totals

When a resumed server returns a valid bounded segment such as Content-Range: bytes 4096-4999/*, bounds[2] is None, so this completion check is skipped and the helper immediately rebuilds a successful 200 containing only the prefix plus that segment even though more representation bytes may remain. Fresh evidence beyond the earlier segment-completeness comment is that the updated parser explicitly supports *, but only known totals trigger another retry. Continue from the parsed end or restart rather than treating an unknown total as complete; the async branch has the same defect.

AGENTS.md reference: AGENTS.md:L114-L121

Useful? React with 👍 / 👎.

Comment thread src/openai/_base_client.py Outdated
Comment on lines +1917 to +1920
except Exception:
# the stream is left open when the body read fails; release the
# connection before the retry loop takes over
await response.aclose()

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Close the response when async reads are cancelled

When a caller cancels a task while aread() or aiter_bytes() is awaiting the next chunk, the cancellation exception inherits from BaseException, so these except Exception cleanup handlers are bypassed. Because resumable GETs are now sent with stream=True, the HTTP client no longer performs the normal non-streamed read cleanup, leaving the response and transport connection open after cancellation; catch cancellation in a BaseException/finally cleanup path (shielding aclose() as needed). The fallback read at line 1907 has the same defect.

AGENTS.md reference: AGENTS.md:L118-L119

Useful? React with 👍 / 👎.

Comment thread src/openai/_base_client.py Outdated
Comment on lines +143 to +145
value = str(response.headers.get("content-range", ""))
if not value.startswith("bytes "):
return None

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Parse range units case-insensitively

When a compliant origin serializes the range unit with different casing, such as Content-Range: Bytes 4096-10239/10240, this exact lowercase prefix check rejects the successful resumed response even though HTTP range-unit names are case-insensitive. In the deterministic-cutoff scenario this discards the received prefix, forces the next attempt to restart in full, and can exhaust all retries despite the origin serving the requested remainder correctly. Normalize the range unit before parsing; _range_total_bytes() has the same issue for 416 responses, and both async paths share these helpers.

Useful? React with 👍 / 👎.

Third round of review hardening:

- only a strong ETag qualifies as a resume validator; a Last-Modified
  date can hide sub-second changes and is no longer used, so responses
  without a strong ETag restart instead of resuming
- a 206 whose Content-Range reports an unknown total (bytes x-y/*) can
  never prove completeness: the partial state is dropped and the next
  attempt downloads the full body instead of returning a possibly
  truncated one
- async read cleanup now catches BaseException so a cancelled task
  still closes the streamed response (CancelledError bypasses
  'except Exception'); the sync paths treat KeyboardInterrupt the same
- Content-Range and the 416 total parser normalise the range unit
  case-insensitively, per RFC 9110 range-unit comparison
@CacinieP

Copy link
Copy Markdown
Author

Third round addressed in 6d469e3:

  • Strong ETag only: _resume_validator no longer falls back to Last-Modified — its one-second granularity can hide changes and there is no way to prove the date is a strong validator, so responses without a strong ETag restart from scratch. Covered by test_last_modified_alone_is_not_a_validator (and the existing no-etag/weak-etag tests).
  • Unknown totals are not completion: a 206 whose Content-Range carries * as the total can never prove completeness — the partial state is dropped and the next attempt performs a plain full download (test_unknown_total_restarts_from_scratch). Bounded segments with a known total keep the resume-from-new-offset behaviour from the previous commit.
  • Cancellation-safe cleanup: the async aread()/aiter_bytes() paths now clean up on BaseException (so CancelledError closes the streamed response before propagating), and the sync paths handle KeyboardInterrupt the same way. Covered by test_cancelled_body_read_closes_the_response.
  • Case-insensitive range units: Content-Range parsing (both the 206 bounds parser and the 416 total parser) normalises the header, so Bytes 4096-10239/10240 is accepted per RFC 9110's case-insensitive range-unit comparison (test_range_units_are_parsed_case_insensitively).

Full local suite for the touched areas: tests/test_resumable_downloads.py (19 tests), tests/test_client.py, tests/test_httpx2.py, tests/test_legacy_response.py — 247 passed, 2 skipped; ruff and mypy clean.

@chatgpt-codex-connector chatgpt-codex-connector Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: 6d469e3c6e

ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".

Comment thread src/openai/_base_client.py Outdated
Comment on lines +1221 to +1222
content = bytes(partial.data)
partial.clear() # release the mutable buffer before response processing

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Avoid copying the complete partial buffer before releasing it

For the targeted 200–300 MB range-capable downloads, bytes(partial.data) allocates a second full-size buffer while the original bytearray is still live, so peak memory remains roughly twice the payload and can still trigger the OOM described by the earlier finding. Fresh evidence is that the new partial.clear() occurs only after this allocation; use buffering that can be transferred or spooled without a full-size duplicate.

AGENTS.md reference: AGENTS.md:L114-L119

Useful? React with 👍 / 👎.

Comment thread src/openai/_base_client.py Outdated
Comment on lines +1935 to +1939
except BaseException:
# the stream is left open when the body read fails or the task is
# cancelled; release the connection before propagating
await response.aclose()
raise

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Shield asynchronous cleanup from active cancellation

When a body read is cancelled inside an AnyIO or Trio cancel scope, catching BaseException does not make this cleanup reliable: level-triggered cancellation can immediately interrupt await response.aclose(), leaving the transport stream open. Fresh evidence beyond the earlier cancellation comment is that the new handler awaits aclose() without shielding it, while the added test merely raises asyncio.CancelledError from the stream and therefore has no active cancellation scope.

AGENTS.md reference: AGENTS.md:L118-L119

Useful? React with 👍 / 👎.

Comment thread src/openai/_base_client.py Outdated
Comment on lines +220 to +223
rebuilt = response_cls(
200,
headers=headers,
content=content,

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Preserve configured encoding when rebuilding responses

When a supported custom HTTP client uses a non-UTF-8 default_encoding, a successful range-capable text response without a charset is reconstructed directly with the response class's UTF-8 default rather than the encoding attached by the client; an encoding selected by a response hook is similarly lost. Consequently, raw-response or cast_to=httpx.Response callers can now get incorrectly decoded .text, so copy the served response's encoding state into the rebuilt response.

Useful? React with 👍 / 👎.

base_url = "http://localhost:7123/v1"
api_key = "test-api-key"

DATA = b"".join(bytes([i % 256]) * 64 for i in range(160)) # 10240 bytes

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 Badge Exercise the required large-payload regression size

The shared fixture is only 10 KB, so these tests do not exercise the 200–300 MB download behavior this change targets and cannot detect payload-size-dependent memory regressions such as excessive buffering. The repository guidance explicitly requires large synthetic in-memory public-entrypoint probes and says their high memory use must not be reduced; add sequential large sync/async coverage rather than relying solely on this miniature fixture.

AGENTS.md reference: AGENTS.md:L123-L128

Useful? React with 👍 / 👎.

Fourth round of review hardening:

- async cleanup now closes the response inside an anyio shielded cancel
  scope, so a level-triggered cancellation (anyio/trio scopes, repeated
  cancels) cannot interrupt aclose itself; a real asyncio task
  cancellation test replaces the manual CancelledError raise
- the rebuilt response copies default_encoding and the effective
  encoding from the interrupted response, keeping .text identical for
  clients configured with a non-UTF-8 default_encoding or hook-set
  encoding
- the accumulated buffer is handed off rather than emptied in place,
  dropping the last reference to the mutable copy as soon as the
  immutable content exists (peak memory matches the stock
  Response.read() join, which also transiently holds two copies)
- large-payload regression probes per the repository's large-payload
  contract: a 32 MiB+ body is generated in memory, interrupted
  mid-transfer, and resumed through the public files.content()
  entrypoint, sync and async, sequentially in one test
@CacinieP

CacinieP commented Sep 1, 2026

Copy link
Copy Markdown
Author

Fourth round addressed in ce670da:

  • Large-payload probes (P1): added a 32 MiB+ in-memory regression probe mirroring tests/test_large_payload_contract.py's conventions — the body is generated in memory, interrupted mid-transfer, and resumed through the public client.files.content() entrypoint for both sync and async, run sequentially inside a single test to bound peak memory under pytest-xdist (test_large_resumable_download). The payload size is not shrinkable by design.
  • Shielded async cleanup: both async cleanup paths now close the response inside anyio.CancelScope(shield=True), so level-triggered cancellation (anyio/trio scopes, repeated cancels) cannot interrupt aclose itself. Verified that the shielded scope works under a plain asyncio.run host as well. The old manual-CancelledError test is joined by test_real_task_cancellation_closes_the_response, which cancels a live task mid-body and asserts the response is closed.
  • Encoding state: the rebuilt response now copies default_encoding and the effective encoding from the interrupted response (when the source derived its encoding from headers, the copied Content-Type header reproduces the same derivation), so .text stays identical for clients with a non-UTF-8 default_encoding or a hook-set encoding (test_rebuilt_response_preserves_encoding_state).
  • Buffer hand-off: _finish_download now swaps the buffer out of the holder instead of emptying it in place, dropping the last reference to the mutable copy as soon as the immutable bytes exists. Note the transient two-copy peak during bytes(bytearray) is the same order as the stock Response.read(), which also transiently holds the chunk list plus the joined result; returning immutable bytes content makes a strictly single-buffer path impossible without changing httpx's response contract.

Local verification: tests/test_resumable_downloads.py (22 tests incl. the 32 MiB probes), tests/test_client.py, tests/test_httpx2.py, tests/test_legacy_response.py — 250 passed, 2 skipped; ruff and mypy clean.

@chatgpt-codex-connector chatgpt-codex-connector Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: ce670da94d

ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".

Comment thread src/openai/_base_client.py Outdated
Comment on lines +1201 to +1202
for chunk in response.iter_bytes():
partial.data.extend(chunk)

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Keep the response object visible to response hooks

When a caller's custom HTTP client has a response event hook that retains the response for post-request inspection, iter_bytes() consumes and closes that object without assigning its .content, while _finish_download() returns a different response. Before this change, the non-streaming send() populated the same hook-visible response before returning; now any successful validator-bearing, range-capable GET leaves the retained response raising ResponseNotRead for .content or .text. Preserve the original response identity or populate its content during reassembly; the async path has the same regression.

Useful? React with 👍 / 👎.

Comment thread src/openai/_base_client.py Outdated
Comment on lines +224 to +225
# the request as it was actually sent (post-redirect, post-auth)
request=representation.request,

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Attach the request that completed the resumed transfer

When a download actually resumes, representation still refers to the initial interrupted 200, so the rebuilt raw response reports that attempt's request rather than the final 206 request that completed the transfer; callers consequently see headers without Range, and rotating redirects can leave the response reporting the wrong final URL and history. Fresh evidence beyond the earlier comment is that the updated helper receives the final response only through elapsed_from, while this line still unconditionally selects representation.request; pass the completing response's request metadata as well.

Useful? React with 👍 / 👎.

Comment thread src/openai/_base_client.py Outdated
def _accepts_byte_ranges(response: httpx2.Response) -> bool:
# https://developer.mozilla.org/en-US/docs/Web/HTTP/Reference/Headers/Accept-Ranges
value = str(response.headers.get("accept-ranges", ""))
return value.strip().lower() == "bytes"

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Recognize bytes in multi-unit Accept-Ranges headers

When an origin advertises more than one supported range unit, such as Accept-Ranges: bytes, example-unit, this exact equality check rejects byte-range support even though the header's standard list syntax permits that value. A deterministically interrupted download from such an origin therefore restarts from byte zero on every attempt and can exhaust all retries despite the server supporting the requested byte ranges. Parse the comma-separated range units and test whether bytes is present; the shared helper affects both sync and async requests.

Useful? React with 👍 / 👎.

…ability

Fifth round of review hardening:

- Accept-Ranges is parsed as a comma-separated unit list, so origins
  advertising 'bytes, other-unit' are recognised as byte-range capable
- the reassembled response now carries the request, extensions and
  redirect history of the attempt that completed the transfer (the
  resumed ranged request), keeping headers/encoding from the original
  representation; the 416-completion path does the same
- the consumed completing response gets its content populated, so
  callers that retained it (e.g. via a response event hook) can still
  read .content/.text instead of hitting ResponseNotRead
@CacinieP

CacinieP commented Sep 1, 2026

Copy link
Copy Markdown
Author

Fifth round addressed in 8e3952e:

  • Multi-unit Accept-Ranges: the header is now parsed as a comma-separated unit list, so Accept-Ranges: bytes, example-unit is recognised as byte-range capable (test_accept_ranges_with_multiple_units).
  • Completing-request metadata: the reassembled response now carries the request, extensions, and redirect history of the attempt that completed the transfer (the resumed ranged request — including on the 416-completion path), while headers and encoding state still come from the original representation (test_rebuilt_response_reports_completing_request asserts the raw response's request carries the Range header).
  • Hook-retained responses: the consumed completing response has its content populated during reassembly, so a response retained outside the client (e.g. by a response event hook) still exposes .content/.text instead of raising ResponseNotRead (test_completing_response_stays_readable).

Local verification: tests/test_resumable_downloads.py (25 tests), tests/test_client.py, tests/test_httpx2.py, tests/test_legacy_response.py — 253 passed, 2 skipped; ruff and mypy clean.

@chatgpt-codex-connector chatgpt-codex-connector Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: 8e3952e9a5

ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".

Comment thread src/openai/_base_client.py Outdated
# the consumed response stays reachable to callers that retained it (for
# example through a response event hook), so populate its content too
# instead of leaving it unreadable
elapsed_from._content = content

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Normalize hook-visible response metadata with its body

When a response hook retains the completing 206 response (or the 416 used for end-of-body completion), this assignment replaces its content with the entire assembled representation but leaves its status and headers unchanged. After the request finishes, the retained response can therefore report 206 with a Content-Range and suffix-sized Content-Length while exposing the full body, which can break hook-based caching, metrics, or post-request inspection; either normalize that response to the rebuilt 200 metadata or avoid attaching mismatched content.

Useful? React with 👍 / 👎.

Comment thread src/openai/_base_client.py Outdated
Comment on lines +240 to +243
rebuilt.default_encoding = representation.default_encoding
effective_encoding = representation.encoding
if effective_encoding is not None:
rebuilt.encoding = effective_encoding

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Let callable default encodings inspect the assembled body

When an actually resumed response has no charset and the custom HTTP client uses HTTPX's supported callable default_encoding, representation is the initial interrupted response and still has no _content; resolving representation.encoding therefore falls back to UTF-8 rather than invoking the detector, and that fallback is then forced onto the rebuilt response. Fresh evidence in the updated encoding fix is that it reads the effective encoding from the unread interrupted response, so non-UTF-8 raw responses still decode incorrectly via .text; preserve an explicitly set encoding only, otherwise let the callable run against the assembled content.

Useful? React with 👍 / 👎.

Sixth round of review hardening:

- the consumed response is left with exactly the body its own exchange
  delivered (a 206 keeps its suffix-sized body with matching status and
  Content-Range), matching what a non-streaming send() would have left
  for a hook-retained reference, instead of the previously attached
  assembled body that disagreed with its metadata; the 416 completion
  path now reads its small body for the same parity
- only an encoding pinned explicitly on the interrupted response is
  copied verbatim; a client-level default_encoding (including a callable
  detector) is carried over and left to resolve against the assembled
  body, so detectors see the full content instead of a UTF-8 fallback
  from the unread interrupted response
@CacinieP

CacinieP commented Sep 1, 2026

Copy link
Copy Markdown
Author

Sixth round addressed in 81cebf8:

  • Per-exchange content on retained responses: the consumed response is now left with exactly the body its own exchange delivered — a 206 keeps its suffix-sized body, its status, and its Content-Range, which is precisely what a non-streaming send() would have left for a hook-retained reference; the previously attached assembled body (which disagreed with that metadata) is gone. The 416-completion path also reads its small body for the same parity. test_completing_response_stays_readable now asserts the retained 206 exposes the suffix it actually served.
  • Explicit vs detected encodings: only an encoding pinned explicitly on the interrupted response is copied verbatim; the client-level default_encoding (including httpx's callable detector form) is carried over and left to resolve against the assembled body, so detectors inspect the full content instead of getting a UTF-8 fallback read off the unread interrupted response. Covered by test_rebuilt_response_keeps_explicit_and_detected_encoding.

Local verification: tests/test_resumable_downloads.py (26 tests), tests/test_client.py, tests/test_httpx2.py, tests/test_legacy_response.py — 254 passed, 2 skipped; ruff and mypy clean.

@chatgpt-codex-connector chatgpt-codex-connector Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: 81cebf8e70

ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".

Comment thread src/openai/_base_client.py Outdated
Comment on lines +1231 to +1232
bounds = _content_range_bounds(response)
if resuming and bounds is not None and (bounds[2] is None or bounds[2] != len(partial.data)):

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Validate the complete Content-Range before accepting it

When a resumed response claims Content-Range: bytes 4096-8191/10240 but delivers 6144 bytes, the assembled length equals the advertised total, so this condition accepts the response even though the body length contradicts the claimed range end. Fresh evidence beyond the earlier completeness comment is that the new condition compares only the total with the assembled length while the parsed end remains unused. Validate that end >= start, that the delivered length is exactly end - start + 1, and that a completed open-ended request reaches total - 1; the async branch has the same issue.

Useful? React with 👍 / 👎.

Comment on lines +1327 to +1329
if resumable_attempt and partial_download:
log.debug("Resuming interrupted download from byte %i", len(partial_download.data))
request.headers["Range"] = f"bytes={len(partial_download.data)}-"

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Add resume headers before preparing the request

When a provider or subclass uses _prepare_request to sign or otherwise finalize the complete request headers, a resumed attempt runs that hook first and only then appends Range and If-Range. The resulting authorization can be stale or omit the body-selecting headers, causing resumed downloads to fail against verifiers that canonicalize the received header set. Build the resume headers before _prepare_request, or rerun preparation after adding them; the async ordering has the same defect and needs corresponding authentication coverage.

AGENTS.md reference: AGENTS.md:L41-L45

Useful? React with 👍 / 👎.

Comment on lines 1354 to 1357
response = self._send_request(
request,
stream=stream or self._should_stream_response_body(request=request),
stream=stream or self._should_stream_response_body(request=request) or resumable_attempt,
**kwargs,

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Preserve the provider response-normalization contract

When a configured provider's normalize_response callback inspects .content, .text, or .json() for a non-streamed GET, passing stream=True here makes the callback receive an unread response and HTTPX raises ResponseNotRead; previously the underlying send completed the body read before _send_request invoked normalization. _read_resumable_body runs only after that callback returns, so it cannot repair the regression, and a callback exception can also leave the streamed response open. Arrange for resumable reading to occur before provider normalization while retaining normalization before status handling; the async callback path has the same issue.

Useful? React with 👍 / 👎.

…zation

Seventh round of review hardening:

- Range and If-Range are added before _prepare_request runs, so request
  signing or header finalisation always covers the final header set
- a 206 whose delivered body length disagrees with its Content-Range
  extent is rejected and the download restarts from scratch
- resumable bodies are consumed inside _send_request via a body_reader
  hook before provider response normalisation, restoring the
  non-streaming contract where normalize_response sees a read response
  (the reassembled response the pipeline consumes); the OpenAI client
  overrides pass the hook through on both sync and async paths
@CacinieP

CacinieP commented Sep 1, 2026

Copy link
Copy Markdown
Author

Seventh round addressed in 124bbef:

  • Sign the final header set: Range/If-Range are now added before _prepare_request runs, so signing and header finalisation always cover the headers that actually go on the wire. test_resume_headers_are_set_before_request_preparation records what a _prepare_request override sees and asserts the resumed attempt already carries its Range.
  • Complete Content-Range validation: a 206 whose delivered body length disagrees with its advertised range extent (end - start + 1) is rejected — the partial state is dropped and the download restarts, instead of accepting an assembled length that merely matches the total. Covered by test_content_range_body_mismatch_restarts.
  • Provider normalisation contract: resumable bodies are now consumed inside _send_request via a body_reader hook, running after the send/auth retry but before normalize_response/normalize_async_response. The callback therefore sees a fully-read response — specifically the reassembled response the rest of the pipeline consumes — instead of an unread streamed one that raises ResponseNotRead. Both the base clients and the OpenAI client overrides carry the hook on sync and async paths, and the reader's exceptions still propagate into the existing retry handling. Covered by test_provider_normalization_sees_read_response.

Local verification: tests/test_resumable_downloads.py (29 tests), tests/test_client.py, tests/test_httpx2.py, tests/test_legacy_response.py — 257 passed, 2 skipped; ruff and mypy clean.

@CacinieP

CacinieP commented Sep 1, 2026

Copy link
Copy Markdown
Author

Hi maintainers 👋 — a polite check-in on this PR, which fixes #2959 (large Batch API result file downloads dying mid-transfer, because retries restart the download from byte 0 and can never get past a deterministic cutoff).

The implementation is complete with sync/async coverage and a dedicated test suite, and every finding from the seven automated review rounds has been addressed. Two things I'd be grateful for:

No rush at all — happy to adapt to any feedback. Thanks for taking a look!

cc @jbeckwith-oai @apcha-oai

Follows the shape of openai#3712/openai#3713 (polling helpers in SDK-owned files):
the byte-range resume helpers, state object, and body readers now live in
the handwritten src/openai/lib/_resumable.py, leaving only the retry-loop
integration (~120 lines) in the template-owned _base_client.py. No
behavior change; the tests are unchanged apart from imports.
@CacinieP

CacinieP commented Sep 1, 2026

Copy link
Copy Markdown
Author

Structural update (7c9daf1): following the SDK-owned-helper shape from #3712/#3713, the resume helpers, partial-download state, and body readers moved into the handwritten src/openai/lib/_resumable.py; the template-owned _base_client.py change is now ~70 lines of retry-loop integration, and _client.py only threads the body-reader hook through the provider overrides. No behaviour change — the same 29 tests pass unchanged (imports aside).

@chatgpt-codex-connector chatgpt-codex-connector Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: 7c9daf1263

ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".

Comment thread src/openai/lib/_resumable.py Outdated
Comment on lines +223 to +225
for chunk in response.iter_bytes():
partial.data.extend(chunk)
delivered.extend(chunk)

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 Badge Avoid four simultaneous copies of large response bodies

For a successful validator-bearing, range-capable 200 response, every chunk is accumulated in both partial.data and delivered; afterward bytes(delivered) creates a third full-body buffer and finish_download() creates a fourth from partial.data while the first three remain live. Fresh evidence beyond the earlier memory findings is this new hook-visibility buffer, which raises peak memory to roughly four times the payload even when no retry occurs, so a targeted 200–300 MB download can transiently consume over 1 GB and be killed; preserve hook readability without duplicating the entire body or use transferable/spooled buffering.

AGENTS.md reference: AGENTS.md:L114-L119

Useful? React with 👍 / 👎.

Comment thread src/openai/lib/_resumable.py Outdated
Comment on lines +95 to +98
headers = [
(key, value)
for key, value in representation.headers.raw
if key.decode("latin-1").lower() not in ("content-range", "content-length", "transfer-encoding")

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Preserve the completing attempt's request ID

When a resumed request completes with a different x-request-id, the rebuilt response copies all headers from the initial interrupted response even though its request, history, extensions, and elapsed time come from the completing attempt. Consequently APIResponse.request_id, raw-response callers, and the request-ID debug log report the failed first attempt's ID rather than the final exchange, which can send support and tracing investigations to the wrong request; copy attempt-scoped headers such as x-request-id from elapsed_from while retaining representation metadata from the original response.

Useful? React with 👍 / 👎.

@CacinieP

CacinieP commented Sep 1, 2026

Copy link
Copy Markdown
Author

Hi — a closing note from the author, for whenever a human has a moment.

First, some honesty about the noise below: nearly all the review activity on this PR has been the automated Codex reviewer, and my replies to it were correspondingly mechanical. That history is what it is; the code underneath is stable, and apart from one memory-footprint commit I'll push tomorrow (already written and verified locally — it collapses the accumulation buffer so peak memory matches the stock Response.read(), and takes x-request-id from the completing exchange), this is my last update unless someone has questions.

What the change is, plainly: retries for large non-streamed GET downloads currently restart from byte 0, so a transfer that dies at a fixed size or duration threshold can never finish — that's #2959. This PR makes retries resume instead, sending Range/If-Range — but only when the origin itself advertised Accept-Ranges: bytes and served a strong validator; every other path is byte-for-byte today's behaviour, and there is no public API change.

The complete diff accounting (current head 7c9daf12, +1323/−4 across 4 files):

File Change Role
src/openai/lib/_resumable.py new, 412 lines All resume logic — validators, Content-Range parsing, buffer management, response reassembly. SDK-owned file per the shape of #3712/#3713.
src/openai/_base_client.py +70/−4 Retry-loop integration only: Range/If-Range headers (set before _prepare_request so signing sees them) and a body_reader hook.
src/openai/_client.py +10 Threads body_reader through the provider-response overrides.
tests/test_resumable_downloads.py new, 835 lines 30 tests: sync/async resume, validators, 416/short-segment/misaligned ranges, cancellation, provider normalization, 32 MiB large-payload probes per the repo's contract.
Commit-by-commit (10)
  1. 0f1f6912 — initial Range-resume implementation
  2. 222e2dfc — If-Range validators, offset checks, cleanup on failed reads
  3. a39c2e67 — strong-ETag-only gate, segment-completeness verification
  4. 6d469e3c — unknown totals restart, cancellation-safe cleanup, case-insensitive range units
  5. ce670da9 — shielded async cleanup, encoding preservation, 32 MiB probes
  6. 8e3952e9 — multi-unit Accept-Ranges, completing-request metadata, hook readability
  7. 81cebf8e — per-exchange response content, detected encodings
  8. 124bbefb — sign resume headers, validate Content-Range, read before provider normalization
  9. 7c9daf12 — move helpers into SDK-owned lib/_resumable.py (template-file footprint → 70 lines)
  10. (tomorrow) — single-buffer accumulation (2× peak, stock parity), attempt-scoped x-request-id

Three questions I'd genuinely value answers to, whichever way they go:

  1. Is this the right layer at all? The disconnect itself is transport-level, but the recovery policy belongs to whoever owns retries — which here is the SDK. Still, if you'd rather this waits for a server-side or httpx2-level answer, that's a legitimate call and I'd close this happily.
  2. If it is SDK territory, is the shape acceptable? ~70 lines in template-owned _base_client.py plus everything else in lib/. Given the custom-code direction of chore: set a ceiling for Python SDK customization #3714/ci: catch accidental growth in Python SDK custom patches #3715, I can also restructure to zero template-file changes (fully opt-in helper in lib/), if you prefer that line held absolutely.
  3. Would you rather take it in-house? If the approach is sound but the review cost of an outside PR on the retry path isn't worth it, I'm glad to hand over the analysis, the test suite, and this branch — no feelings hurt; the issue getting fixed is the actual goal (and fix(embeddings): check for numpy once per response #3754fix: avoid repeated numpy checks for embeddings #3757 suggests that's sometimes the efficient path).

After tomorrow's commit I'll go quiet. Thanks for reading this far.

The interrupted-then-resumed flow accumulated every chunk twice — once in
the shared partial buffer and once in a per-response `delivered` buffer —
and then copied each again (`bytes(delivered)` for the response body and
`bytes(buffer)` for the reassembled one), holding up to four full-body
copies of a 200-300 MB Batch API result at once.

Chunks now accumulate exactly once, into the partial buffer. The response
body is assigned after assembly: a fresh 200 restart shares the assembled
body zero-copy, and a 206 keeps a suffix-sized copy of only the bytes it
delivered. Peak memory for a successful range-capable download drops from
~4.2x to ~2.1x the body size (measured with tracemalloc), matching what a
plain `Response.read()` holds.

Also: the reassembled response now reports the completing attempt's
`x-request-id`, so the header agrees with the request, extensions, and
elapsed timing already taken from that attempt.
@CacinieP

CacinieP commented Sep 3, 2026

Copy link
Copy Markdown
Author

Both findings from the last review round are addressed in 836e3a4.

  • Peak memory (P1): the double accumulation buffer is gone — chunks now land exactly once in the shared partial buffer, and the response body is assigned after assembly (a fresh 200 restart shares the assembled body zero-copy; a 206 keeps only a suffix-sized copy of the bytes it delivered). Peak allocation for a successful range-capable download drops from ~4.2x to ~2.1x the body size, measured with tracemalloc against the previous and current commits — matching what a plain Response.read() holds.
  • Completing request id (P2): the reassembled response now carries the completing attempt's x-request-id, so the header agrees with the request, extensions, and elapsed timing already taken from that attempt (test_rebuilt_response_reports_completing_request_id).

No behaviour change otherwise — all 31 tests in tests/test_resumable_downloads.py pass, including the 32 MiB large-payload probes.

@chatgpt-codex-connector chatgpt-codex-connector Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: 836e3a4ac2

ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".

Comment on lines +103 to +105
headers.extend(
(key, value) for key, value in elapsed_from.headers.raw if key.decode("latin-1").lower() == "x-request-id"
)

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Preserve final-attempt rate-limit headers

When an interrupted GET resumes, the completing request can return updated x-ratelimit-* or retry-after values, but the rebuilt response retains those headers from the first, failed exchange. Raw-response callers therefore see stale quota state and may issue requests based on an incorrect remaining limit. Fresh evidence beyond the earlier request-ID finding is that this code now overlays only x-request-id; preserve other attempt-scoped response headers from elapsed_from as well.

Useful? React with 👍 / 👎.

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

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

bug: Batch API result file download fails for large outputs (>200MB) with ConnectionResetError

1 participant