Skip to content

fix(client): FileSet upload retries and timeout propagation - #1125

Merged
albcui merged 6 commits into
mainfrom
albcui/NVBUG6562815-fix-fileset-upload-retry-body
Aug 7, 2026
Merged

fix(client): FileSet upload retries and timeout propagation#1125
albcui merged 6 commits into
mainfrom
albcui/NVBUG6562815-fix-fileset-upload-retry-body

Conversation

@albcui

@albcui albcui commented Aug 6, 2026

Copy link
Copy Markdown
Contributor

Summary

It was observed that a FileSet upload that hit a transient failure failed with h11._util.LocalProtocolError: Too little data for declared Content-Length error. However, this is a bit of a red herring, because it hides the underlying issue, which is a bit gnarly, so let's dig a bit deeper.

Root cause

The core crux of the issue is in filesystem.py's FilesetFileSystem._put_file, which implements fsspec.

file_size =  (await anyio.Path(lpath).stat()).st_size

async def stream_file() -> AsyncIterator[bytes]:
    async with await anyio.open_file(lpath, "rb") as f:
        while chunk := await f.read(self.blocksize):
            callback.relative_update(len(chunk))
            yield chunk

await self._client.with_headers({"Content-Length": str(file_size)}).upload_file(
    ...
    content=stream_file(),
)

The stream_file() is a generator object that gets called and stored once in PreparedRequest.content and reused by every pass of NemoClient's retry loop. If any transient failures occur, then the same Content-Length followed by zero bytes (the generator is already exhausted) is sent, which breaks the HTTP/1.1 protocol, and we get a httpx.LocalProtocolError, which subclasses TransportError, which gets retried until attempts ran out. This is bad because we actually lose the information for the original error.

So what could've caused the original failure?

Let's start with entrypoint from Customizer's file_io taks:

UPLOAD_TIMEOUT = httpx.Timeout(30.0, write=10 * 60, read=5 * 60)
self.sdk.with_options(timeout=UPLOAD_TIMEOUT).files.upload(...)

The expected behaviour is that UPLOAD_TIMEOUT would propagate down to files.upload, which should extend the write timeout to a generous 600s. However, this is not the case. What actually happened was:

  • with_options(...) created a new NeMoPlatform client with the new timeout based on UPLOAD_TIMEOUT, but keeps the original httpx.Client, which has its own timeout (60s). So now there are two timeouts:
    • NeMoPlatform.timeout = Timeout(30, write=600, read=300)
    • NeMoPlatform._client.timeout = Timeout(60, ...)
  • then client_from_platform shares the NeMoPlatform._client. The sharing is not wrong by itself, it's the fact that we didn't propagate the NeMoPlatform.timeout in the first place.
  • the issue is made worse by the fact that downstream,FilesetFileSystem._ensure_async built a brand new https.AsyncClient with no timeout at all, which defaults to 5s.

Essentially, FilesetFileSystem._put_file's httpx client had a timeout of 5s (on both writes and reads). This could error for both scenarios:

  1. Streaming all the chunks of a large file took longer than 5s, times out on the write phase
  2. Even if we streamed all the chunks, the Files service might not respond within 5s because it needs to commit all the chunks to storage, triggering a timeout

The fix involved changes in multiple layers of the code:

  • client/client.py -- don't retry requests with generator bodies, at least not at this layer of the code
  • tasks/file_io/run.py -- retry here instead, since it can rebuild the generator
  • client/adapter.py + filesystem/filesystem.py -- propagate the timeout properly
    • Adapter applies client.with_options(timeout=platform.timeout)
    • ensure_async propagates the timeout onto both the new https.AsyncClient as well as the AsyncFilesClient

Summary by CodeRabbit

Bug Fixes

  • Bug Fixes

    • Client timeout settings are now preserved across synchronous, asynchronous, and filesystem operations, including unlimited-timeout configurations.
    • Retry behavior now avoids replaying partially consumed streaming uploads.
    • File operations retry more reliably for temporary connection, rate-limit, and server errors.
    • Upload failures now surface consistently after retry attempts are exhausted.
    • Fileset metadata is validated consistently during creation and updates.
    • File listings now provide consistent file details.
  • Documentation

    • Clarified retry behavior for one-time request bodies and streaming uploads.

