Skip to content

Errors

Every non-success response from DHIS2 raises a typed exception. The hierarchy is rooted at Dhis2ClientError and branches into four leaves: Dhis2ApiError (any HTTP non-success), AuthenticationError (401 / 403 specifically, on top of Dhis2ApiError), OAuth2FlowError (raised by the OAuth2 PKCE flow when token exchange fails), UnsupportedVersionError (raised on connect() when the live DHIS2 has no generated module).

Worked example — branch on Dhis2ApiError

from dhis2w_client import Dhis2ApiError
from dhis2w_core.client_context import open_client
from dhis2w_core.profile import profile_from_env

async with open_client(profile_from_env()) as client:
    try:
        # `delete_bulk` takes (resource_type, uids).
        await client.metadata.delete_bulk("dataElements", ["doesNotExist"])
    except Dhis2ApiError as exc:
        print(f"HTTP {exc.status_code}: {exc.message}")
        # `web_message()` materialises the typed envelope when DHIS2 returned one;
        # returns None on errors whose body isn't a WebMessage (network 500s, etc.).
        wm = exc.web_message()
        if wm is not None and wm.response is not None:
            for c in wm.response.conflicts or []:
                print(f"  conflict: {c.object} -> {c.value}")

Worked example — narrow except for auth failures

from dhis2w_client import AuthenticationError, Dhis2ApiError

try:
    await client.system.me()
except AuthenticationError as exc:
    # Bad PAT / expired / wrong username/password. Distinct from
    # other 4xx failures so callers can prompt for re-auth specifically.
    print(f"re-auth needed: {exc.message}")
except Dhis2ApiError as exc:
    # Any other DHIS2-side error (404, 409, 500, ...).
    print(f"unexpected: {exc.status_code} {exc.message}")

Worked example — UnsupportedVersionError

from dhis2w_client import Dhis2Client, BasicAuth
from dhis2w_client.errors import UnsupportedVersionError

# `allow_version_fallback=False` (the default) fails fast when the live
# DHIS2 is on a version without a committed `generated/v{NN}` tree.
try:
    async with Dhis2Client("https://newer-dhis2.example", auth=BasicAuth(...)) as client:
        ...
except UnsupportedVersionError as exc:
    # `exc.version` is the unsupported version key (e.g. 'v44');
    # `exc.available` is the list of trees the client does have.
    print(f"no generated module for {exc.version}; available: {exc.available}")
    print("run `d2w codegen generate --url ...` to add one")

Pass allow_version_fallback=True on the client constructor (or via open_client(..., allow_version_fallback=True)) to use the nearest-lower populated version instead of raising.

Worked example: examples/v42/client/error_handling.py.

errors

Exception hierarchy for dhis2w-client — shared across all version trees.

Errors carry no version-specific behaviour, so they live in one module rather than being copied per version. A single shared hierarchy means except dhis2w_client.Dhis2ApiError catches errors from a client bound to any DHIS2 major (v41/v42/v43), not just the baseline.

Classes

Dhis2ClientError

Bases: Exception

Base class for all dhis2w-client errors.

Source code in packages/dhis2w-client/src/dhis2w_client/errors.py
class Dhis2ClientError(Exception):
    """Base class for all dhis2w-client errors."""

Dhis2ApiError

Bases: Dhis2ClientError

Raised when the DHIS2 API returns a non-success response.

Source code in packages/dhis2w-client/src/dhis2w_client/errors.py
class Dhis2ApiError(Dhis2ClientError):
    """Raised when the DHIS2 API returns a non-success response."""

    def __init__(self, status_code: int, message: str, body: object | None = None) -> None:
        """Capture HTTP status, message, and optional response body."""
        super().__init__(f"DHIS2 API returned {status_code}: {message}")
        self.status_code = status_code
        self.message = message
        self.body = body

    @property
    def web_message(self) -> WebMessageResponse | None:
        """Parse `body` as a WebMessageResponse when the shape matches, else None.

        DHIS2 returns the envelope on errors too (e.g. 409 on /api/dataValueSets
        with `status=WARNING` + populated `conflicts[]`), so callers can inspect
        import counts and per-row rejections without re-parsing. The error-envelope
        shape is stable across majors, so the baseline (v42) model parses any tree's body.

        Imported lazily because `envelopes.py` pulls in the generated OAS tree,
        which itself imports `client.py` (for the generated resource
        accessors), and `client.py` imports `errors.py` — classic cycle. The
        `web_message` call-site runs only after the package is fully loaded,
        so the late import is safe.
        """
        if not isinstance(self.body, dict):
            return None
        from dhis2w_client.v42.envelopes import WebMessageResponse as _WMR

        try:
            return _WMR.model_validate(self.body)
        except Exception:
            return None
Attributes
web_message property

Parse body as a WebMessageResponse when the shape matches, else None.

DHIS2 returns the envelope on errors too (e.g. 409 on /api/dataValueSets with status=WARNING + populated conflicts[]), so callers can inspect import counts and per-row rejections without re-parsing. The error-envelope shape is stable across majors, so the baseline (v42) model parses any tree's body.

Imported lazily because envelopes.py pulls in the generated OAS tree, which itself imports client.py (for the generated resource accessors), and client.py imports errors.py — classic cycle. The web_message call-site runs only after the package is fully loaded, so the late import is safe.

Functions
__init__(status_code, message, body=None)

Capture HTTP status, message, and optional response body.

