Skip to content

Error Handling

aiopikvm provides a structured exception hierarchy for precise error handling.

Exception hierarchy

PiKVMError
├── APIError
│   ├── AuthError
│   ├── BusyError
│   ├── UnavailableError
│   ├── RedirectError
│   └── ResponseError
├── ConfigurationError
├── ConnectError
├── ConnectionTimeoutError
├── WebRTCError
└── WebSocketError

All exceptions inherit from PiKVMError, so you can catch all aiopikvm errors with a single handler.

Exception types

Exception When raised
PiKVMError Base exception for all errors; also raised when accessing resources before entering async context
APIError PiKVM returned an HTTP error (>= 400) or the JSON body has "ok": false
AuthError Authentication failed (HTTP 401 or 403)
BusyError PiKVM is busy with another operation (HTTP 409); the same call usually succeeds once it finishes
UnavailableError The subsystem is disabled in the kvmd config or offline (HTTP 503)
RedirectError PiKVM answered with a redirect (3xx) and the client was not created with follow_redirects=True — or it was, and the redirects formed a loop, the one case where status_code is 0
ResponseError The response was not the documented JSON envelope, did not match the model for that endpoint, or did not survive its Content-Encoding
ConfigurationError The client cannot use what it was given, and nothing was sent: an unusable URL, proxy or credentials — including a TOTP code that is not ASCII, which is only known once the code has been produced — a call with no parameters at all, or a value kvmd's own encoding would silently mangle: a shortcut key holding a comma or whitespace, a key name that will not fit a binary WebSocket frame
ConnectError Failed to connect to PiKVM, or the connection broke mid-request
ConnectionTimeoutError Request timed out
WebRTCError The Janus gateway or its ustreamer plugin refused, the negotiation never completed, or the session was used while not open — before its async with block, or after it. Once the upgrade is through, /janus/ws speaks Janus's own protocol, so this carries code and reason — Janus's numbering or the plugin's — instead of an HTTP status; code is 0 when the failure had none. The upgrade itself is not this: kvmd's auth chain sits in front of Janus, so a refused handshake raises AuthError/APIError like any request, and the signalling socket failing raises WebSocketError
WebSocketError The WebSocket could not be opened, or it broke instead of closing cleanly. A handshake kvmd itself refuses raises AuthError/APIError instead

APIError details

APIError carries the HTTP status and the error block kvmd puts in the body ({"ok": false, "result": {"error": "AtxIsBusyError", "error_msg": "..."}}). The class name is the kvmd exception, so it is subsystem-specific:

from aiopikvm import APIError

try:
    await kvm.atx.click_power()
except APIError as exc:
    print(f"Status: {exc.status_code}")   # 409
    print(f"Class: {exc.error}")          # AtxIsBusyError
    print(f"Message: {exc.error_msg}")    # Performing another ATX operation, ...

error and error_msg are empty strings when the response carried no kvmd error block — for example when a reverse proxy answered instead of kvmd.

Note

status_code is 0 when there was no single status to report: an error kvmd put in the body of an HTTP 200 ("ok": false), or a redirect loop the client gave up on.

Retrying a busy device

ATX, MSD and GPIO reject a request while an earlier operation is still running. That is a BusyError, and it is the one failure worth retrying as-is:

from aiopikvm import BusyError

for attempt in range(5):
    try:
        await kvm.atx.click_power()
        break
    except BusyError:
        await asyncio.sleep(1)

Redirects

A redirect is reported instead of being followed, because following one can hand the credential to the target. A socket follows one only within its own ws/wss scheme, and there it repeats its handshake headers verbatim, so it carries whatever the auth mode sends; every other redirect is refused before anything is resent — the absolute https:// a real server sends included. Over HTTP the X-KVMD-User / X-KVMD-Passwd pair travels anywhere, Authorization is dropped when the origin changes except on a plain httphttps upgrade of the same host, and the session token travels as far as its cookie scope reaches: the device's subdomains and its other ports, and for a token set by hand every host.

from aiopikvm import RedirectError

try:
    await kvm.atx.get_state()
except RedirectError as exc:
    print(exc)  # HTTP 301: PiKVM redirected to https://pikvm.local/api/atx

The usual cause is an http:// base URL that PiKVM's nginx redirects to https:// — by which point the password or the token has already gone out in cleartext, so fix the URL rather than the symptom. Pass follow_redirects=True to the client if you have a proxy that legitimately redirects.

Values the type checker catches

Several parameters take one of a short list of names kvmd knows, and a name outside it is an HTTP 400 — a mistake that otherwise only shows up when the call runs. Those parameters carry a literal type, so a typo is a type error instead:

