Skip to content

feat(fal): opt in to node-local SDK uploads - #1182

Merged
dennybritz merged 25 commits into
mainfrom
cdn-uploader
Oct 13, 2026
Merged

dennybritz merged 25 commits into
mainfrom
cdn-uploader

Conversation

@dennybritz

@dennybritz dennybritz commented Sep 21, 2026 •

Copy link
Copy Markdown
Contributor

Resolves PLAT-5240.

SDK uploads currently keep the runner waiting for remote storage. This adds an internal, opt-in path to the node-local uploader: File.from_path("/tmp/video.mp4") returns once the node has durably accepted the complete file, while the uploader finishes the CDN transfer in the background.

Everything is gated by FAL_USE_LOCAL_UPLOADER. With the variable unset, 0, false, or empty, uploads still use FalFileRepositoryV3. The flag is read on the runner at upload time.

Upload path

File.from_bytes / File.from_path
  └─ _try_with_fallback
       └─ get_builtin_repository("fal_v3")
            ├─ flag off → FalFileRepositoryV3
            └─ flag on  → LocalFileRepository
                            └─ POST /uploads over the Unix socket
                                 └─ 202 accepted_local + file_url

The fal_v3 registry factory selects the repository. Deprecated fal_v2 and cdn IDs resolve through the same factory. Explicit repository objects keep their chosen destination. A caller upload policy still takes precedence over the default destination, including an explicitly constructed LocalFileRepository().

LocalFileRepository adapts the existing bytes/file methods to _local_uploader.upload(file_name, content, headers). The client accepts bytes or a Path, sends Content-Length, and waits for the uploader's accepted_local receipt.

  • The default socket is /run/fal-upload/upload.sock; CDN_UPLOADER_SOCKET_PATH overrides it. Proxy settings are ignored.
  • Small files are read once and retained as FileData, preserving File.as_bytes() behavior. Files selected for multipart handling stream in explicit 64 KiB reads. This bounds reads even on older supported httpx versions. Each rejected file submission reopens the source from byte zero.
  • The uploader owns remote multipart transfers and retries. The SDK's multipart options determine whether file bytes are retained locally.
  • Headers reuse V3 authentication and carry the caller's CDN token, request ID, content type, and lifecycle preferences. Fal credentials are required; a CDN token alone is insufficient for this adapter.
  • Filenames are normalized to printable ASCII before entering a header: non-ASCII characters become ?, control characters become ?, and surrounding whitespace is removed. For example, vidéo.mp4 is sent as vid?o.mp4.

Settings needing account defaults or recipient lookup remain available to the uploader's REST reservation path.

Retry and fallback

Fallback is allowed only when the SDK knows the uploader did not take the work. It uses FalFileRepositoryV3 with the original save arguments.

Outcome SDK behavior
HTTP 429: full or draining Retry after 100 ms, then 200 ms; after the third rejection, upload directly through V3.
Cannot connect, send failure, missing local-client dependency, or non-202 HTTP response below 500 Upload directly through V3.
HTTP 5xx, interrupted/lost response, read timeout, or invalid acceptance receipt Raise LocalUploadError; do not submit the file again.

Errors name the HTTP status or transport exception class without echoing response bodies, signed URLs, or credentials.

LocalUploadError.falls_back = False also stops the outer File fallback chain. This matters because the usual File.from_bytes and File.from_path calls otherwise retry against the legacy fal repository. The exception attribute survives toolkit serialization, so the same rule applies on the runner. Definite refusals permit fallback; other repository errors keep their existing fallback behavior.

For example, with the flag enabled:

video = File.from_path("/tmp/video.mp4")
# Returns after the node has accepted the complete file.
# The CDN URL may still be pending while the uploader transfers it.

A missing socket uses direct V3 upload. A lost acceptance reply raises: the node may already be transferring the file, so resubmitting could create a second copy. This trades availability on uncertain failures for avoiding automatic duplicate uploads.

Generated streams

For output whose size is unknown until it is fully produced, LocalFileRepository.save_stream uses the uploader's session API. Nothing calls it yet; it is there for a future public entry point such as File.from_stream.

def frames():
    for frame in encoder:
        yield frame.to_bytes()

