Skip to content

fix(client): retry only replayable request content - #3771

Open
Hughhhhcoder wants to merge 6 commits into
openai:mainfrom
Hughhhhcoder:codex/openai-python-retry-content
Open

Hughhhhcoder wants to merge 6 commits into
openai:mainfrom
Hughhhhcoder:codex/openai-python-retry-content

Conversation

@Hughhhhcoder

@Hughhhhcoder Hughhhhcoder commented Aug 31, 2026

Copy link
Copy Markdown
  • I understand that this repository is auto-generated and my pull request may not be merged

Changes being requested

Automatic retries can reuse a consumed request body. A one-shot iterable or non-seekable multipart file part may then send an empty body on the retry; if that retry succeeds, the SDK reports success without resending the original payload. Seekable streams also need their original offset restored. The expected behavior is to retry only when every body part can be replayed.

The retry paths currently decide whether to retry based on response status or request failure, but do not check the replayability of options.content and options.files or rewind seekable parts. This patch adds one replayability guard for both synchronous and asynchronous clients:

  • bytes, bytearrays, and exact list/tuple containers remain retryable;
  • opaque iterables, including wrappers that return one stored iterator, and non-seekable streams are treated as non-replayable;
  • seekable content and multipart file parts are restored to their starting offset before retrying;
  • retryable status responses, timeouts, and connection failures all use the guard.

The patch does not buffer request bodies or change the public API. #3484 proposes spooling non-seekable multipart streams to preserve retries; this PR takes the smaller, conservative approach of not retrying a consumed stream. The multipart checks here were added in response to review feedback, so they are no longer out of scope.

Additional context & links

The one-shot-content and multipart regression cases fail on the earlier base and pass with this change. The branch has been normally merged with current main, retaining the separate retry-limit and application-error changes from #3867.

Validation after that merge:

  • PYTHONPATH=src ./scripts/test tests/test_client.py with the repository's pinned Steady mock server: 275 passed, 2 skipped in each Pydantic lane;
  • PYTHONPATH=src ./scripts/lint: Ruff, Pyright, mypy, and import checks passed using the documented Node.js and pnpm versions;
  • git diff --check: passed.

@Hughhhhcoder
Hughhhhcoder requested a review from a team as a code owner August 31, 2026 12:59
@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-16T06:10:13.621895Z 50fb122 New commits
🔒 Security Review Completed 2026-09-16T06:12:49.449671Z 50fb122 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.

@sylvesterkaczmarek

Copy link
Copy Markdown
Contributor

A one-shot Iterable can still be misclassified as replayable here. isinstance(content, Iterator) only catches objects that implement __next__; a valid Iterable[bytes] can instead return the same internal iterator from __iter__() without being an Iterator itself.

After the first request consumes that internal iterator, rewind() returns True, and the retry reuses the exhausted iterable, so it can still send an empty body and report success.

Could we either treat generic iterables conservatively unless repeatability is known, or add a regression case with an iterable wrapper whose __iter__() returns one stored generator? That reproduces the silent empty-retry case this PR is intended to prevent.

@Hughhhhcoder

Copy link
Copy Markdown
Author

@sylvesterkaczmarek Good catch — the stored-generator wrapper reproduced the silent empty-success retry. I addressed it in 53256c2 by treating opaque sync and async iterables as non-replayable unless their repeatability is known here; exact list/tuple containers remain retryable. The parameterized regression now covers stored sync/async iterators across status, timeout, and connection retries. Both Pydantic v1 and v2 client suites pass (213 passed, 2 skipped each), and the full lint/type-check suite passes.\n\n@codex review\n@codex security review

@chatgpt-codex-connector

Copy link
Copy Markdown

Security review completed. No security issues were found in this pull request.

Reviewed commit: 53256c2765

View security finding report

Only the user who started this review can view the report in Codex.

ℹ️ About Codex security reviews in GitHub

This is an experimental Codex feature. Security reviews are triggered when:

  • You comment "@codex security review"
  • A regular code review gets triggered (for example, "@codex review" or when a PR is opened), and you’re opted in so security review runs alongside code review

Once complete, Codex will leave suggestions, or a comment if no findings are found.

@chatgpt-codex-connector

Copy link
Copy Markdown

Codex Review: Didn't find any major issues. Delightful!

Reviewed commit: 53256c2765

ℹ️ 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".

@Hughhhhcoder

Copy link
Copy Markdown
Author

Thanks for checking this edge case. I rechecked the current head (53256c2765): the helper no longer uses isinstance(content, Iterator) to decide replayability. It conservatively permits only exact list/tuple containers (type(content) in (list, tuple)), so an Iterable wrapper that returns one stored generator is treated as non-replayable.

The regression already includes both synchronous and asynchronous one-shot wrappers whose __iter__/__aiter__ return the same stored iterator, parameterized across status, timeout, and connection failures. The focused retry suite passes (15 tests). I also confirmed the current head does not retry the wrapper, so no additional code change is needed for this feedback.

@sylvesterkaczmarek sylvesterkaczmarek left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

