Guides

Errors in practice

How to catch ARX's errors, what each kind means for your application, when to retry, and what a first connection's errors say.

Catch on the base class, by code

Every error an ARX call throws is an ArxException with a stable Code: the same number and name in every provider. (A misuse found while the schema catalog is built at startup, such as a table declared twice, is an ArgumentException, as .NET builders report one.) Catch the base class and filter by code:

try
{
    await service.UpdateAsync(change);
}
catch (ArxException e) when (e.Code == ArxErrorCode.RecordNotFound)
{
    // the record isn't there
}

Some codes come as a subclass (ArxValidationException when ARX refused the call before the database, ArxDatabaseException when the database refused it, ArxNotSupportedException for ARX8001), but not every one: a record not found (ARX2002) can come as the base class. A catch of a subclass can miss an error, so filter on the code.

The thousands digit is the Category: 1 validation, 2 not found, 3 constraint, 4 data, 5 schema, 6 permission, 7 transient, 8 not supported, 9 other. The message starts with the code (ARX3001 UniqueViolation: …), names the table and column where it can, and is written not to repeat the values you sent, so it is safe to log. The database's own error stays on the exception: its native code (SqlState or NativeCode) and the driver's exception as InnerException.

That holds for Message, not for InnerException: the driver's own message can quote values. MySQL's and MongoDB's do for a duplicate key (Duplicate entry 'A-100', dup key: { account_number: "A-100" }); PostgreSQL's hides them by default. So log exception.Message and exception.Code, not exception.ToString(), when values may be personal data.

The Error codes page of each provider lists every code with the database errors it comes from.

Which table and column

EntityName and AttributeName are filled when ARX knows them. That covers everything ARX checks itself: names, value types against a catalog that declares the columns, GetAttributeValue<T>, and an undeclared table. It also covers a column the database says is missing (ARX5002); when only a link names it, the error names the link's table and alias. An error the database raises names what the database says:

  • A unique violation (ARX3001) carries ConstraintName (customer_email_key), not the column. To show "email already in use" next to a form field, map your constraint names to fields.
  • A value too long (ARX4002), or of the wrong type (ARX4004) for a column the catalog doesn't type: PostgreSQL doesn't say which column, so AttributeName is empty. With the schema preloaded with DataTypes, or the columns declared, ARX checks a value's type before the database and names the column. A value's length is still the database's check.

A Multiple call that fails

CreateMultipleAsync, UpdateMultipleAsync and DeleteMultipleAsync are all or nothing. A database error doesn't say which entity caused it, because the database checks the statement as a whole. When you need to know, for an import report say, send the entities one call each in a batch scope with savepoints: the failed call's error has its BatchNumber, and the others still commit.

await service.RunBatchAsync(async scope =>
{
    foreach (var entity in entities)
    {
        try
        {
            await scope.CreateAsync(entity);
        }
        catch (ArxException error) when (error.Category is ArxErrorCategory.Constraint or ArxErrorCategory.Data)
        {
            report.Add((error.BatchNumber, error.Code));   // the entity at that position wasn't written
        }
    }
}, new ArxBatchOptions { Savepoints = true });

Savepoints are on PostgreSQL, SQL Server and MySQL. MongoDB and DynamoDB refuse them (ARX8001): send one call per entity outside a scope there.

When the record isn't there

Call Result
RetrieveAsync null
RetrieveMultipleAsync an empty collection
UpdateAsync ARX2002 RecordNotFound; nothing is created
UpdateMultipleAsync ARX2002 if any entity matches nothing; nothing changes
DeleteAsync(table, id) false
DeleteAsync(entity) 0; with RequireSingleRow = true, ARX2002
DeleteMultipleAsync how many existed and went
An update or delete whose entity carries an old version (a table with .Version(…)) ARX2004 RecordChanged: the record is there, at another version; nothing is written

A delete that finds nothing isn't an error: deleting something already gone (a retry, two users) is the same end state. When your application needs the record to have been there, use RequireSingleRow. An update that finds nothing is: it would otherwise lose the change without a word.

When someone else changed the record

Declare a version column on a table (.Version("account", "row_version"), an integer column) and ARX keeps it: a create writes 1, every update adds one. Read the record, change it, and update it with the version it was read with; if another writer got there first, the update is ARX2004 and writes nothing:

try
{
    account["name"] = "Northwind Traders Ltd";   // account was read with its row_version
    await service.UpdateAsync(account);
}
catch (ArxException error) when (error.Code == ArxErrorCode.RecordChanged)
{
    // Read it again, apply the change to what is there now (or ask the user), and update again.
}

