# Downloading files This document describes how file downloads work in EDRI, including resumable downloads (HTTP Range) and high-throughput streaming via `SharedMemoryPipe`. --- ## Two common download strategies EDRI supports two primary ways to serve downloads: 1. **File-backed response (seekable, size-known)** * best for classic HTTP downloads * supports `Range` resume automatically 2. **Streamed response (on-the-fly generation)** * best for generating large outputs dynamically (e.g. zipping many files) * can use `SharedMemoryPipe` for throughput * does not support HTTP `Range` resume (size may be unknown) --- ## File-backed downloads (recommended when possible) If your response can point to a real file on disk (a `pathlib.Path`), EDRI can provide a full HTTP download experience. ### Resume (HTTP Range) When the response body is backed by `pathlib.Path`, EDRI automatically: * parses the client's `Range` header * **requires an `If-Range` header** matching the file's `ETag` — Range is only honored when `If-Range` matches exactly * If `If-Range` matches and the range is valid: returns **206 Partial Content** with a `Content-Range` header and the requested byte slice * If `If-Range` does not match or is missing: serves the full file (200 OK) * If the range is **malformed** (unparseable): the `Range` is ignored and the full file is served (200 OK), per RFC 7233 * If the range is **well-formed but unsatisfiable** (e.g. entirely past the end of the file): returns **416 Range Not Satisfiable** with `Content-Range: bytes */` and an empty body > **Note:** Without an `If-Range` header (or if it doesn't match the `ETag`), Range requests are ignored and the full file is returned. > Developer note: respond with the file as usual. EDRI handles Range logic automatically. ### Errors reaching the file When the `pathlib.Path` cannot be served, EDRI maps the failure to a status *before* the response body starts: * file does not exist (`FileNotFoundError`) → **404 Not Found** * any other filesystem error — stale handle, connection reset, backend down (`OSError`) → **503 Service Unavailable** * `response` attribute `file` not filled (`AttributeError`) → **500 Internal Server Error** If a read fails *after* streaming has begun (headers already sent), the status can no longer change: the failure is logged and the stream is aborted. All filesystem operations (`stat`, `open`, `seek`, `read`, `close`) run in a worker thread so they never block the asyncio event loop. (two-unrelated-etag-mechanisms)= ### Two unrelated ETag mechanisms The `ETag`/`If-Range` used here is **not** the same as the cache ETag from `@request(cache=...)` caching ({ref}`manager-level-caching`). They are independent: | | Download (`response_file`) | Cached request (`@request(cache=...)`) | |---|---|---| | Source | `File.fingerprint` — an **imprint of the file's content** | `Cache.tag()` → `"-"` (**identifier + timestamp**, not a content hash) | | Conditional header | `If-Range` (gates `Range` resume) | `If-None-Match` (→ **304 Not Modified**, empty body) | | Purpose | resume a partial download against a known-unchanged file | skip re-sending an unchanged representation | They never interact: a `304` carries no body (so there is nothing to serve a partial from), and a client does not Range-request a representation it already holds in cache. --- ## Streamed downloads (on-the-fly generation) Some downloads are too large to materialize first, or you want to generate them dynamically. Example use cases: * creating a ZIP archive on the fly from many files * streaming a generated report * exporting large datasets ### SharedMemoryPipe `SharedMemoryPipe` provides a high-throughput tunnel from a **Worker** to an **API client**, implemented using: * POSIX shared memory * POSIX semaphores This avoids copying large payloads through intermediate processes. See {doc}`/shared_memory_pipe` for full details. ### Lifetime `SharedMemoryPipe` instances are **per-request**: * created for one request/response flow * used to transfer the payload * cleaned up when the request completes ### Resume limitation Classic HTTP resume via `Range` requires a seekable, fixed-size representation. For streamed outputs where total size is unknown (or not stable), HTTP `Range` resume is not supported. --- ## Declaring a file download EDRI provides a `File` helper type for describing downloadable files. Below are two short examples showing both supported approaches. ### Event and response definition ```python from edri.api.dataclass import File from edri.dataclass.event import Event, event from edri.dataclass.response import Response, response @response class DownloadArchiveResponse(Response): file: File @event class DownloadArchive(Event): response: DownloadArchiveResponse ``` --- ## Option A: Path-backed download (resume-friendly) Use this when the file exists on disk or you can generate it fully before responding. ```python from pathlib import Path self.event.response.file = File( file_name="archive.zip", mime_type="application/zip", path=Path("/tmp/archive.zip"), ) ``` ✔ Supports HTTP `Range` resume (206 Partial Content). --- ## Option B: Streamed download via SharedMemoryPipe (throughput-friendly) Use this when generating the output on the fly (e.g. zipping a large folder). ```python from edri.api.dataclass import File from edri.utility.shared_memory_pipe import SharedMemoryPipe with SharedMemoryPipe() as smp: self.event.response.file = File( file_name="archive.zip", mime_type="application/zip", path=smp.reader(), ) self.event_send(self.event) ``` ✔ Ideal for very large dynamic outputs ✖ Does NOT support HTTP `Range` resume (size unknown upfront) --- ## Response headers EDRI sets the following headers for file downloads. ### Common headers (all downloads) For both Path-backed and streamed downloads: * **Content-Type** is set to the file’s MIME type. * **Content-Disposition** is set to `attachment` and includes a UTF-8 encoded filename using the `filename*=` syntax. This ensures correct media type handling and proper filename support across browsers. --- ### Path-backed downloads (`pathlib.Path`) Additional headers: * **Accept-Ranges: bytes** – indicates that byte-range requests are supported. * **ETag** – typically based on the file fingerprint. Required for proper HTTP `Range` resume behavior (must match `If-Range`). If the client sends a satisfiable `Range` header with a matching `If-Range`: * **Content-Length** is set to the size of the returned range. * **Content-Range** describes the returned byte range (`bytes -/`). * HTTP status: **206 Partial Content**. If the range is well-formed but unsatisfiable (matching `If-Range`): * **Content-Range** is `bytes */`. * HTTP status: **416 Range Not Satisfiable** (empty body). If no `Range` header is present, `If-Range` does not match, or the `Range` is malformed: * **Content-Length** is set to the total file size. * HTTP status **200 OK** is returned. --- ### Streamed downloads (`SharedMemoryPipe`) For streamed responses: * If the total size is known up-front, **Content-Length** is set. * If the total size is unknown, EDRI falls back to **Transfer-Encoding: chunked**. Because streamed outputs are not necessarily seekable and may not have a stable total size, HTTP `Range` resume is not supported in this mode. There is also no `File.fingerprint` to compute up front — the content is produced incrementally — so the `If-Range` validator the resume path depends on cannot exist. --- ## Implementation note `SharedMemoryPipe` is not selected automatically by EDRI. They are explicitly created by the developer — typically inside a **Worker**. The intended pattern is: * A Worker creates the `SharedMemoryPipe` * The Worker assigns the pipe reader to `event.response.file` * The Worker sends the event * The Worker streams bytes into the pipe This design keeps heavy data transfer out of the Manager thread and enables high-throughput streaming directly from the Worker to the API client.