_RequestContentReplay only checks options.content; multipart options.files is ignored. A non-seekable file upload has content=None, so the client still retries after the first attempt consumes the file and can send an empty/partial upload. Include file parts in replayability/rewind logic, or disable retries when any upload part is non-seekable, and add a multipart regression.

@Hughhhhcoder

Copy link
Copy Markdown
Author

Addressed in commit 0681754. Retry bookkeeping now inspects multipart file parts as well as raw request content: seekable file streams rewind to their original offsets, while any non-seekable file disables the retry. Added sync and async regressions for both cases; 19 focused retry/multipart tests pass, with Ruff, formatting, and diff checks passing.

@Hughhhhcoder

Copy link
Copy Markdown
Author

@codex review

@chatgpt-codex-connector

Copy link
Copy Markdown

Security review completed. No security issues were found in this pull request.

Reviewed commit: 06817547ee

View security finding report

Only the user who started this review can view the report in Codex.

ℹ️ About Codex security reviews in GitHub

This is an experimental Codex feature. Security reviews are triggered when:

  • You comment "@codex security review"
  • A regular code review gets triggered (for example, "@codex review" or when a PR is opened), and you’re opted in so security review runs alongside code review

Once complete, Codex will leave suggestions, or a comment if no findings are found.

@chatgpt-codex-connector

Copy link
Copy Markdown

Codex Review: Didn't find any major issues. Hooray!

Reviewed commit: 06817547ee

ℹ️ 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".

@Hughhhhcoder

Copy link
Copy Markdown
Author

Thanks for the follow-up. Both cases are covered on the current head: commit 53256c27 treats opaque one-shot Iterable wrappers as non-replayable, and commit 06817547 includes each multipart file payload in the replayability/rewind checks. Non-seekable uploads therefore stop after the first failed attempt, while seekable uploads rewind and retry.

Validation on the current head: the focused test_client.py retry/multipart selection passed 19 tests, and Ruff check/format passed. No additional code change is needed.

@Hughhhhcoder

Copy link
Copy Markdown
Author

Synced this branch with the latest upstream main at 3cc8d784ad05f75a265012ee86638adaf93d8bf2 using a regular merge (no force-push); current head is cac52ea8.

Validation after the sync:

  • uv run --locked pytest tests/test_client.py: 217 passed, 2 skipped
  • uv run --locked ruff check on changed files: passed
  • uv run --locked ruff format --check on changed files: passed
  • git diff --check origin/main...HEAD: passed

@Hughhhhcoder

Copy link
Copy Markdown
Author

@codex review

@chatgpt-codex-connector

Copy link
Copy Markdown

Security review completed. No security issues were found in this pull request.

Reviewed commit: cac52ea84f

View security finding report

Only the user who started this review can view the report in Codex.

ℹ️ About Codex security reviews in GitHub

This is an experimental Codex feature. Security reviews are triggered when:

  • You comment "@codex security review"
  • A regular code review gets triggered (for example, "@codex review" or when a PR is opened), and you’re opted in so security review runs alongside code review

Once complete, Codex will leave suggestions, or a comment if no findings are found.

@chatgpt-codex-connector

Copy link
Copy Markdown

Codex Review: Didn't find any major issues. Can't wait for the next one!

Reviewed commit: cac52ea84f

ℹ️ 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".

@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: b5876c89cb

ℹ️ 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 +1121 to +1124
request_body_replays = [
_RequestContentReplay(input_options.content),
*(_RequestContentReplay(content) for content in _iter_file_contents(input_options.files)),
]

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 Track replayability after preparing request options

When a subclass or provider's _prepare_options() replaces a replayable input body with a cached one-shot iterator or non-seekable file, these trackers still describe the original input_options; because preparation happens later, a retryable failure can trigger a second attempt using the exhausted transformed body and even report success after sending an empty payload. The async path has the same ordering. Derive the replay state from the prepared options actually used for the first attempt, or otherwise ensure preparation cannot invalidate it.

Useful? React with 👍 / 👎.

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

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

Addressed in 50fb1224. The replay trackers are now created after _prepare_options() for each attempt, so they describe the content and multipart files actually passed to _build_request() rather than the original input options. I added sync and async regressions where the preparation hook replaces replayable bytes with the same cached one-shot iterable; both now stop after the first failed attempt instead of reusing the exhausted transformed body.

Validation after the change:

  • PYTHONPATH=src ./scripts/test tests/test_client.py: 275 passed, 2 skipped in each Pydantic lane
  • PYTHONPATH=src ./scripts/lint: Ruff, Pyright, mypy, and import checks passed
  • git diff --check: passed

@sylvesterkaczmarek sylvesterkaczmarek left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Re-reviewed current 50fb1224. The remaining preparation-order issue is resolved: replay state is now derived from the prepared options immediately before _build_request(), so a provider hook that substitutes a one-shot body is treated as non-replayable on the actual attempt. The added sync and async regressions cover that transformation directly. Together with the existing multipart rewind/non-seekable coverage, I do not see a remaining replay-safety blocker.

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.

2 participants