A download stops after 800 MiB. You inspect the partial file, send Range: bytes=838860800-, and append the response.
That works only if several assumptions hold: the remote file has not changed, the server honors the range, the bytes on disk are the bytes you think they are, and the local file length represents durable progress.
If any assumption is false, “resume” can produce a corrupt file that looks complete.
A reliable downloader needs to answer two separate questions:
- Am I continuing the same remote representation?
- Can I trust the local prefix I am continuing from?
HTTP validators answer the first. Verified durable checkpoints help answer the second.
This article uses the resumable-download implementation introduced in WebDAVClient 3.1.0 as a concrete .NET example. The runnable code pins the published 3.2.0 package. Although the package also supports WebDAV, this transfer workflow uses HTTP GET, not DAV-specific discovery. The design applies to ordinary HTTP file endpoints that meet its validator and range requirements.
The small API hides a deliberate recovery policy
With an already-configured DavClient, the high-level call is:
using WebDAVClient.Transfers;
await client.DownloadFileAsync(
DavPath.FromSegments("backup.zip"),
"backup.zip",
cancellationToken);
After an interrupted attempt, call it again with the same endpoint, remote path, and local destination. There is no automatic retry loop.
The method stages data separately and publishes the destination only after validating the completed transfer. It does not resume arbitrary partial files, silently restart when content changes, or overwrite an existing destination.
Understanding those limits is more useful than treating the method as a generic “retry download” button.
1. Range chooses bytes, not a version
A range request asks for part of a representation:
GET /files/backup.zip HTTP/1.1
Host: storage.example.com
Range: bytes=1048576-
Accept-Encoding: identity
If the file is 3 MiB long, a suitable response to this request would be:
HTTP/1.1 206 Partial Content
ETag: "revision-42"
Content-Range: bytes 1048576-3145727/3145728
Content-Length: 2097152
Range endpoints are inclusive. The response above contains 2,097,152 bytes, not the entire 3,145,728-byte representation.
But Range alone does not establish that those bytes belong after your saved first MiB. Another process might have replaced the file with a different 3 MiB file.
Matching length is not matching identity.
2. Bind the transfer to a strong ETag
An ETag is an opaque server-supplied validator. Preserve its value, including quotes; do not assume it is an MD5 hash, timestamp, or content checksum.
These validators are different:
ETag: "revision-42"
ETag: W/"revision-42"
The second is weak. It can describe semantically equivalent representations without establishing the byte identity needed here. Removing W/ does not make it strong.
The helper requires a strong ETag and an explicit representation length on the initial response. It saves both with the checkpoint. Even an empty resource must satisfy the validator/length requirements.
A server without strong validators might still support useful downloads. It simply does not meet this workflow’s resume contract. The safe response is to make a separate, explicit full-download decision, not to guess at byte continuity.
HTTP also defines date-based conditional requests under specific rules. This helper deliberately does not use Last-Modified-only resume.
3. Understand what If-Range does on a mismatch
If-Range means, approximately: “Send this range if the representation still matches; otherwise send the whole current representation.”
A server can therefore respond with 200 OK and a complete file when the validator no longer matches. That response must never be appended to a saved prefix.
WebDAVClient’s nonempty-prefix resume request uses both conditions:
GET /files/backup.zip HTTP/1.1
Host: storage.example.com
Range: bytes=1048576-
If-Range: "revision-42"
If-Match: "revision-42"
Accept-Encoding: identity
If-Match separately makes a changed representation a failed precondition on a conforming server. The client still validates the actual response; sending a header is not proof that it was honored.
The resume path requires:
- A 206 response covering precisely the requested remainder.
- The same total representation length and exact saved strong ETag.
- Consistent range and content-length metadata, when content length is present.
- Identity-encoded content and exactly the expected number of bytes.
- A successfully observed end of the response stream.
An ignored-range 200, changed validator, malformed Content-Range, or short/overlong body is an explicit failure. A 416 is not treated as proof that the local file is already complete.
A checkpoint at offset zero uses a conditional full GET instead of a range. It still remains bound to the saved representation.
Turn off transparent decompression
Byte offsets apply to a particular representation. If the transport transparently decompresses a response, bytes written locally can stop corresponding to the offsets the server used.
The helper requests Accept-Encoding: identity and rejects encoded successful responses. When injecting HttpClient, also configure:
var handler = new HttpClientHandler
{
AllowAutoRedirect = false,
AutomaticDecompression = DecompressionMethods.None
};
Set this before constructing the transport. Once a handler has decompressed the body and removed the relevant headers, the library cannot reliably reconstruct what happened.
4. Do not use the partial file’s length as your checkpoint
A process can write bytes without having durably committed them. It can also crash after writing more bytes than its checkpoint describes.
For destination backup.zip, the workflow reserves four local paths:
| Path | Purpose |
|---|---|
backup.zip.davpart | Staged data, not a completed download |
backup.zip.davcheckpoint | Last committed transfer state |
backup.zip.davcheckpoint.new | Checkpoint replacement being written |
backup.zip.davlock | Persistent sentinel used for exclusive access |
The checkpoint records the resolved remote URI, strong ETag, total length, durable offset, prefix SHA-256, and whether EOF was validated. A separate checksum covers the serialized checkpoint payload.
After roughly each MiB of progress, the implementation orders its work:
- Flush staged bytes to disk.
- Write and flush the replacement checkpoint.
- Atomically replace the previous checkpoint.
On restart, it verifies the checkpoint and rehashes the entire committed prefix before making a network request.
Suppose the checkpoint commits the first MiB, but a crashed process left 1.3 MiB in the staging file. If the committed prefix verifies, the uncommitted tail is truncated and downloaded again. If the staging file is shorter than the checkpoint or its prefix hash differs, recovery stops.
This trades some disk reads and possible retransmission for a useful guarantee: the offset does not come from an unverified FileInfo.Length.
These hashes detect accidental local corruption. They are not authentication against a malicious local writer, and they cannot make an untruthful server’s reused ETag reliable. Use a trusted local directory, restrict access to checkpoints, and avoid path aliases through symlinks or reparse points.
5. A full-sized file is not necessarily a completed transfer
Reading the expected number of bytes is necessary but insufficient. A stream can fail when the downloader checks for EOF.
The implementation marks a checkpoint complete only after exact-length and successful EOF validation. It then flushes the final state and moves the staging file to the destination without replacement, in the same directory.
Only after publication does it remove the checkpoint.
That ordering makes several crash outcomes distinguishable:
| Interruption point | What a later attempt can do |
|---|---|
| Mid-body, with a valid checkpoint | Verify the prefix, discard any uncommitted tail, and request the remainder |
| Before the first checkpoint becomes durable | A crash can leave orphan data; stop and reconcile rather than infer state |
| After a complete checkpoint, before rename | Verify the completed staging file and publish without another network request |
| After rename, before cleanup or caller-observed success | The destination may already be complete; inspect it rather than overwrite or blindly restart |
The existing destination is never overwritten, including when another process creates it during the transfer.
The .davlock file intentionally remains after success. Exclusivity comes from the open OS file handle, not from interpreting the file’s mere presence as an active transfer. Do not delete the sentinel while another process might use that destination.
This design uses durable file flushes and same-directory atomic rename. It does not promise portable directory-fsync semantics or survival of every power-loss, filesystem, and hardware failure. Inconsistent artifacts are rejected rather than repaired by guesswork.
A runnable .NET downloader
Create a .NET 8 console application and add the published package:
dotnet new console -n ReliableDownload --framework net8.0
Set-Location ReliableDownload
dotnet add package WebDAVClient --version 3.2.0
Set WEBDAV_ENDPOINT to the containing HTTPS collection or URL directory, ending in /. Set WEBDAV_REMOTE_FILE to one unescaped filename, WEBDAV_OUTPUT to a new local destination, and supply credentials through WEBDAV_USERNAME and WEBDAV_PASSWORD.
For example, endpoint https://storage.example.com/files/ plus filename backup.zip addresses https://storage.example.com/files/backup.zip. Authentication here uses username/password credentials; inject a differently configured transport if your endpoint uses another supported mechanism.
For a public download endpoint, omit Credentials and the two required credential variables. Keep the identity-encoding, redirect, and response-validation settings.
Use this complete Program.cs:
using System;
using System.IO;
using System.Net;
using System.Net.Http;
using System.Threading;
using WebDAVClient;
using WebDAVClient.Helpers;
using WebDAVClient.Transfers;
try
{
string Required(string name) => Environment.GetEnvironmentVariable(name)
?? throw new ArgumentException("Set " + name + ".");
var endpoint = new Uri(Required("WEBDAV_ENDPOINT"));
if (endpoint.Scheme != Uri.UriSchemeHttps)
{
throw new ArgumentException("Use HTTPS for authenticated access.");
}
using var http = new HttpClient(new HttpClientHandler
{
Credentials = new NetworkCredential(
Required("WEBDAV_USERNAME"), Required("WEBDAV_PASSWORD")),
UseDefaultCredentials = false,
AllowAutoRedirect = false,
AutomaticDecompression = DecompressionMethods.None,
UseCookies = false
});
using var client = new DavClient(endpoint, http);
using var deadline = new CancellationTokenSource(TimeSpan.FromMinutes(10));
await client.DownloadFileAsync(
DavPath.FromSegments(Required("WEBDAV_REMOTE_FILE")),
Required("WEBDAV_OUTPUT"),
deadline.Token);
Console.WriteLine("Download complete; destination published.");
return 0;
}
catch (OperationCanceledException)
{
Console.Error.WriteLine("Cancelled. Keep matching transfer artifacts for a later attempt.");
return 130;
}
catch (WebDAVException ex)
{
Console.Error.WriteLine($"WebDAV failed: HTTP {ex.ResponseStatusCode ?? ex.GetHttpCode()}.");
Console.Error.WriteLine("Reconcile the response before deciding whether to resume.");
return 1;
}
catch (Exception ex) when (ex is InvalidDataException || ex is IOException
|| ex is HttpRequestException || ex is UnauthorizedAccessException
|| ex is ArgumentException || ex is UriFormatException)
{
Console.Error.WriteLine($"Transfer stopped: {ex.GetType().Name}. Inspect configuration and saved state.");
return 1;
}
The destination’s parent directory must already exist. The program has a ten-minute overall budget; HttpClient also retains its normal request-header timeout. The helper passes cancellation through network and disk I/O, but synchronous durable flushes and the final rename are not interruptible.
Run dotnet run. If a transient interruption occurs, fix the underlying problem and run the same program again with the same arguments. Use the same absolute local destination; changing the working directory can change what a relative output path means.
Do not point it at a plain partial file created by CopyToAsync. That file has no compatible checkpoint. Renaming it to .davpart does not establish its identity.
Choose recovery by failure type
A retry policy should not flatten every exception into “try again.”
| Failure | Appropriate response |
|---|---|
| Cancellation or interrupted body with matching valid state | Make a caller-approved later attempt using the same arguments |
| Changed ETag or total length | Decide which remote version the application needs; do not append |
| Ignored or invalid range | Investigate server/proxy behavior before another resume attempt |
| Corrupt checkpoint, short/altered prefix, or orphan artifacts | Restore a known matching pair or deliberately discard state while no operation is running |
| Destination already exists | Reconcile the destination separately; do not overwrite it automatically |
| Disk permissions or free-space failure | Fix the local cause and inspect the saved state |
Avoid dumping checkpoints into logs: their remote URI can contain private path or query information.
The helper uses a 64 KiB body buffer and 64-bit lengths/offsets. That keeps transfer-buffer memory bounded independently of file size; it does not remove the cost of hashing a large committed prefix on restart.
Resume is a contract, not a header
Range gets you part of a response. Safe restart also needs representation identity, response validation, verified local state, and a clear point at which incomplete data becomes a completed file.
WebDAVClient’s implementation makes those decisions explicit: strong validators, durable checkpoints, no-overwrite publication, and no automatic replay. It does not provide parallel/multi-range downloads, unknown-length resume, progress callbacks, or resumable uploads.
For a working starting point, use the resumable-download guide and executable example. The implementation and regression tests are also useful if you are designing your own downloader.
The tests cover interruption, altered checkpoints and prefixes, ignored ranges, EOF failures, publication conflicts, and completed-checkpoint recovery. Those are better starting points for a transfer test plan than a successful uninterrupted download.