From cfdee0cf596bd1499e4bec452f6c183e3d4a6edb Mon Sep 17 00:00:00 2001 From: "dobby-yivi-agent[bot]" <275734547+dobby-yivi-agent[bot]@users.noreply.github.com> Date: Thu, 7 May 2026 21:39:24 +0000 Subject: [PATCH] docs(sdk): document retry config, resumable downloads, UploadSessionExpiredError Closes #68 --- docs/sdk/js-decryption.md | 28 ++++++++++++++++++++++++++++ docs/sdk/js-encryption.md | 37 +++++++++++++++++++++++++++++++++++++ docs/sdk/js-errors.md | 35 +++++++++++++++++++++++++++++++++++ 3 files changed, 100 insertions(+) diff --git a/docs/sdk/js-decryption.md b/docs/sdk/js-decryption.md index f9e808d..20c232e 100644 --- a/docs/sdk/js-decryption.md +++ b/docs/sdk/js-decryption.md @@ -79,6 +79,34 @@ const result = await opened.decrypt({ *Provide either `element` or `session`. If neither is provided, the SDK throws a `DecryptionError`. +## Retries and resumable downloads + +The download GET retries transient failures using the same `retry` config as uploads — see [Encryption — Retry options](/sdk/js-encryption#retry-options) for the full table and defaults. + +A mid-stream failure (network drop, idle timeout) does not start over from byte zero. The SDK reissues the GET with a `Range: bytes=-` header and splices the resumed body onto what the consumer already saw. The retry counter is shared across resumes, so a flapping connection that delivers some bytes per attempt still exhausts the budget rather than looping forever. + +```ts +const pg = new PostGuard({ + pkgUrl, cryptifyUrl, + retry: { + onRetry: ({ attempt, maxAttempts, nextDelayMs }) => { + ui.showRetry(attempt, maxAttempts, nextDelayMs); + }, + }, +}); + +const result = await pg.open({ uuid }).decrypt({ element: '#yivi-web-form' }); +result.download(); +``` + +A resume is only accepted when Cryptify replies `206 Partial Content` with a `Content-Range` whose start byte matches the requested offset. A `200 OK` on a resume request is treated as fail-not-retry — some intermediaries (caching proxies, misconfigured CDNs) silently ignore `Range` and return the full body from byte zero, which would corrupt the decoded stream. The SDK surfaces the mismatch as a `NetworkError` so the retry loop short-circuits. + +[Source: cryptify.ts#L238-L277](https://github.com/encryption4all/postguard-js/blob/a60716e0b4eaaed0f3763a2eebbcf6c39fc0560d/src/api/cryptify.ts#L238-L277) + +::: warning Behaviour change in v1.6 +The internal `downloadFileWithRetry` helper now returns its `ReadableStream` synchronously instead of via a `Promise`. Stream-level errors (including the no-more-retries terminal error) surface on the consumer's first `read()`, not as a function-level rejection. Callers using the public `pg.open({ uuid }).decrypt(...)` API are unaffected — the SDK consumes the stream internally and a single `await` still surfaces the same errors. Only direct consumers of `downloadFileWithRetry` need to adjust. +::: + ## Recipient selection When the ciphertext was encrypted for multiple recipients, the SDK needs to know which recipient key to use. Pass the `recipient` parameter with the email address of the intended recipient. If there is only one recipient in the ciphertext, the parameter can be omitted. diff --git a/docs/sdk/js-encryption.md b/docs/sdk/js-encryption.md index 742e365..bee3959 100644 --- a/docs/sdk/js-encryption.md +++ b/docs/sdk/js-encryption.md @@ -89,6 +89,43 @@ Requires `cryptifyUrl` to be set in the constructor. *Provide either `files` or `data`, not both. +### Retry options + +Pass `retry` on the `PostGuardConfig` to tune how chunk PUTs and downloads handle transient failures. Defaults are sensible — supply a partial object to override only what you need. + +```ts +const pg = new PostGuard({ + pkgUrl: 'https://pkg.staging.yivi.app', + cryptifyUrl: 'https://fileshare.staging.yivi.app', + retry: { + maxAttempts: 5, + chunkTimeoutMs: 60_000, + onRetry: ({ attempt, maxAttempts, nextDelayMs }) => { + console.log(`retrying in ${nextDelayMs} ms (attempt ${attempt} of ${maxAttempts})`); + }, + }, +}); +``` + +[Source: retry.ts#L3-L27](https://github.com/encryption4all/postguard-js/blob/a60716e0b4eaaed0f3763a2eebbcf6c39fc0560d/src/util/retry.ts#L3-L27) + +| Field | Type | Default | Description | +|-------|------|---------|-------------| +| `maxAttempts` | `number` | `5` | Total attempts including the first one | +| `initialDelayMs` | `number` | `500` | Delay before the first retry | +| `maxDelayMs` | `number` | `30_000` | Cap on the pre-jitter exponential delay | +| `multiplier` | `number` | `2` | Multiplier applied between attempts | +| `chunkTimeoutMs` | `number` | `60_000` | Per-attempt timeout for a chunk PUT | +| `finalizeTimeoutMs` | `number` | `120_000` | Per-attempt timeout for the finalize call | +| `downloadTimeoutMs` | `number` | `0` (off) | Per-attempt timeout for the download GET. `0` means no per-attempt timeout — the retry budget bounds it instead | +| `onRetry` | `(event: RetryEvent) => void` | `undefined` | Fires after a retriable failure, before the backoff delay | + +`RetryEvent` carries `attempt` (1-indexed, the attempt that just failed), `maxAttempts`, the underlying `error`, and `nextDelayMs`. Use it to drive a "retrying… (attempt N of M)" indicator. + +What gets retried: 5xx responses, fetch-level network errors (`TypeError` from `Failed to fetch`), and per-attempt timeout aborts. What does not: 4xx responses, `UploadSessionExpiredError` (see [Error Handling](/sdk/js-errors#uploadsessionexpirederror)), and caller-driven aborts via your `AbortSignal`. `initUpload` and `finalizeUpload` are deliberately not retried — both are session-defining steps where a silent retry could mask a server-side state mismatch. + +The same `retry` config governs downloads. See [Decryption — Retries and resumable downloads](/sdk/js-decryption#retries-and-resumable-downloads). + ### Notify options The upload is silent by default. Both recipient and sender mails are opt-in. Pass `notify` to enable either or both. diff --git a/docs/sdk/js-errors.md b/docs/sdk/js-errors.md index 908a329..e904e77 100644 --- a/docs/sdk/js-errors.md +++ b/docs/sdk/js-errors.md @@ -8,6 +8,7 @@ The SDK exports a hierarchy of error classes. All errors extend `PostGuardError` Error └── PostGuardError ├── NetworkError + │ └── UploadSessionExpiredError ├── YiviNotInstalledError ├── YiviSessionError └── DecryptionError @@ -47,6 +48,40 @@ Thrown when: | `429` | Rate limited | Back off and retry | | `500` | Server error | Retry later | +## `UploadSessionExpiredError` + +A subclass of `NetworkError`. Thrown when Cryptify reports that the upload session is no longer known to the server — the session sat idle past its TTL, the server was restarted, or the path UUID is malformed. Distinct from `NetworkError` so retry policies short-circuit instead of burning the budget on something that will never recover. + +Surfaced from both chunk PUT and finalize paths, parsed from Cryptify's structured 404 body (`upload_session_not_found`). + +### Properties + +| Property | Type | Description | +|----------|------|-------------| +| `message` | `string` | `Upload session is no longer known to the server ()` | +| `status` | `number` | Always `404` | +| `body` | `string` | Raw 404 response body | +| `uuid` | `string` | The upload UUID that expired | +| `reason` | `string` | Cryptify's structured reason: `'expired_or_unknown'`, `'invalid_uuid'`, `'file_missing'` | + +```ts +import { UploadSessionExpiredError } from '@e4a/pg-js'; + +try { + await pg.encrypt({ files, recipients, sign }).upload(); +} catch (e) { + if (e instanceof UploadSessionExpiredError) { + showMessage('Your upload session expired. Please start over.'); + return; + } + throw e; +} +``` + +[Source: errors.ts#L31-L40](https://github.com/encryption4all/postguard-js/blob/a60716e0b4eaaed0f3763a2eebbcf6c39fc0560d/src/errors.ts#L31-L40) + +The user must start a new upload — there is no resume path once a session is gone. Catch this case before a generic `NetworkError` branch so the message is specific. + ## `YiviNotInstalledError` Thrown when `pg.sign.yivi()` or `opened.decrypt({ element })` is used but the required Yivi packages are not installed. The SDK attempts to dynamically import `@privacybydesign/yivi-core`, `@privacybydesign/yivi-client`, and `@privacybydesign/yivi-web`. If any import fails, this error is thrown.