@github-actions github-actions Bot added the fix label Aug 6, 2026
@albcui
albcui force-pushed the albcui/NVBUG6562815-fix-fileset-upload-retry-body branch from df72839 to b93e86a Compare August 6, 2026 14:24
@albcui
albcui marked this pull request as ready for review August 6, 2026 14:25
@albcui
albcui requested review from a team as code owners August 6, 2026 14:25
@coderabbitai

coderabbitai Bot commented Aug 6, 2026

Copy link
Copy Markdown
Contributor

Review Change Stack

Note

Reviews paused

It looks like this branch is under active development. To avoid overwhelming you with review comments due to an influx of new commits, CodeRabbit has automatically paused this review. You can configure this behavior by changing the reviews.auto_review.auto_pause_after_reviewed_commits setting.

Use the following commands to manage reviews:

  • @coderabbitai resume to resume automatic reviews.
  • @coderabbitai review to trigger a single review.

Use the checkboxes below for quick actions:

  • ▶️ Resume reviews
  • 🔍 Trigger review
📝 Walkthrough

Walkthrough

The change propagates timeouts through platform and filesystem clients, restricts retries for consumed request bodies, and updates file operations to use typed outputs, validated metadata, and shared transient exceptions.

Changes

Client reliability and file operations

Layer / File(s) Summary
Client timeout propagation
packages/nemo_platform_plugin/src/nemo_platform_plugin/client/adapter.py, packages/nemo_platform_plugin/src/nemo_platform_plugin/client/client.py, packages/filesets/src/filesets/filesystem/filesystem.py, packages/nemo_platform_plugin/tests/client/*, packages/filesets/tests/test_filesystem_client.py
Configured transport and per-request timeouts propagate through typed clients and filesystem clients. timeout=None remains unlimited. Tests cover defaults, overrides, cloning, workspace, retry settings, and upload propagation.
Replayable request retry handling
packages/nemo_platform_plugin/src/nemo_platform_plugin/client/client.py, packages/nemo_platform_plugin/src/nemo_platform_plugin/client/types.py, packages/nemo_platform_plugin/tests/client/test_retry_streaming_body.py
Retry decisions inspect request-body replayability across synchronous, asynchronous, streaming, and non-streaming paths. Tests cover transport failures, responses, read timeouts, retry headers, logging, and exhausted iterators.
Typed file operation retries
packages/nmp_customization_common/src/nmp/customization_common/tasks/file_io/run.py, packages/nmp_customization_common/tests/tasks/test_file_io.py
File operations use typed outputs, shared transient exceptions, validated metadata, and retry coverage for creation and upload failures.

Sequence Diagram(s)

sequenceDiagram
  participant Caller
  participant NemoClient
  participant RequestBody
  participant Server
  Caller->>NemoClient: send request
  NemoClient->>RequestBody: inspect replayability
  NemoClient->>Server: transmit request
  Server-->>NemoClient: response or transport failure
  NemoClient->>NemoClient: retry only when body state permits
  NemoClient-->>Caller: response or NemoTransportError
Loading

Possibly related PRs

Suggested reviewers: mckornfield, callingmedic911

🚥 Pre-merge checks | ✅ 4 | ❌ 1

❌ Failed checks (1 warning)

Check name Status Explanation Resolution
Docstring Coverage ⚠️ Warning Docstring coverage is 47.76% which is insufficient. The required threshold is 80.00%. Write docstrings for the functions missing them to satisfy the coverage threshold.
✅ Passed checks (4 passed)
Check name Status Explanation
Description Check ✅ Passed Check skipped - CodeRabbit’s high-level summary is enabled.
Title check ✅ Passed The title clearly and concisely describes the main changes: FileSet upload retry handling and timeout propagation.
Linked Issues check ✅ Passed Check skipped because no linked issues were found for this pull request.
Out of Scope Changes check ✅ Passed Check skipped because no linked issues were found for this pull request.
✨ Finishing Touches 💡 1
📝 Generate docstrings 💡
  • Create stacked PR
  • Commit on current branch
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Commit unit tests in branch albcui/NVBUG6562815-fix-fileset-upload-retry-body

Comment @coderabbitai help to get the list of available commands.

@coderabbitai coderabbitai Bot 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.

Actionable comments posted: 1

🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Inline comments:
In `@packages/nemo_platform_plugin/src/nemo_platform_plugin/client/client.py`:
- Around line 245-251: Update the retry-suppression log condition near
_should_retry so it runs only when the response would otherwise be retryable
with replayable=True and produce a backoff, while the actual request is
non-replayable, backoff is None, and retries remain. Do not log for successful
or otherwise non-retryable responses; preserve the existing retry and return
behavior.
🪄 Autofix

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: Path: .coderabbit.yaml

Review profile: CHILL

Plan: Enterprise

Run ID: 42fd85f4-47aa-4dcb-ab0f-4f0f27ff9e0d

📥 Commits

Reviewing files that changed from the base of the PR and between a1b7051 and b93e86a.

⛔ Files ignored due to path filters (1)
  • sdk/python/nemo-platform/src/nemo_platform/filesets/filesystem/filesystem.py is excluded by !sdk/**
📒 Files selected for processing (9)
  • packages/filesets/src/filesets/filesystem/filesystem.py
  • packages/filesets/tests/test_filesystem_client.py
  • packages/nemo_platform_plugin/src/nemo_platform_plugin/client/adapter.py
  • packages/nemo_platform_plugin/src/nemo_platform_plugin/client/client.py
  • packages/nemo_platform_plugin/src/nemo_platform_plugin/client/types.py
  • packages/nemo_platform_plugin/tests/client/test_adapter.py
  • packages/nemo_platform_plugin/tests/client/test_retry_streaming_body.py
  • packages/nmp_customization_common/src/nmp/customization_common/tasks/file_io/run.py
  • packages/nmp_customization_common/tests/tasks/test_file_io.py

Comment thread packages/nemo_platform_plugin/src/nemo_platform_plugin/client/client.py Outdated
@github-actions

github-actions Bot commented Aug 6, 2026

Copy link
Copy Markdown
Contributor
Suite Lines Covered Line Rate Branch Rate
Unit Tests 31454/40076 78.5% 63.0%
Integration Tests 18318/38028 48.2% 20.8%

@albcui albcui changed the title fix(client): propagate timeouts properly fix(client): repair FileSet upload retries and timeout propagation Aug 6, 2026
albcui added 4 commits August 6, 2026 12:03
… through

Retrying a streaming upload re-sent an exhausted iterator under the
original Content-Length, so h11 aborted with "Too little data for
declared Content-Length" and masked the real failure. Retry such a body
only while it is still untouched -- that is, on a connection-establishment
failure, which happens before httpx reads any of it -- and surface
anything later to the caller. A retryable status code is never replayed
either, since the body is spent by the time a response arrives.

file_io's tenacity layer rebuilds the request from the source file on
every attempt, so it is where an upload retry belongs. Teach it to catch
what the client actually raises: NemoTransportError, InternalServerError
and RateLimitError are client types, not httpx ones, so none of them were
being retried.

Also carry the platform's timeout into the typed client and onto the
async transport FilesetFileSystem builds. Both silently fell back to
their own defaults -- the latter to httpx's 5s, which a multi-GB upload
blows through waiting for the server to commit the body to storage.

Drive-by, so the ty pre-commit hook passes on the touched files: correct
list_fileset_files' return annotation to the FilesetFileOutput it really
returns, validate fileset metadata into FilesetMetadata explicitly, and
add ty suppressions next to the existing mypy ones in test_adapter.

NVBUG6562815

Signed-off-by: Albert Cui <albcui@nvidia.com>
… see

_create_fileset_with_retry talks only to the typed client, which wraps
everything it raises. Five of the six entries in its retry tuple could
never fire: the two bare httpx types arrive as NemoTransportError, and
the three Stainless SDK types belong to a path this method never takes.
Only ClientInternalServerError was live.

So a connection refusal or timeout against the Files service -- the case
the retry exists for -- sailed past tenacity on the first attempt and
came out of sdk_error_handler as a terminal FileUploadError. Same root
cause as the upload retry, second call site.

Share TRANSIENT_FILESYSTEM_EXCEPTIONS instead, which adds
NemoTransportError and RateLimitError and drops the dead entries. Ruff
then found the three SDK imports unused across the whole module,
confirming they were unreachable rather than merely redundant here.

Signed-off-by: Albert Cui <albcui@nvidia.com>
_should_retry_request logged "not retrying: one-shot stream" on every
outcome it did not retry, which included every successful upload -- one
misleading line per file in the customizer's job logs. Ask _should_retry
again as if the body were replayable to tell that case apart from a
success, a 404, or an exhausted attempt budget.

Drop bytearray and memoryview from _is_replayable. httpx treats anything
that is not bytes/str as an iterable of chunks, and iterating either of
those yields int, so it rejects both on the first attempt regardless of
what the predicate claims -- neither could ever have reached a retry.

Cover the interaction the spent-body check has with the retry-decision
headers client_from_platform turns on: a server sending x-should-retry:
true cannot conjure back a body that is already spent, and a replayable
body must still honour it. Neither had a test, and moving the check below
that branch silently breaks the first.

Also drop an isinstance guard in the adapter that can never be false,
give _create_fileset_with_retry the before_sleep_log its two sibling
retries already have, and validate create's fileset metadata into
FilesetMetadata the way the update path does.

NVBUG6562815

Signed-off-by: Albert Cui <albcui@nvidia.com>
_should_retry and _should_retry_request only ever ran as a pair: the
first decided, the second added the body's say in it. The split was an
artefact of layering one-shot-body handling onto the existing decision
logic, and it cost a second _should_retry call just to work out whether
the body was the reason a retry stopped.

Let the policy decide first and give the body a veto at the end. Reaching
the tail already means a retry was due, so the veto is the only thing
that can stop it there -- which is exactly when the decline is worth
logging. The replayable keyword and the repeat call both fall out.

The x-should-retry branch no longer returns early either. It and the
status-code path applied the same retry-after logic, so they now share a
tail, which is what lets the veto cover both without being written twice.

No behaviour change: the same 207 tests pass, and both mutations the
streaming-body tests were written to catch still fail against the
consolidated function.

Signed-off-by: Albert Cui <albcui@nvidia.com>
@albcui
albcui force-pushed the albcui/NVBUG6562815-fix-fileset-upload-retry-body branch from 28ff4c2 to ab3ea2b Compare August 6, 2026 16:03
@coderabbitai

coderabbitai Bot commented Aug 6, 2026

Copy link
Copy Markdown
Contributor

Note

GitHub couldn't provide a complete incremental comparison for this pull request, so CodeRabbit is performing a full review instead. This review may take a little longer.

@albcui albcui changed the title fix(client): repair FileSet upload retries and timeout propagation fix(client): FileSet upload retries and timeout propagation Aug 6, 2026
@albcui
albcui requested review from matthewgrossman and soluwalana and removed request for soluwalana August 6, 2026 20:32

@ironcommit ironcommit 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.

LGTM. This works around our very messy transport system. But it will do.

Comment thread packages/filesets/src/filesets/filesystem/filesystem.py Outdated
Comment thread packages/nemo_platform_plugin/src/nemo_platform_plugin/client/adapter.py Outdated
Signed-off-by: Albert Cui <albcui@nvidia.com>
@albcui
albcui enabled auto-merge August 6, 2026 22:24
@albcui
albcui disabled auto-merge August 7, 2026 15:24
@albcui
albcui enabled auto-merge August 7, 2026 15:24
@albcui
albcui added this pull request to the merge queue Aug 7, 2026
Merged via the queue into main with commit c62a514 Aug 7, 2026
52 checks passed
@albcui
albcui deleted the albcui/NVBUG6562815-fix-fileset-upload-retry-body branch August 7, 2026 15:57
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants