Errors & Events
The stable
storage.*error codes every provider maps to, helpers for deciding whether to retry, and the events the library publishes for monitoring.
Error codes
Expected failures come back as a failed Result with a stable code. Caller cancellation propagates as
OperationCanceledException from connection calls (uploads, downloads, listings, and
IStorageService.CopyAsync/MoveAsync) and from sync planning. It is reported instead where a report
says what was left: StorageLibrary.CopyAsync/MoveAsync (Outcome = Cancelled), the transfer queue's
Result methods, and a sync cancelled while applying (a successful result with report.Cancelled = true).
Every provider maps its own failures to the same codes:
| Code | Meaning | Typical sources |
|---|---|---|
storage.not_found |
the item does not exist | FTP 550, SFTP no-such-file, HTTP 404/410 |
storage.conflict |
the destination exists, or a condition failed | ConflictPolicy.Fail, ETag mismatch, HTTP 409/412/423 |
storage.invalid_path |
the path escapes the mount or is malformed | .., a local path outside the root, FTP 501/553 |
storage.invalid_content |
an argument or option is invalid | bad octal mode, bad command text, invalid connection settings (AddOrUpdateConnectionAsync, TestConnectionAsync) |
storage.too_large |
a bounded read or upload exceeded its limit | DownloadBytesAsync limits, HTTP 413, a sync tree over MaxItems |
storage.unsupported |
the connection cannot do this | missing capability, server lacks a command |
storage.authentication_failed |
the credentials were rejected | FTP 530, SSH auth, HTTP 401 |
storage.permission_denied |
signed in, but not allowed | FTP 550/553 permission, SFTP, HTTP 403, local ACL |
storage.tls_failure |
TLS handshake or certificate pin failed | untrusted or unpinned certificate, FTP 534/535 |
storage.host_key_rejected |
the SSH host key is not trusted | no matching fingerprint or known_hosts entry |
storage.connection_failed |
could not connect | DNS failure, refused, unreachable, proxy failure |
storage.connection_lost |
the connection dropped mid-operation | FTP 421/425/426, reset sockets |
storage.server_busy |
rate limited or out of sessions | HTTP 429/503, FTP 421, session pool full |
storage.quota_exceeded |
out of space or quota | FTP 452/552, HTTP 507, disk full |
storage.timeout |
the operation timed out | read/connect timeouts, HTTP 408/504; a queue pause, cancel, or remove that stopped waiting after ControlTimeout (the request still applies) |
storage.unavailable |
the service is unavailable | provider outages, other HTTP 5xx, FTP 450 and other 4xx replies; a disposed transfer queue; a job whose store failed 8 times in a row |
storage.partial_failure |
a multi-step operation stopped halfway | restore or move-source deletion failed, a WebDAV 207 Multi-Status |
storage.cancelled |
the caller cancelled, reported where a report says what was left | a cancelled CopyAsync/MoveAsync (Outcome = Cancelled) |
storage.provider_error |
anything not classified above | carries the exception type; never the message, except an S3 SDK refusal (AmazonClientException), whose reason is kept with URL queries and credential-like values redacted |
storage.unauthorized |
legacy code, not raised by the built-in providers | StorageErrors.Create still maps it |
Classify errors by Code (or StorageErrorInfo.IsTransient), not by the Core error kind:
storage.cancelled is never transient, although it is built as an availability error. The codes are
constants on StorageErrors (NotFoundCode, ConflictCode, CancelledCode, …), and
StorageErrorInfo.IsConnectionFault says whether an error (connection_lost, tls_failure) means the
connection itself should be replaced.
StorageErrorInfo (namespace CL.Storage.Errors) helps act on them:
if (StorageErrorInfo.IsTransient(result.Error)) // timeout, unavailable, connection_*, server_busy
{
if (StorageErrorInfo.TryGetRetryAfter(result.Error, out var wait))
await Task.Delay(wait);
}
if (StorageErrorInfo.TryGetDetail(result.Error, StorageErrorInfo.FtpReplyKey, out var reply))
Console.WriteLine($"FTP server replied {reply}"); // also SftpStatusKey, HttpStatusKey
A failure raised after the destination was already written carries destinationState=complete
(StorageErrorInfo.DestinationCommitted(error)), with a leftBehind=<path> entry for each object the
provider could not remove, such as its own backup; treat the destination as committed and clean up, never
retry blindly. This applies to staged uploads and StorageWriteStream.CommitAsync too: a
storage.partial_failure with destinationState=complete means the content was written and the
leftBehind entries name what is still there. A WebDAV 207 on a COPY or MOVE carries
destinationState=partial (on the locked delete of an empty folder only httpStatus=207). The detail keys
are constants on StorageErrorInfo: DestinationStateKey, LeftBehindKey, RetryAfterKey,
FtpReplyKey, SftpStatusKey, HttpStatusKey, TlsReasonKey, PresentedCertificateKey,
PresentedPublicKeyKey, and PresentedFingerprintKey.
A rejected SSH host key carries presentedFingerprint in Details. To rebuild an error stored as its
code, message, and details (for example from a job store), use StorageErrors.Create. Provider response bodies,
credentials, and signed query strings are never included. FTP, SFTP, and WebDAV already retry
transient failures themselves for reads, listings, info, directory creation, and uploads from seekable
streams (deletes and moves only with RetryNonIdempotent; see
Connections), so such an error you receive has already
been retried. A retryAfterMs detail (TryGetRetryAfter) comes from servers that send Retry-After
(Azure, Swift).
TLS failures
storage.tls_failure from the platform's TLS stack carries a tlsReason detail
(StorageErrorInfo.TlsReasonKey) that says what to do (an FTP server's own 534/535 reply carries only
ftpReply):
tlsReason |
Meaning | Extra details |
|---|---|---|
server_certificate_rejected |
the server's certificate is not trusted or not pinned | presentedCertificateSha256 and presentedPublicKeySha256, ready to pin |
client_certificate_rejected |
the server refused the client certificate: a credential problem | — |
protocol_mismatch |
no TLS version or cipher in common | — |
handshake_failed |
anything else during the handshake | — |
connection_interrupted |
a hint on storage.connection_lost (transient): the TLS stream failed after the handshake (OpenSSL unexpected EOF or bad record MAC, SChannel decrypt failure) |
— |
The reason comes from the platform's own TLS stack: SChannel status codes on Windows (matched by code,
since Windows localizes the messages) and OpenSSL alerts on Linux. Only failures of the handshake are
TLS failures: an error raised while it ran, or an alert about it (the client certificate, the protocol, or
the server's certificate), which TLS 1.3 sends after the handshake completed. Any other stream that breaks
later is storage.connection_lost, which is transient.
A TLS 1.3 server refuses a client certificate only after the handshake, which the platform reports as a
dropped connection. On WebDAV, which watches its TLS connections for this, it becomes
client_certificate_rejected only when a connection that was asked for a
certificate (the server sent acceptable issuers or its certificate request) failed after our reply with
evidence of a refusal (an alert's worth of bytes, or a request sent on it that got no answer), and only
for the attempt that sent its request on that connection. A spare connection the pool opened and later
closed without using, or a refusal on a connection another transfer used, never turns this attempt's drop
into a refusal. A dropped connection on a server that merely asks for a certificate (optional client
certificates) stays connection_lost, with tlsReason=client_certificate_rejected as a hint. Behind an
HTTP proxy tunnel the TLS stream does not sit on the watched connection, so a refusal is never detected
there: it stays connection_lost, with at most that hint. Other providers do not watch their
connections, so such a drop stays connection_lost there. FTP and WebDAV add the presented certificate;
S3, Azure, Google Cloud, and Swift report the reason only.
if (StorageErrorInfo.TryGetDetail(result.Error, StorageErrorInfo.TlsReasonKey, out var reason)
&& reason == "server_certificate_rejected"
&& StorageErrorInfo.TryGetDetail(result.Error, StorageErrorInfo.PresentedPublicKeyKey, out var key))
{
// Ask the user, then pin: settings.TrustedPublicKeySha256 = [key];
}
Events
All events go to the CodeLogic event bus (the event types live in CL.Storage.Events). Publishing never
fails the storage operation: a handler that throws is logged and ignored. Item events (written, deleted,
copied, moved, folder transfers) are awaited before the call returns, so a slow handler delays it;
connection events are published without waiting.
| Event | Published when |
|---|---|
StorageItemWrittenEvent |
an upload or append committed |
StorageItemDeletedEvent |
an item was deleted |
StorageItemCopiedEvent / StorageItemMovedEvent |
a copy or move within one connection completed |
StorageCrossConnectionCopyCompletedEvent / StorageCrossConnectionMoveCompletedEvent |
a transfer between connections completed (with counts); a move that committed but kept its source is announced as a copy |
StorageDirectoryUploadedEvent / StorageDirectoryDownloadedEvent |
a local folder transfer completed |
StorageConnectionOpenedEvent |
an FTP or SFTP session opened and authenticated |
StorageConnectionLostEvent |
a session dropped, timed out, or failed TLS and was retired |
StorageConnectionRetryEvent |
a transient failure is being retried (attempt, delay, code) |
StorageConnectionHealthChangedEvent |
a health check found a connection in a new state (and on its first check) |
StorageOperationFailedEvent |
a service operation failed (operation, path, code); not copies and moves, whose report carries the error |
StorageTransferStartedEvent / StorageTransferCompletedEvent / StorageTransferFailedEvent / StorageTransferCancelledEvent |
a transfer-queue job started an attempt or finished (with its kind, source, destination, attempts, and error code) |
StorageTransferRetryingEvent |
a queue job failed transiently and will run again after a delay |
StorageTransferBlockedEvent |
a queue job needs a person: an untrusted identity or rejected credentials (Reason) |
StorageTransferNeedsReconciliationEvent |
a queue job left a mixed state that needs checking |
StorageTransferInterruptedEvent |
a queue job found running after a restart may have touched its destination (with the phase it reached) |
events.Subscribe<StorageConnectionHealthChangedEvent>(e =>
logger.Warning($"{e.ConnectionId} is now {(e.Healthy ? "healthy" : $"unhealthy ({e.ErrorCode})")}"));
events.Subscribe<StorageOperationFailedEvent>(e =>
{
if (e.ErrorCode != StorageErrors.NotFoundCode)
logger.Warning($"{e.Operation} {e.Path} on {e.ConnectionId} failed: {e.ErrorCode}");
});
StorageOperationFailedEvent includes expected failures such as storage.not_found, so filter on
ErrorCode. Input rejected before reaching the provider (invalid paths or options) is not reported.
Each retry and each retired session is also logged as a warning, and so is each failed operation
(code, message and details) except the routine not_found, conflict, cancelled and invalid_path;
the same connection, operation and code is logged at most once a minute. S3 service errors carry the
request id in Details (requestId=…) for support cases.