SubtitleEdit/subtitleedit · error · InvalidOperationException

Cannot retry download on non-seekable stream after partial d

Error message

Cannot retry download on non-seekable stream after partial download

What it means

Thrown inside DownloadHelper's retry block when a transient failure occurs, the code wants to retry, but the destination stream cannot seek (CanSeek == false) and already has bytes written (Position > 0). Because retry/resume needs to rewind or set the write position, a non-seekable partial stream would produce a corrupted file, so the helper aborts rather than silently corrupt. The original failure is chained as InnerException.

Source

Thrown at src/ui/Logic/Download/DownloadHelper.cs:180

                // If this was the last retry, don't wait
                if (attempt >= maxRetries)
                {
                    break;
                }

                // Exponential backoff with jitter: wait before retrying
                var baseDelay = Math.Min(2000 * (int)Math.Pow(2, attempt - 1), 30000);
                var jitter = Random.Shared.Next(0, 1000);
                var delayMs = baseDelay + jitter;

                await Task.Delay(delayMs, CancellationToken.None).ConfigureAwait(false);

                // Don't reset stream position - we'll resume from where we left off
                // Only reset if we can't seek (which means we can't resume anyway)
                if (!destination.CanSeek && destination.Position > 0)
                {
                    throw new InvalidOperationException(
                        "Cannot retry download on non-seekable stream after partial download",
                        lastException);
                }
            }
        }

        // All retries exhausted
        var bytesDownloaded = destination.CanSeek ? destination.Position : 0;
        throw new InvalidOperationException(
            $"Failed to download file after {maxRetries} attempts. URL: {url}. Downloaded: {bytesDownloaded}/{totalBytes ?? 0} bytes",
            lastException);
    }

    private static async Task<bool> CheckRangeSupport(
        HttpClient httpClient,
        string url,
        CancellationToken cancellationToken)
    {

View on GitHub (pinned to 17a9f07487)

Solutions

  1. Always pass a seekable destination (FileStream or MemoryStream) to DownloadFileAsync so retries can rewind/resume.
  2. If you truly need a non-seekable sink, download to a temp FileStream first, then copy to your sink.
  3. Disable retry by setting maxRetries: 1 only if you accept that any transient failure is fatal (rarely the right choice).
  4. Ensure the server supports range requests (CheckRangeSupport) and the stream is seekable for true resume semantics.

Example fix

// before: piping straight into a non-seekable stream
await DownloadHelper.DownloadFileAsync(http, url, networkStream, progress, ct);

// after: buffer to a seekable file, then forward
var tmp = Path.GetTempFileName();
await using (var fs = File.OpenWrite(tmp))
    await DownloadHelper.DownloadFileAsync(http, url, fs, progress, ct);
using var src = File.OpenRead(tmp);
await src.CopyToAsync(networkStream, ct);
Defensive patterns

Strategy: validation

Validate before calling

// Never hand a non-seekable stream with partial bytes to the downloader
if (!destination.CanSeek)
    throw new ArgumentException("Pass a seekable stream to enable retry/resume.", nameof(destination));

Type guard

static bool IsRetrySafeDestination(Stream s) => s.CanSeek && s.CanWrite;

Try / catch

try { await DownloadHelper.DownloadFileAsync(http, url, nonSeekable, progress, ct); }
catch (InvalidOperationException ex) when (ex.Message.Contains("non-seekable stream"))
{ _logger.Error("Destination must be seekable for retries. Buffer to a file first."); throw; }

Prevention

When it happens

Trigger: Passing a non-seekable destination (e.g. a NetworkStream, certain wrapped streams, some crypto pipes) to DownloadFileAsync, where the first attempt downloads some bytes then hits a retryable HttpRequestException / TaskCanceledException / IOException / InvalidOperationException. On retry the guard trips because position cannot be rewound.

Common situations: Streaming a download directly to a pipe or another process's stdin; using a GZipStream/CryptoStream over a non-seekable base without buffering; a MemoryStream wrapped by a non-seekable adapter. Note: even when the server lacks range support, a seekable stream is rewound to startPosition on retry — non-seekable streams cannot be recovered.

Related errors


AI-assisted analysis of SubtitleEdit/subtitleedit@17a9f07487 (2026-08-13). Data as JSON: /api/errors/0cb6037223f8a9c0. Report an issue: GitHub.