Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions pydgraph/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,7 @@
from pydgraph.client_stub import DgraphClientStub, client_stub
from pydgraph.errors import (
AbortedError,
AbortReason,
ConnectionError, # noqa: A004
RetriableError,
TransactionError,
Expand Down Expand Up @@ -44,6 +45,7 @@
from pydgraph.txn import Txn

__all__ = [
"AbortReason",
"AbortedError",
"AsyncDgraphClient",
"AsyncDgraphClientStub",
Expand Down
4 changes: 2 additions & 2 deletions pydgraph/async_txn.py
Original file line number Diff line number Diff line change
Expand Up @@ -341,7 +341,7 @@ def _common_except_mutate(error: Exception) -> NoReturn:
The original error otherwise
"""
if util.is_aborted_error(error):
raise errors.AbortedError
raise errors.AbortedError(util.abort_error_message(error))

if util.is_retriable_error(error):
raise errors.RetriableError(error)
Expand Down Expand Up @@ -437,7 +437,7 @@ def _common_except_commit(error: Exception) -> NoReturn:
The original error otherwise
"""
if util.is_aborted_error(error):
raise errors.AbortedError
raise errors.AbortedError(util.abort_error_message(error))

raise error

Expand Down
56 changes: 54 additions & 2 deletions pydgraph/errors.py
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,8 @@

from __future__ import annotations

import enum

from pydgraph.meta import VERSION

__author__ = "Garvit Pahal"
Expand All @@ -13,13 +15,63 @@
__status__ = "development"


class AbortReason(enum.Enum):
"""The category of a transaction abort, as reported by the Dgraph server.

The server encodes the reason as a ``"<code>: <detail>"`` prefix on the gRPC
ABORTED status message; :func:`parse_abort_reason` maps that prefix to one of
these values.

- ``CONFLICT`` — a write-write conflict with another concurrent transaction;
retrying with a fresh transaction is the expected response.
- ``PREDICATE_MOVE`` — a predicate is being moved between groups and commits on
it are temporarily blocked; back off and retry once the move completes.
- ``STALE_STARTTS`` — the transaction's start timestamp predates the current Zero
leader (a leader change); retry with a fresh transaction.
- ``UNKNOWN`` — no reason was reported. Returned for aborts from older servers
that do not yet categorize the reason, so callers degrade gracefully.
"""

CONFLICT = "conflict"
PREDICATE_MOVE = "predicate-move"
STALE_STARTTS = "stale-startts"
UNKNOWN = "unknown"


def parse_abort_reason(message: str | None) -> AbortReason:
"""Parses the abort category from a server abort message.

The reason is the ``"<code>: <detail>"`` prefix; matching is case-insensitive and
tolerant of surrounding whitespace. A message with no recognized prefix (e.g. from
a pre-feature server) returns :attr:`AbortReason.UNKNOWN`.
"""
if not message:
return AbortReason.UNKNOWN
code = message.split(":", 1)[0].strip().lower()
for reason in (
AbortReason.CONFLICT,
AbortReason.PREDICATE_MOVE,
AbortReason.STALE_STARTTS,
):
if code == reason.value:
return reason
return AbortReason.UNKNOWN


class AbortedError(Exception):
"""Error thrown by aborted transactions."""
"""Error thrown by aborted transactions.

The parsed abort category is available as :attr:`reason`; the full server message
remains available via ``str(error)``.
"""

def __init__(
self, message: str = "Transaction has been aborted. Please retry"
self,
message: str = "Transaction has been aborted. Please retry",
reason: AbortReason | None = None,
) -> None:
super().__init__(message)
self.reason = reason if reason is not None else parse_abort_reason(message)


class RetriableError(Exception):
Expand Down
4 changes: 2 additions & 2 deletions pydgraph/txn.py
Original file line number Diff line number Diff line change
Expand Up @@ -339,7 +339,7 @@ def create_request(
@staticmethod
def _common_except_mutate(error: Exception) -> None:
if util.is_aborted_error(error):
raise errors.AbortedError
raise errors.AbortedError(util.abort_error_message(error))

if util.is_retriable_error(error):
raise errors.RetriableError(error)
Expand Down Expand Up @@ -399,7 +399,7 @@ def _common_commit(self) -> bool:
@staticmethod
def _common_except_commit(error: Exception) -> None:
if util.is_aborted_error(error):
raise errors.AbortedError
raise errors.AbortedError(util.abort_error_message(error))

raise error

Expand Down
14 changes: 14 additions & 0 deletions pydgraph/util.py
Original file line number Diff line number Diff line change
Expand Up @@ -46,6 +46,20 @@ def is_aborted_error(error: Any) -> bool:
return False


def abort_error_message(error: Any) -> str:
"""Returns the server-supplied message from a gRPC error.

Prefers ``error.details()`` (the status description, which carries the abort
reason prefix), falling back to ``str(error)``. Used to surface the categorized
abort reason on AbortedError.
"""
if hasattr(error, "details") and callable(error.details):
details = error.details()
if details:
return details
return str(error)


def is_retriable_error(error: Exception) -> bool:
"""Returns true if the error is retriable (e.g server is not ready yet)."""
msg = str(error)
Expand Down
36 changes: 36 additions & 0 deletions tests/docker-compose.multigroup.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,36 @@
# Multi-group, no-ACL cluster for the live transaction-abort-reason tests
# (tests/test_abort_reason_live.py). Two alpha groups (replicas=1) enable the
# predicate-move case; the single zero is restartable for the stale-startts case.
#
# Usage:
# DGRAPH_IMAGE_TAG=local docker compose -f tests/docker-compose.multigroup.yml up -d
# TEST_SERVER_ADDR=localhost:9180 \
# TEST_ZERO_HTTP=localhost:6180 \
# TEST_ZERO_RESTART_CMD="docker compose -f tests/docker-compose.multigroup.yml restart zero1" \
# python -m pytest tests/test_abort_reason_live.py -v
# docker compose -f tests/docker-compose.multigroup.yml down
services:
zero1:
image: dgraph/dgraph:${DGRAPH_IMAGE_TAG:-latest}
ports:
- "5180:5180"
- "6180:6180"
command: dgraph zero -o 100 --my=zero1:5180 --replicas=1 --logtostderr -v=2 --bindall

alpha1:
image: dgraph/dgraph:${DGRAPH_IMAGE_TAG:-latest}
ports:
- "8180:8180"
- "9180:9180"
command:
dgraph alpha -o 100 --my=alpha1:7180 --zero=zero1:5180 --logtostderr -v=2 --raft "idx=1;
group=1" --security "whitelist=0.0.0.0/0;"

alpha2:
image: dgraph/dgraph:${DGRAPH_IMAGE_TAG:-latest}
ports:
- "8182:8182"
- "9182:9182"
command:
dgraph alpha -o 102 --my=alpha2:7182 --zero=zero1:5180 --logtostderr -v=2 --raft "idx=2;
group=2" --security "whitelist=0.0.0.0/0;"
83 changes: 83 additions & 0 deletions tests/test_abort_reason.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,83 @@
# SPDX-FileCopyrightText: © 2017-2026 Istari Digital, Inc.
# SPDX-License-Identifier: Apache-2.0

"""Unit tests for surfacing the transaction-abort reason on AbortedError.

The Dgraph server encodes the abort category as a ``"<code>: <detail>"`` prefix on the
gRPC ABORTED status message. These tests verify that the prefix is parsed into an
``AbortReason``, that the full message is preserved, and that aborts from a server which
reports no reason degrade gracefully to ``UNKNOWN``.
"""

from __future__ import annotations

import unittest

import pydgraph
from pydgraph import errors


class TestAbortReason(unittest.TestCase):
# --- The three server-reported categories ---

def test_conflict_reason(self) -> None:
err = errors.AbortedError("conflict: Transaction has been aborted. Please retry")
assert err.reason == pydgraph.AbortReason.CONFLICT

def test_predicate_move_reason(self) -> None:
err = errors.AbortedError(
"predicate-move: Commits on predicate name are blocked due to predicate move"
)
assert err.reason == pydgraph.AbortReason.PREDICATE_MOVE

def test_stale_startts_reason(self) -> None:
err = errors.AbortedError(
"stale-startts: Transaction has been aborted due to a leader change. Please retry"
)
assert err.reason == pydgraph.AbortReason.STALE_STARTTS

# --- Full message preserved alongside the parsed reason ---

def test_full_message_preserved(self) -> None:
desc = "conflict: Transaction has been aborted. Please retry"
err = errors.AbortedError(desc)
assert str(err) == desc

# --- Graceful degradation against an older server (no reason prefix) ---

def test_legacy_message_degrades_to_unknown(self) -> None:
# The default message (what older servers emit) has no category prefix.
err = errors.AbortedError()
assert err.reason == pydgraph.AbortReason.UNKNOWN

def test_unrecognized_prefix_degrades_to_unknown(self) -> None:
err = errors.AbortedError("something-else: not a known category")
assert err.reason == pydgraph.AbortReason.UNKNOWN

# --- Parsing robustness ---

def test_reason_is_case_insensitive_and_trimmed(self) -> None:
assert errors.AbortedError("CONFLICT: x").reason == pydgraph.AbortReason.CONFLICT
assert (
errors.AbortedError(" predicate-move : y").reason
== pydgraph.AbortReason.PREDICATE_MOVE
)

def test_reason_without_detail_still_parses(self) -> None:
assert errors.AbortedError("conflict").reason == pydgraph.AbortReason.CONFLICT

def test_parse_abort_reason_none_is_unknown(self) -> None:
assert errors.parse_abort_reason(None) == pydgraph.AbortReason.UNKNOWN
assert errors.parse_abort_reason("") == pydgraph.AbortReason.UNKNOWN

# --- Explicit reason overrides parsing ---

def test_explicit_reason_overrides_message(self) -> None:
err = errors.AbortedError(
"opaque message", reason=pydgraph.AbortReason.STALE_STARTTS
)
assert err.reason == pydgraph.AbortReason.STALE_STARTTS


if __name__ == "__main__":
unittest.main()
Loading
Loading