Parameter Type Values
hid.set_params(keyboard_output=…) KeyboardOutput usb, ps2, disabled
hid.set_params(mouse_output=…) MouseOutput usb, usb_win98, usb_rel, ps2, disabled
hid.send_mouse_button(), ws.send_mouse_button() MouseButton left, right, middle, up, down
msd.download(compress=…) Compression "", none, lzma, zstd
switch.atx_power() ATXAction on, off, off_hard, reset_hard
switch.atx_click() ATXButton power, power_long, reset
redfish.reset() ResetType On, ForceOn, ForceOff, GracefulShutdown, ForceRestart, PushPowerButton

Each lives in the resource module whose parameter it belongs to, so a variable holding one can be annotated:

from aiopikvm import MouseButton

button: MouseButton = "left"
await kvm.hid.send_mouse_button(button)

The annotation is the point: without it mypy infers str for that variable and refuses the call. Write it where the value is written down, not at the call site.

For a name that arrives at runtime — out of a config file, off a UI — the values are on the type:

from typing import cast, get_args
from aiopikvm import MouseButton

if name not in get_args(MouseButton.__value__):
    raise ValueError(f"kvmd has no mouse button named {name!r}")
await kvm.hid.send_mouse_button(cast(MouseButton, name))

The cast is not optional: get_args() is typed as returning tuple[Any, ...], so the check above narrows nothing for mypy even though it settles the question at runtime. What makes the cast honest is the line before it.

Two things these types deliberately do not do. They do not enforce anything at runtime: the client sends what it is handed, so a value some later kvmd understands still goes through with a cast or a # type: ignore[arg-type]. And they stay out of the response models, where a literal type would turn a value this release has not seen into a ResponseError instead of a string the caller can look at and decide about.

ResetType is matched by kvmd as written. Every other list in the table is lowercased before matching, so a device would also take "USB" or "Left" — only the canonical spelling is typed.

Key names are the one vocabulary left as plain str, and they are case-sensitive like ResetType. There are 126 of them, and a key is usually computed rather than written out, so they are a runtime set instead: see KEY_NAMES.

Usage patterns

Catch all errors

from aiopikvm import PiKVMError

try:
    async with PiKVM("https://pikvm.local", user="admin", passwd="admin") as kvm:
        await kvm.atx.power_on()
except PiKVMError as exc:
    print(f"PiKVM error: {exc}")

Catch specific errors

from aiopikvm import (
    APIError,
    AuthError,
    BusyError,
    ConnectError,
    ConnectionTimeoutError,
    ResponseError,
    UnavailableError,
)

try:
    await kvm.atx.click_power()
except AuthError:
    print("Invalid credentials")
except BusyError:
    print("PiKVM is busy — retry in a moment")
except UnavailableError:
    print("The subsystem is offline")
except ResponseError as exc:
    print(f"Cannot parse this kvmd version: {exc}")
except ConnectError:
    print("Cannot reach PiKVM")
except ConnectionTimeoutError:
    print("Request timed out")
except APIError as exc:
    print(f"API error {exc.status_code}: {exc}")

Order matters: BusyError, UnavailableError, ResponseError, AuthError and RedirectError all inherit from APIError, so a bare except APIError first would swallow them.

A subsystem that is disabled in the kvmd config does not produce UnavailableError: kvmd answers HTTP 400 with its own class name, so it arrives as a plain APIError. Tell them apart by exc.error — for example "AtxDisabledError" — or check the subsystem state first, where enabled says so without a failed call.

WebSocket errors

The upgrade to /api/ws goes through the same auth chain as every REST call and is refused with an ordinary HTTP response, so a refused handshake raises AuthError — not WebSocketError, which is reserved for a socket that never opened or that broke:

from aiopikvm import APIError, AuthError, WebSocketError

try:
    async with kvm.ws() as ws:
        async for event in ws.events():
            print(event)
except AuthError as exc:
    print(f"kvmd refused the credentials: HTTP {exc.status_code}")
except APIError as exc:
    print(f"kvmd refused the upgrade: HTTP {exc.status_code} {exc.error}")
except WebSocketError as exc:
    print(f"the connection failed or was lost: {exc}")

events() ends quietly when either side closes the connection cleanly and raises WebSocketError when it breaks instead, so a loop that simply finishes never hides a dropped connection.

Context not entered

from aiopikvm import PiKVM, PiKVMError

kvm = PiKVM("https://pikvm.local")

try:
    _ = kvm.atx  # Raises PiKVMError — async context not entered
except PiKVMError as exc:
    print(exc)

Full example

import asyncio
from aiopikvm import (
    PiKVM,
    PiKVMError,
    AuthError,
    ConnectError,
    ConnectionTimeoutError,
)

async def main():
    try:
        async with PiKVM("https://pikvm.local", user="admin", passwd="admin") as kvm:
            state = await kvm.atx.get_state()
            print(f"Power: {state.leds.power}")
    except AuthError:
        print("Check your username and password")
    except ConnectError:
        print("Cannot connect to PiKVM — check the URL and network")
    except ConnectionTimeoutError:
        print("Connection timed out — PiKVM may be busy")
    except PiKVMError as exc:
        print(f"Unexpected error: {exc}")

asyncio.run(main())