Error handling
Zaris operations return a structured KvResult instead of throwing for expected outcomes. You inspect the result's status and metadata to decide what to do next. Exceptions are reserved for genuinely unexpected failures.
The result model
Every operation returns a KvResult, or a KvResult<T> when it produces a value. The result describes the full outcome, not just success or failure.
var result = await client.PutAsync("key", "value");
Because expected conditions — a missing key, a concurrency conflict, a locked resource — come back as status values rather than exceptions, you handle them with normal control flow instead of try/catch.
Core properties
A KvResult exposes the outcome through a few properties you check on every call.
Status reports the outcome as a KvStatus value. Common values are Success, NotFound, Conflict, InvalidInput, Unavailable, TransportError, and Locked.
result.Status
IsSuccess is a derived helper meaning the status is Success and there is no error. It is the quickest correctness gate.
if (result.IsSuccess)
{
// operation succeeded
}
Error holds the error message when the operation failed. Use it for logging and diagnostics.
result.Error
Reading a typed value
KvResult<T> adds a Value. That value is meaningful only when the operation succeeded, so check IsSuccess before you read it.
var result = await client.GetAsync<int>("key");
if (result.IsSuccess)
{
Console.WriteLine(result.Value);
}
Read Value only after confirming IsSuccess. On a failed or NotFound result the value is not populated, and using it treats a missing or errored read as if it returned real data. Gate every Value access behind a success check.
Handle outcomes by status
For anything beyond a simple success check, branch on Status. Each status calls for a different response, so map them deliberately rather than treating every non-success the same way.
| Status | When it occurs | How to handle |
|---|---|---|
NotFound | The key does not exist | Branch on your logic; often not an error |
Conflict | Optimistic concurrency failed — an IfMatch did not match, or a transaction commit lost a race | Reread and retry |
Locked | The resource is locked by another owner | Back off and retry |
InvalidInput | The request itself is wrong | Fix the code or input; do not retry |
Unavailable / TransportError | A transient infrastructure or connectivity problem | Retry with backoff |
The following snippets show the checks for the cases you handle most.
if (result.Status == KvStatus.NotFound)
{
// key does not exist
}
if (result.Status == KvStatus.Conflict)
{
// retry logic
}
if (result.Status == KvStatus.Unavailable ||
result.Status == KvStatus.TransportError)
{
// transient issue → retry
}
Mutation semantics
For writes, the result also tells you what actually changed. This lets you distinguish a create from an update and detect no-op writes.
Mutated is true when the operation actually changed stored state.
if (result.Mutated)
{
// state actually changed
}
IsUpdate is true when an existing value was overwritten, as opposed to a new value being created.
if (result.IsUpdate)
{
// existing value was overwritten
}
Together they let a single PutAsync report whether it created or updated the key.
var result = await client.PutAsync("key", "value");
if (result.IsSuccess)
{
if (result.IsUpdate)
Console.WriteLine("Updated existing value");
else
Console.WriteLine("Created new value");
}
Concurrency, TTL, and metadata
The result carries the data you need for safe follow-up operations.
Version is the entry's current version, used for optimistic concurrency. Capture it and pass it to a later IfMatch write to update the entry only if no one else changed it first.
var version = result.Version;
TimeToLive and ExpiryUtc report the remaining lifetime and absolute expiry, which are useful for monitoring and debugging.
var ttl = result.TimeToLive;
var expiry = result.ExpiryUtc;
Metadata exposes entry metadata including labels, entity, and lease information.
var metadata = result.Metadata;
Retry strategy
Retry only failures that a retry can actually fix: transient infrastructure problems and lost concurrency races. Retrying anything else wastes work or masks a real bug.
This loop retries a conditional write on Conflict and stops on any other outcome. Rereading the current version between attempts (not shown) is what lets a retry eventually succeed.
for (int i = 0; i < 3; i++)
{
var result = await client.PutAsync(
"key",
"value",
Put.WithIfMatch(version));
if (result.IsSuccess)
break;
if (result.Status != KvStatus.Conflict)
break;
await Task.Delay(50);
}
Retry on Conflict, Locked, Unavailable, and TransportError. Do not retry on InvalidInput, on logical or business errors, or on NotFound unless your logic specifically expects the key to appear.
Never retry InvalidInput. It means the request is malformed, so every retry fails the same way and only adds load. Fix the input or the code instead.
Results compared with exceptions
Expected outcomes come back as a KvResult; only unexpected failures throw. Handle both paths.
| Scenario | Behavior |
|---|---|
| Expected outcome (not found, conflict, locked) | Returns a KvResult |
| Unexpected failure | Throws an exception |
Exceptions surface for cases such as serialization failures, invalid arguments, and internal system errors. Check the result on every call, and let a try/catch cover the exceptional path.
Best practices
These habits keep error handling correct.
- Check
IsSuccessorStatuson every operation. - Use
Statusfor precise, case-by-case handling. - Retry only transient and conflict failures, with backoff.
- Use
VersionwithIfMatchfor safe conditional updates. - Use
MutatedandIsUpdateto tell what actually changed.
Next steps
- Transactions for grouped operations with conflict detection.
- Client lifecycle to manage clients and clean up resources.