url = LocalFileRepository().save_stream(frames(), "out.mp4", "video/mp4")
POST /upload-sessions              -> 201 {"upload_id": "s-1", "state": "receiving"}
PUT  /upload-sessions/s-1/body     (chunks, counted as they stream) -> 204
POST /upload-sessions/s-1/finish   {"size_bytes": N} -> 202 accepted_local
  • Byte count: finish carries the counted size, so the uploader rejects a truncated body.
  • Aborting: if the producer raises, or the body or finish request fails, the SDK sends DELETE /upload-sessions/s-1 and re-raises the original error.
  • No fallback: streams never fall back to V3, because the producer cannot be replayed.
  • Waiting: wait_for_completion=True waits after finish, the same way as for bytes and files.

Limits and follow-ups

  • Local acceptance does not mean the CDN file is readable yet, and it does not protect against losing the node's spool disk. Completion tracking is PLAT-5925.
  • Cancelling an async wrapper's await does not stop its upload thread.
  • Plain function runners may lack httpx. They currently fall back to V3; PLAT-5926 tracks installing the dependencies so those runners can use local acceleration.
  • Generated streams are reachable only through LocalFileRepository.save_stream, which has no public File entry point yet.

Validation

FAL_KEY=test:key FAL_CONFIG_PATH=/dev/null \
PYTHONPATH=projects/fal/src:projects/isolate_proto/src:projects/fal_client/src:projects/fal/openapi-fal-rest \
python -m pytest projects/fal/tests/unit/toolkit projects/fal/tests/unit/test_serialization.py -q

pre-commit run --all-files

The toolkit and serialization suites pass: 495 passed, 1 skipped (missing GCLOUD_SA_JSON). The 74 local-uploader/socket tests also pass with httpx 0.20.0. All pre-commit hooks pass.

Tests cover routing, authentication and metadata, bounded file reads, 429 replay, missing-httpx fallback, and uncertainty through the public File entrypoints, plus the stream session sequence and abort on producer failure. Against a real cdn-uploader built from isolate-cloud main (CDN_UPLOADER_TEST_BINARY), the 5 integration tests pass. They include a generated stream that waits for CDN completion. The socket tests serialize the toolkit in a separate process, then verify both successful acceptance and a dropped reply after the server receives the complete body. The dropped-reply case must raise without calling another repository.

Optional process tests use a locally built Rust uploader and controlled HTTP storage peer to check 80 and 101 MiB uploads after caller exit, hashes, and full-queue fallback. They were not rerun for this fix because no built uploader binary was available:

CDN_UPLOADER_TEST_BINARY=<isolate-cloud>/monorust/target/debug/cdn-uploader \
python -m pytest projects/fal/tests/integration/toolkit/test_local_uploader.py -q

Note

Medium Risk
Changes default CDN upload routing and duplicate-upload semantics on runners with the flag enabled; fallback behavior is carefully gated but upload failures after local acceptance no longer retry automatically.

Overview
Adds an opt-in node-local upload path for the Python SDK (FAL_USE_LOCAL_UPLOADER), so default fal_v3 uploads can hand bytes/files to a Unix-socket HTTP uploader and return once the node has durably accepted the payload (CDN transfer continues in the background).

Routing: New _local_uploader client (POST /uploads, session API for unknown-size streams, optional wait_for_completion polling) and LocalFileRepository adapter. LocalUploadRefused (connect failures, 429 after brief replay, missing httpx) falls back to direct FalFileRepositoryV3; uncertain failures set falls_back = False so File’s repository fallback chain does not duplicate uploads.

Upload policy: File policy saves go through save_with_policy, which can delegate to the local uploader when opted in (policy JSON in headers, no fal CDN creds), with in-process S3 POST as fallback. _upload_file_name is factored out of _prepare_upload for shared validation/naming.

Tests cover routing, refusals vs hard errors, policy handoff, streams, serialization/socket behavior, and optional integration against the Rust cdn-uploader binary.

Reviewed by Cursor Bugbot for commit bbd031c. Bugbot is set up for automated code reviews on this repo. Configure here.

* feat(fal): opt in to node-local SDK uploads

* docs(fal): keep local uploader details internal

* fix(fal): prevent fallback after local upload failures
@linear-code

linear-code Bot commented Sep 21, 2026 •

Copy link
Copy Markdown

Move the FAL_USE_LOCAL_UPLOADER check into get_builtin_repository, where
repository ids already resolve, and replace the isinstance no-fallback
guard with a FileRepository.falls_back class attribute that survives
pickle-by-value. Restore the _try_with_fallback name.

LocalFileRepository.save_file now reads small files once and delegates
to save, streaming only above the multipart threshold. The socket client
gets a single _new_client factory, verify=False on the Unix transport,
and loses the unreachable size validator, response URL re-validation,
and the test-only status_code attribute.

