Error Handling¶
Exception Hierarchy¶
All Aerospike errors extend AerospikeError:
AerospikeError
├── TimeoutError
├── ConnectionError
├── SerializationError
├── SecurityError
│ ├── AuthenticationError
│ └── AuthorizationError
├── GenerationError
├── InvalidNamespaceError
├── InvalidNodeError
├── BackoffError
│ └── MaxErrorRateError
├── QuotaError
├── QueryError
│ └── QueryTerminatedError
├── UdfError
├── BatchError
├── RecordNotFoundError
├── RecordExistsError
├── RecordTooBigError
├── FilteredOutError
├── BinError
│ ├── BinExistsError
│ ├── BinNotFoundError
│ ├── BinTypeError
│ └── BinOpInvalidError
├── ElementError
│ ├── ElementNotFoundError
│ └── ElementExistsError
├── CapacityError
│ └── KeyBusyError
├── SecondaryIndexError
│ ├── IndexNotFoundError
│ └── IndexAlreadyExistsError
└── TransactionError
└── CommitError
Catch a base class to handle a whole family at once (for example except BinError
covers BinExistsError, BinNotFoundError, BinTypeError, and
BinOpInvalidError), or a leaf class for a specific outcome. The
secondary-index base is named SecondaryIndexError rather than IndexError so
it does not shadow Python’s built-in IndexError.
Import from the top-level package:
from aerospike_sdk import AerospikeError, RecordExistsError, RecordNotFoundError
Default Behavior¶
The default error handling depends on the operation type:
Operation |
Default |
Behavior |
|---|---|---|
Single-key (1 key) |
Raise |
Exception raised immediately |
Multi-key (batch) |
In-stream |
Errors embedded in |
Set query / scan |
In-stream |
Errors embedded in |
Checking Results¶
stream = await session.query(*users.ids(1, 2, 3)).execute()
async for result in stream:
if result.is_ok:
print(result.record.bins)
else:
print(f"Key failed: {result.to_exception()}")
Branch on is_ok: it checks both result_code and exception. A row that
failed client-side — before the server answered — carries a negative client
code such as ResultCode.CLIENT_ERROR or ResultCode.SERVER_NOT_AVAILABLE
(a client-side deadline reports ResultCode.TIMEOUT, the same code a server
timeout uses) alongside the exception, which holds the detail.
to_exception() returns the failure as the typed exception or_raise() would
raise — the row’s exception when the client attached one, otherwise the
subclass for its result_code — or None for a row that succeeded. Use it to
log or collect failures without raising.
Or raise on any failure:
async for result in stream:
record = result.record_or_raise() # raises AerospikeError on failure
Result Codes and Messages¶
result_code is a ResultCode constant that behaves like an IntEnum member:
repr shows the name and the numeric wire value, while str and int give the
number alone, so an f-string prints the same code 5 that server logs and the
result-code reference use. name and value expose the two halves.
try:
await session.insert(key).bin("name").set_to("Alice").execute()
except RecordExistsError as exc:
print(f"{type(exc).__name__} (code {exc.result_code}): {exc.base_message}")
# RecordExistsError (code 5): Key already exists
exc.result_code.name # 'KEY_EXISTS_ERROR'
exc.result_code.value # 5
base_message is the failure in plain words – the result code’s descriptive
string, or the server’s own message when it sent one (an index create that
collides with a different definition reports that collision). str(exc) is the
full message: the numeric code, then any retry decoration (iter=, In Doubt:,
node=), then the base message.
A failure the client raises without a server answer carries a code too. Those codes are negative, and they are the same numbers every Aerospike client reports for the same condition, so a log line or a runbook reads the same across languages. The exception type stays the thing to catch; the code is what tells two failures of the same type apart:
try:
await session.upsert(key).bin("n").set_to(1).execute()
except TimeoutError as exc:
if exc.result_code == ResultCode.MAX_RETRIES_EXCEEDED:
... # every allowed attempt failed (just one, with retries off)
else:
... # ResultCode.TIMEOUT: the total deadline fired, or the server said so
except ConnectionError as exc:
exc.result_code # ResultCode.SERVER_NOT_AVAILABLE
Extended Server Error Detail¶
For failures the result code alone does not fully explain, the server can attach
a subcode and a message to the response. This is opt-in per operation via
error_detail_verbosity on a Behavior, so the default path pays no extra cost.
Verbosity |
Attaches |
|---|---|
|
nothing |
|
numeric |
|
|
|
the above + an |
When requested, the detail is surfaced on the raised AerospikeError (and every
subclass) as sub_code, server_message, and exp_trace; each is None when
the server did not supply it.
from aerospike_sdk import Behavior, ErrorDetailVerbosity, SubCode
from aerospike_sdk.policy.behavior_settings import Scope, Settings
from aerospike_sdk.exceptions import AerospikeError
behavior = Behavior(
"verbose-errors",
{Scope.ALL: Settings(error_detail_verbosity=ErrorDetailVerbosity.MESSAGE)},
)
session = client.create_session(behavior=behavior)
try:
stream = await session.query(users.id(1)).bin("scores").on_list_index(99).get_values().execute()
await stream.first_or_raise()
except AerospikeError as err:
if err.sub_code == SubCode.OPNOT_CDT_INDEX_OUT_OF_BOUNDS:
... # refresh the cached list size and retry
print(err.result_code, err.sub_code, err.server_message)
A subcode value is scoped to its parent result code — it is not globally
unique, so always interpret the (result_code, sub_code) pair together. The
SubCode catalog enumerates the known subcodes.
Batches report failures per record rather than raising, so the same detail
travels as data instead: a failed batch row carries the full surface on
RecordResult — sub_code, server_message, and exp_trace (each None
for successful rows or when detail was not requested) — and
RecordResult.or_raise() attaches all three to the raised error, matching
the single-key exception shape.
Client-Side Guidance (hint)¶
Some result codes name a condition without saying what caused it. The server
reports BinNameTooLong for a bin name over the limit, for instance, but does not
say which bin. For codes whose cause is a common, recognizable
misconfiguration, the SDK adds local guidance: it is appended to the exception
message and also kept on its own attribute.
try:
await session.upsert(customers.id(1)).put({"a_very_long_bin_name": 1}).execute()
except AerospikeError as err:
print(err) # server message, then the guidance on the next line
print(err.hint) # just the guidance, or None when there is nothing to add
When the SDK still holds the operation that failed, the guidance names the specific cause rather than the general one:
Error 21, iter=1, node=10.0.0.4:3000: Bin name length greater than 15 characters or maximum bins exceeded
Bin name 'a_very_long_bin_name' exceeds the server's 15-character limit.
hint is generated locally and never travels the wire — that is the difference
from server_message, which is the server’s own text. Most codes carry no hint:
guidance that only restates the code name would be noise.
ParameterError is the exception. The server validates arguments — string
write flags, expression text, CDT bounds — and holds the reason it rejected one,
but sends it only when asked (see Extended Server Error Detail above).
A ParameterError that arrives with neither sub_code nor server_message
therefore carries a hint naming error_detail_verbosity, so the first time the
mistake is hit the message says how to see the server’s explanation. Once detail
is requested the hint is gone and the server’s sentence stands alone.
Precedence¶
Guidance resolves narrowest-first:
What the SDK knows about the operation — the bin-naming case above.
The
(result_code, sub_code)pair, when extended error detail is enabled.The result code alone, as a fallback.
Step 2 matters because several codes cover more than one condition, and the
server already distinguishes them. FailForbidden alone could be a durability
violation, a set-level stop-writes limit, cluster clock skew, an in-progress
truncate, XDR filtering, or conflict resolution; BinNameTooLong is returned
both for an over-long name and for a record with too many bins. Without a
subcode the SDK says so rather than picking one — naming a single cause would
misdirect whenever it is not that one. With a subcode it names the condition
exactly:
Error 22, SubCode: 8, iter=1, node=10.0.0.4:3000: Operation not allowed at this time
A non-durable delete was refused because it would violate durability.
Strong-consistency namespaces require durable deletes: enable durable delete on
the operation, or on the Behavior the session carries.
Subcode integers are scoped to their parent code, not globally unique, so the lookup keys on the pair. An unrecognized subcode — a newer server than this SDK — falls back to the per-code text rather than losing the guidance.
Keeping the guidance on its own attribute means it can be logged or suppressed independently of the message — useful when error text is parsed or forwarded.
In-Doubt Writes¶
Every AerospikeError carries an in_doubt flag. It is True when a write
reached the server but its outcome is unknown — the record may or may not have
been applied. Typical producers are a client-side timeout after the request was
sent, a connection that dropped mid-flight, and retry exhaustion on a write.
Failures where the request never left the client (validation errors, timeouts
before send) and read operations are never in doubt.
from aerospike_sdk import TimeoutError
try:
await session.insert(orders.id(order_id)).put(payload).execute()
except TimeoutError as err:
if err.in_doubt:
... # verify with a read before retrying a non-idempotent write
else:
... # the write did not happen; safe to retry as-is
For batch operations with in-stream errors, the per-key flag is
RecordResult.in_doubt. This holds even when the batch fails as a whole:
each row reports its own outcome — rows the server answered keep their
result, and unanswered rows carry the failure’s stamped code, so a batch
write that client-times-out after reaching the wire yields one row per key
with result_code=TIMEOUT and in_doubt=True. For transactions,
CommitError.in_doubt reports whether the commit itself may have landed
(see Transactions).
The direct point-operation shortcuts — session.get, session.put,
session.get_many, and session.put_many — raise the same SDK exception
types as every other path: a failure that propagates out of them is
converted at the boundary, so except AerospikeError (or a typed subclass
like TimeoutError) works uniformly, in_doubt included. The _many
variants also deliver per-key result slots: an exception instance
returned in the result list (not raised) is the same SDK type the
single-key call would have raised, in_doubt included, so
isinstance(slot, RecordNotFoundError) and slot.result_code work on a
slot exactly as they do in an except clause. Failure-free windows return
without a conversion pass.
Client vs Server Timeouts¶
TimeoutError.client tells you which side gave up. True means the
client’s own deadline fired (socket or total timeout expired locally) —
the server may still be working, so pair it with in_doubt before
retrying a write. False means the server itself reported the timeout
result code, so the operation was accounted for on the server side.
try:
await session.put(orders.id(order_id), payload)
except TimeoutError as err:
if err.client and err.in_doubt:
... # local deadline on a sent write; read-verify before retrying
elif not err.client:
... # the server timed it out; look at server load, not the network
Retry and Diagnostic Context¶
Every AerospikeError also answers where and how many times the
command failed:
node— the cluster node the failing attempt targeted, when the retry loop recorded one (Nonefor failures that never reached node selection).iteration— the number of attempts made before the command failed.sub_exceptions— the errors of prior retry attempts, oldest first, each converted into this hierarchy (empty when the command was not retried).base_message— the failure message without the retry-context decoration the full message carries.
try:
await session.put(orders.id(order_id), payload)
except AerospikeError as err:
log.error(
"write failed on %s after %s attempts: %s",
err.node, err.iteration, err.base_message,
)
for attempt in err.sub_exceptions:
log.debug("prior attempt: %s", attempt)
Design notes¶
A few adjacent capabilities are intentionally not part of the exception surface; they are recorded here so the choices are visible:
Resolved policy values (
connect_timeout,socket_timeout,total_timeout,max_retries) are not echoed onto exceptions. Since namespace-mode-aware policy selection happens inside the underlying client, no SDK-level layer reliably knows which resolved policy the failing command actually used — an echoed value that can be wrong is worse than none. Reconstruct the resolved values from yourBehavior(behavior.get_settings(...)) when needed.Connection-recycling hints: the underlying client decides internally whether a failed command’s connection is reusable; the signal is not actionable from Python and is not exposed.
Message format: the human-readable message stays prose (with
base_messageas the undecorated form) rather than encoding fields in a positional prefix — the structured attributes carry every field the message would otherwise need to encode.
ErrorStrategy¶
Override the default with on_error at execute time:
from aerospike_sdk import ErrorStrategy
# Force in-stream errors for single-key operations
stream = await (
session.query(users.id(1))
.execute(on_error=ErrorStrategy.IN_STREAM)
)
# result.is_ok will be False instead of raising
ErrorHandler (Callback)¶
Route errors to a custom callback:
from aerospike_sdk import ErrorHandler
def my_handler(result):
log.warning(f"Record error: {result.result_code} for {result.record.key}")
stream = await (
session.query(*users.ids(1, 2, 3))
.execute(on_error=ErrorHandler(my_handler))
)
# Errors go to my_handler; only successes appear in the stream
Common Patterns¶
Optimistic Locking¶
try:
await (
session.update(users.id(1))
.ensure_generation_is(expected_gen)
.bin("balance").set_to(new_balance)
.execute()
)
except GenerationError:
# Record was modified since we read it — retry
pass
Create-Only Insert¶
from aerospike_sdk import RecordExistsError
try:
await session.insert(users.id(1)).put({"name": "Ada"}).execute()
except RecordExistsError:
# Key already taken — fall back to an update or report a conflict
pass
Conditional Write with Filter¶
from aerospike_sdk import FilteredOutError
stream = await (
session.update(users.id(1))
.where("$.balance >= 100")
.fail_on_filtered_out()
.bin("balance").add(-100)
.execute()
)
try:
result = await stream.first_or_raise()
except FilteredOutError:
print("Insufficient balance — filter matched no record")