An update without the version isn't checked (the last writer wins) but still moves the version. .Version("account", "row_version", required: true) refuses such an update (ARX1001). Every provider from 0.1.0-preview.4; DynamoDB only in earlier versions.

In a web API

A starting point for turning codes into HTTP statuses. Map by code, not by category: the NotFound category (2xxx) also holds ARX2003 and ARX2004, which are conflicts, not missing records.

Codes Meaning for the caller Status
ARX2002 RecordNotFound not there 404
ARX2003 MultipleRecordsMatched the key you matched by isn't unique 409
ARX2004 RecordChanged changed since it was read: read again 409 (or 412 with If-Match)
ARX3001 UniqueViolation, ARX3002 ForeignKeyViolation conflicts with other data 409
ARX3003, ARX3004, 4xxx a value the table refuses 400 or 422
ARX1005 InvalidLookup, ARX1007 UnsupportedValueType, ARX1011 SelectionSetValueNotAllowed, ARX1013 InvalidDateTimeValue a value refused before the database: an option the set doesn't allow, an empty date, a reference to a record that can't exist 400 or 422 when the value came from the request
ARX1001 InvalidArgument a paging value below 1, or another argument out of range 400 when it came from the request (page size, page number)
ARX1017 SuspectedInjection a name carrying SQL or query syntax: log it 400
other 1xxx, 5xxx, 6xxx, 8001, 9001 a fault in the application or its setup: log it 500
7xxx (IsTransient is true) try again later 503

The other 1xxx codes (an undeclared table, an invalid name your code built, a connection string missing) are the application's own mistakes rather than the caller's, so treat them as 500 unless the name came from the request. The same goes for the value codes above when your own code built the value: then it is a bug, not bad input.

Retrying

Retry only when IsTransient is true: a connection that failed, a deadlock or serialization failure, throttling, a timeout. Retry the whole unit of work (the transaction or batch scope, from its start), with a backoff.

ARX doesn't retry your calls or your transactions. Two things below it do: the drivers (the AWS SDK retries throttled DynamoDB requests, and the MongoDB driver retries a write once after a network error), and, on MongoDB only, the transaction ARX opens itself for a call that writes several documents outside your transaction: ARX runs that transaction once more when MongoDB labels the failure TransientTransactionError, and commits it once more on UnknownTransactionCommitResult, as MongoDB recommends (the whole transaction, so nothing is written twice). A transaction of yours (BeginTransactionAsync) is never retried. So an ARX7xxx error has usually been retried already.

A failure after the request was sent can leave you not knowing whether it was written (a timeout, a dropped connection). With supplied ids a retried create says so: ARX3001 for a record the first attempt wrote. With generated ids it can't, so look the record up by a business key before writing it again. On MongoDB, a write outside a transaction whose connection fails or times out after it was sent, or whose write concern fails, is ARX7005 (ARX7003 for a server time limit) with IsTransient false and a message that says its outcome is unknown (from 0.1.0-preview.5; it was transient): the server may still apply it, so a retry policy that follows IsTransient doesn't apply it twice. A failure before anything was sent (no server reachable, a full pool) stays transient.

A first connection

What a setup mistake looks like:

Mistake Error
Wrong user or password ARX6001 PermissionDenied (the database's sign-in failure is the InnerException)
Wrong host or port, server not running ARX7005 ConnectionFailure (transient)
No such database ARX7005 ConnectionFailure on every SQL provider (PostgreSQL 3D000, SQL Server 4060, MySQL 1049), with IsTransient false: a retry won't make the database appear. Check Database= in the connection string. On MySQL a user without rights on every database gets ARX6001 PermissionDenied instead (MySQL error 1044, "access denied to database"): MySQL doesn't tell such a user whether the database exists, so check Database= as well as the grants
No such table ARX5001 UndefinedEntity (from the database, on a read or a call without a catalog)
A table not declared in the schema catalog ARX1016 IdNotDeclared, naming the line to add, and "Did you mean …?" when a declared name is close. A misspelt table name is the usual cause: the same typo is ARX1016 on a write with a catalog (refused before the database) and ARX5001 on a read (the database's answer)
A service without a catalog, on a table with no primary key (or a compound one) ARX1019 TableNotResolvable, saying why and how to declare it

Call ValidateSchemaAsync() once at startup: it connects and checks every declared table and id column against the database, and says what doesn't match.