Source code in packages/dhis2w-client/src/dhis2w_client/errors.py
def __init__(self, status_code: int, message: str, body: object | None = None) -> None:
    """Capture HTTP status, message, and optional response body."""
    super().__init__(f"DHIS2 API returned {status_code}: {message}")
    self.status_code = status_code
    self.message = message
    self.body = body

AuthenticationError

Bases: Dhis2ClientError

Raised when authentication fails or tokens are invalid.

Source code in packages/dhis2w-client/src/dhis2w_client/errors.py
class AuthenticationError(Dhis2ClientError):
    """Raised when authentication fails or tokens are invalid."""

OAuth2FlowError

Bases: Dhis2ClientError

Raised when the OAuth 2.1 authorization-code flow fails.

Source code in packages/dhis2w-client/src/dhis2w_client/errors.py
class OAuth2FlowError(Dhis2ClientError):
    """Raised when the OAuth 2.1 authorization-code flow fails."""

UnsupportedVersionError

Bases: Dhis2ClientError

Raised when the DHIS2 instance version has no generated client and fallback is disabled.

Source code in packages/dhis2w-client/src/dhis2w_client/errors.py
class UnsupportedVersionError(Dhis2ClientError):
    """Raised when the DHIS2 instance version has no generated client and fallback is disabled."""

    def __init__(self, version: str, available: list[str]) -> None:
        """Capture the reported version and the list of versions we have codegen for."""
        summary = ", ".join(available) if available else "none"
        super().__init__(
            f"DHIS2 instance reports version {version}; "
            f"no generated client available (have: {summary}). "
            "Run `d2w codegen --url <instance>` to generate one."
        )
        self.version = version
        self.available = available
Functions
__init__(version, available)

Capture the reported version and the list of versions we have codegen for.

Source code in packages/dhis2w-client/src/dhis2w_client/errors.py
def __init__(self, version: str, available: list[str]) -> None:
    """Capture the reported version and the list of versions we have codegen for."""
    summary = ", ".join(available) if available else "none"
    super().__init__(
        f"DHIS2 instance reports version {version}; "
        f"no generated client available (have: {summary}). "
        "Run `d2w codegen --url <instance>` to generate one."
    )
    self.version = version
    self.available = available

VersionPinMismatchError

Bases: UnsupportedVersionError

Raised when Dhis2Client(version=...) pins a major different from the server's reported version.

Source code in packages/dhis2w-client/src/dhis2w_client/errors.py
class VersionPinMismatchError(UnsupportedVersionError):
    """Raised when `Dhis2Client(version=...)` pins a major different from the server's reported version."""

    def __init__(self, pinned: str, reported: str) -> None:
        """Capture the pinned generated tree + the wire-reported server version."""
        Dhis2ClientError.__init__(
            self,
            f"Dhis2Client pinned to {pinned!r} but DHIS2 reports {reported!r}. "
            "Running pinned-major models against a different-major server silently "
            "round-trips renamed or added fields wrong. Drop the explicit "
            "`version=` to auto-detect, or pass `allow_version_mismatch=True` if "
            "you've audited the schema overlap yourself.",
        )
        self.version = reported
        self.available = [pinned]
        self.pinned = pinned
        self.reported = reported
Functions
__init__(pinned, reported)

Capture the pinned generated tree + the wire-reported server version.

Source code in packages/dhis2w-client/src/dhis2w_client/errors.py
def __init__(self, pinned: str, reported: str) -> None:
    """Capture the pinned generated tree + the wire-reported server version."""
    Dhis2ClientError.__init__(
        self,
        f"Dhis2Client pinned to {pinned!r} but DHIS2 reports {reported!r}. "
        "Running pinned-major models against a different-major server silently "
        "round-trips renamed or added fields wrong. Drop the explicit "
        "`version=` to auto-detect, or pass `allow_version_mismatch=True` if "
        "you've audited the schema overlap yourself.",
    )
    self.version = reported
    self.available = [pinned]
    self.pinned = pinned
    self.reported = reported

Functions

format_unauthorized_message(method, path, www_authenticate)

Build a 401 message, surfacing actionable hints for known DHIS2 OAuth2 failures.

Source code in packages/dhis2w-client/src/dhis2w_client/errors.py
def format_unauthorized_message(method: str, path: str, www_authenticate: str | None) -> str:
    """Build a 401 message, surfacing actionable hints for known DHIS2 OAuth2 failures."""
    base = f"401 Unauthorized at {method} {path}"
    if not www_authenticate:
        return base
    description_match = _WWW_AUTHENTICATE_DESCRIPTION_RE.search(www_authenticate)
    if description_match is None:
        return base
    description = description_match.group(1).strip()
    mapping_match = _OPENID_MAPPING_RE.search(description)
    if mapping_match:
        claim = mapping_match.group("claim")
        value = mapping_match.group("value")
        return (
            f"{base} — DHIS2 accepted the OAuth2 JWT but no DHIS2 user has "
            f"openId={value!r} set, so the OIDC mapping (claim={claim!r}) returned no match. "
            "As an admin, PATCH the target user once:\n"
            "  curl -u <admin>:<password> -X PATCH \\\n"
            "    -H 'Content-Type: application/json-patch+json' \\\n"
            f'    -d \'[{{"op":"add","path":"/openId","value":"{value}"}}]\' \\\n'
            "    <base_url>/api/users/<user-uid>\n"
            "Fixed in DHIS2 v43+."
        )
    return f"{base}{description}"