Tests patch the client factory instead of httpx globally, split the
branching error parametrize, and wait on a server-side event instead of
wrapping the client class.
LocalFileRepository.save_stream uploads output whose size is unknown
until the producer is exhausted. It carries the same caller headers as
save and save_file, counts bytes as they stream, and finishes with the
count so the uploader can reject a truncated body. A producer that
raises aborts the session.

Reachable only through the local repository; File has no stream entry
point yet.
@dennybritz
dennybritz marked this pull request as ready for review September 22, 2026 02:19

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

Stale Bugbot comment from a previous run.

Comment thread projects/fal/src/fal/toolkit/file/_local_uploader.py Outdated
httpx encodes header values as ASCII, so a non-ASCII file name raised
UnicodeEncodeError with FAL_USE_LOCAL_UPLOADER on while the REST path,
which sends the name in a JSON body, accepted it. The uploader and the
CDN reject non-ASCII header bytes too, so encode on the client.

Only bytes outside printable ASCII are encoded; ordinary names are sent
unchanged, as the Rust client sends them.
A timeout, a peer close, and a write failure all read as "connection
was interrupted", so logs could not tell them apart. Include the httpx
exception class name; the exception text stays out because it can carry
credentials or signed URLs.

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

Beyond the inline findings, I also checked LocalFileRepository.save_file for a stat/stream size race (local.py:113) — os.path.getsize() is read before the file is reopened for streaming, but since falls_back=False on this repository, a size mismatch there was ruled out as a real risk for the intended single-write-then-upload runner workflow rather than a new bug.

Extended reasoning...

This PR adds an opt-in node-local upload path (LocalUploader, LocalFileRepository, save_stream) touching file upload headers, a new falls_back/no-retry semantic, and Unix-socket protocol code; two confirmed findings (UnicodeEncodeError on non-ASCII file names, and an incorrect upload-policy-conflict rejection when repository=LocalFileRepository() is passed explicitly) are already queued as inline comments. I additionally reviewed the save_file size-race candidate (stat then reopen) and treated it as ruled out given the expected write-then-upload usage pattern rather than a newly introduced defect worth flagging again. No third-party CHANGES_REQUESTED review is visible in the timeline, but given the confirmed findings a human should still land the fixes before merge.

Findings marked 🟡 are optional suggestions and need no follow-up push.

Additional findings (outside the current diff — GitHub can't attach inline comments there):

  • 🟡 projects/fal/src/fal/toolkit/file/file.py — Apps that pass repository=LocalFileRepository() explicitly (a supported, tested pattern, e.g. test_errors_do_not_retry_or_fall_back) get a caller's upload-policy override wrongly rejected, unlike apps using the default "fal_v3" string or an explicit FalFileRepositoryV3(). _DEFAULT_REPOSITORY_TYPES (file.py:99) lists only "FalFileRepositoryV3", so _is_default_repository (file.py:105) treats LocalFileRepository as a foreign destination and _require_no_repository_conflict raises UploadPolicyInputError naming "LocalFileRepository" to the caller, even though it is the very destination the env-var path treats as the default fal_v3 target. …

    Why this was flagged

    …Fix: have _is_default_repository also recognize LocalFileRepository (or, more generally, whatever get_builtin_repository substitutes for "fal_v3") as default-equivalent.

    Trigger: an app explicitly constructs File.from_bytes/from_path(..., repository=LocalFileRepository()) and a caller request also carries the x-fal-upload-policy header (get_upload_policy(request) is not None). _require_no_repository_conflict (file.py:108) calls _is_default_repository(repository); since type(repository).name == "LocalFileRepository" is not in _DEFAULT_REPOSITORY_TYPES = {"FalFileRepositoryV3"} (file.py:99), it returns False and the function raises UploadPolicyInputError. On base/main, an app using the equivalent FalFileRepositoryV3() object or the "fal_v3" string id would pass this check via _DEFAULT_REPOSITORY_TYPES or _DEFAULT_REPOSITORY_IDS and let the caller's policy proceed. The safeguard this bypasses is the intended one: _DEFAULT_REPOSITORY_TYPES exists precisely to treat pickle-identity-unstable stand-ins for the default destination as default, but LocalFileRepository,…

    Verification: nit. The mechanism is real. _DEFAULT_REPOSITORY_TYPES = frozenset({"FalFileRepositoryV3"}) (file.py:99) omits LocalFileRepository, so _is_default_repository (file.py:105: type(repository).__name__ in _DEFAULT_REPOSITORY_TYPES) returns False for a LocalFileRepository() instance, and _require_no_repository_conflict (file.py:114 -> 120) raises UploadPolicyInputError at the two call…

Comment thread projects/fal/src/fal/toolkit/file/_local_uploader.py Outdated
* refactor(fal): define the local uploader socket directory

* refactor(fal): keep only the consumed socket default
…ck (#1199)

* fix(fal): retry definite local upload rejections before direct CDN

* refactor(fal): replay upload bodies through one local client

* test(fal): trim redundant large-file fallback assertions

* refactor(fal): simplify definite rejection retries and fallback

* test(fal): expect definite 429 rejections from the uploader

* test(fal): consolidate rejection fallback coverage

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

Stale Bugbot comment from a previous run.

Comment thread projects/fal/src/fal/toolkit/file/providers/local.py Outdated
…ctions

The streaming session had one caller that always ran begin, body and finish
in order, so its state flags and misuse guards protected nothing. upload()
and upload_stream() now open their own client, count streamed bytes, and
abort on any failure. Reuse the shared retry helper for 429 replays and let
httpx read large files directly.
save_file read small files through the base class into the retrying save,
so a rejection was replayed inside the outer save_file retry. Upload the
bytes directly; each entry point now has exactly one retry and fallback.
Any uploader error now uploads through FalFileRepositoryV3 after the 429
retries, so a missing socket or lost response no longer fails the save.
This removes the FileRepository.falls_back flag and the acceptance
uncertainty tracking, which only existed to block fallback. A lost
response can now publish a second URL.

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

Stale Bugbot comment from a previous run.

Comment thread projects/fal/src/fal/toolkit/file/providers/local.py
…itory

LocalFileRepository is what FAL_USE_LOCAL_UPLOADER substitutes for the
default destination, so passing it explicitly must not reject a caller's
upload policy as a foreign-destination conflict.
…d the work

Sanitize file names as the REST API does so both upload paths publish the
same name. Raise instead of re-uploading after the uploader may have
accepted the bytes. Replay rejections around the request only, read the
file once per attempt, and build headers from the V3 repository. Select
the local repository from the fal_v3 registry factory. Drop the unused
stream API and overlapping tests.
* feat(fal): let local uploads wait for CDN completion (PLAT-5925)

save_kwargs={"wait_for_completion": True} makes LocalFileRepository
long-poll the uploader's GET /uploads/{id}?wait= until the CDN has the
file. A failed or unknown transfer falls back to a direct CDN upload;
streams raise.

* refactor(fal): make wait_for_completion keyword-only in the local uploader client

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

Stale Bugbot comment from a previous run.

Comment thread projects/fal/src/fal/toolkit/file/_local_uploader.py
LocalFileRepository.save_stream drives the uploader's session API: reserve,
stream the body, then finish with the counted byte count. Any failure,
including a raising producer, aborts the session; streams never fall back
because the producer cannot be replayed. wait_for_completion applies after
finish. Uploads and sessions share one request classifier.
Move the session protocol into _stream next to _accept so both public
functions read the same way, and give _request explicit keyword arguments.

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

Cursor Bugbot has reviewed your changes using default effort and found 1 potential issue.

Fix All in Cursor

❌ Bugbot Autofix is OFF. To automatically fix reported issues with cloud agents, enable autofix in the Cursor dashboard.

Reviewed by Cursor Bugbot for commit 6eb8bb3. Configure here.

Comment thread projects/fal/src/fal/toolkit/file/_local_uploader.py
…r (PLAT-5890) (#1213)

* feat(fal): route upload-policy uploads through the node-local uploader (PLAT-5890)

Opted-in runners hand outputs carrying x-app-fal-upload-policy to the
node-local uploader, which POSTs them to the caller's bucket. The policy is
forwarded without fal credentials, so an uploader without policy support
refuses it instead of writing to the fal CDN. Refusals upload in-process as
before; a 413 or 422 from the uploader is raised as UploadPolicyError.

* fix(fal): percent-encode policy upload file names for the local uploader

Header values must be ASCII, so non-ASCII names were rewritten to "?" and
keyed differently from the in-process path. The uploader decodes the name,
so both paths now store the same key and return the same URL. The fal CDN
path keeps the "?" rewrite that matches the REST API.
Fold the refusal and possibly-accepted tables into one classification
table. Drop the 429 row, the explicit-repository dimension, the
missing-socket test and the real-socket 429 replay, which repeat other
tests.
@dennybritz
dennybritz merged commit 20220c2 into main Oct 13, 2026
15 of 31 checks passed
@dennybritz
dennybritz deleted the cdn-uploader branch October 13, 2026 10:07
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