Skip to content
Merged
Show file tree
Hide file tree
Changes from 3 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
6 changes: 6 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,12 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0

## [Unreleased]

### Fixed

- **Unraid API 4.37.8+ nullable `docker`/`vms` roots** — When the Docker or VM service fails, these servers return `{"docker": null}` with errors ([unraid/api#2123](https://github.com/unraid/api/pull/2123)). `query()` now raises `UnraidAPIError` (carrying the GraphQL errors) when every returned root is null and errors are present, and the container, VM, network, update-status, port-conflict, log and organizer readers no longer fail with `AttributeError`. They never return an empty list for a failed service ([#135](https://github.com/tinkerologie/unraid-api/issues/135)).
- **Introspection disabled on Unraid API 4.37.8+** — These servers refuse schema introspection with `INTROSPECTION_DISABLED` ([unraid/api#2095](https://github.com/unraid/api/pull/2095), [#2129](https://github.com/unraid/api/pull/2129)). `get_capabilities()` now recognises that code and uses `ServerCapabilities.introspection_disabled()`: every current field, minus `RETIRED_FIELDS` (direct array disk removal). VM UUIDs are requested again, and `remove_array_disk()` refuses without a request. Other introspection failures still fall back to `permissive()` ([#135](https://github.com/tinkerologie/unraid-api/issues/135)).
- **GraphQL errors on HTTP 4xx** — A 4xx response with a GraphQL `errors` body raises `UnraidAPIError("GraphQL request rejected (HTTP <status>)")` with those errors, so their messages and `extensions.code` are kept instead of a bare "HTTP error 400" ([#135](https://github.com/tinkerologie/unraid-api/issues/135)).

## [1.14.0] - 2026-10-24

### Added
Expand Down
7 changes: 7 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -273,6 +273,13 @@ the fallback. If introspection fails, permissive capabilities optimistically
attempt native restart. `typed_get_vms()` requests `uuid` only when introspection confirms support;
unknown schemas omit it to preserve VM listing.

Unraid API 4.37.8 and later refuse schema introspection unless the developer
sandbox is enabled. The client recognises the `INTROSPECTION_DISABLED` response
and treats the server as current: every field is available except those in
`RETIRED_FIELDS`, so VM UUIDs are still requested. On these servers a failed
Docker or VM service raises `UnraidAPIError`; the readers never return an empty
list for it.

Plugin and language installs modify the server. Follow their returned operation
with `subscribe_plugin_install_updates(operation.id)`. `remove_array_disk()`
emits `DeprecationWarning` and rejects servers without the retired mutation;
Expand Down
27 changes: 26 additions & 1 deletion src/unraid_api/capabilities.py
Original file line number Diff line number Diff line change
Expand Up @@ -35,6 +35,16 @@
)


# Fields removed upstream. A server that refuses introspection (Unraid API
# 4.37.8+) is newer than every removal listed here.
RETIRED_FIELDS: frozenset[str] = frozenset(
{
# Direct array disk removal, retired after v4.36.0 (unraid/api#2068).
"ArrayMutations.removeDiskFromArray",
}
)


def build_introspection_query() -> str:
"""Single aliased __type query covering every capability-relevant type."""
fragments = []
Expand All @@ -51,7 +61,7 @@ def build_introspection_query() -> str:
class ServerCapabilities:
"""Typed view over which GraphQL fields a specific server exposes."""

__slots__ = ("_fields", "_permissive")
__slots__ = ("_current", "_fields", "_permissive")

def __init__(
self,
Expand All @@ -61,6 +71,7 @@ def __init__(
) -> None:
self._fields: dict[str, frozenset[str]] = dict(fields)
self._permissive = permissive
self._current = False

@property
def is_permissive(self) -> bool:
Expand All @@ -71,6 +82,18 @@ def permissive(cls) -> ServerCapabilities:
"""Fallback when introspection fails — assume every feature is present."""
return cls({}, permissive=True)

@classmethod
def introspection_disabled(cls) -> ServerCapabilities:
"""Capabilities of a server that refuses introspection (API 4.37.8+).

Such a server has every field this client knows except those retired
upstream. Unlike `permissive()` it is a known, current server, so
optional fields such as `VmDomain.uuid` are requested.
"""
caps = cls({})
caps._current = True
return caps

@classmethod
def from_introspection_response(
cls, response: Mapping[str, Any]
Expand All @@ -90,6 +113,8 @@ def has(self, path: str) -> bool:
"""Check whether a dotted path (`TypeName.fieldName`) exists on the server."""
if self._permissive:
return True
if self._current:
return path not in RETIRED_FIELDS
if "." not in path:
raise ValueError(
f"Capability path must be dotted (TypeName.fieldName), got {path!r}"
Expand Down
83 changes: 73 additions & 10 deletions src/unraid_api/client.py
Original file line number Diff line number Diff line change
Expand Up @@ -83,12 +83,24 @@

# HTTP status codes
HTTP_OK = 200
HTTP_BAD_REQUEST = 400
HTTP_UNAUTHORIZED = 401
HTTP_FORBIDDEN = 403
HTTP_SERVER_ERROR = 500
DEFAULT_HTTP_PORT = 80
DEFAULT_HTTPS_PORT = 443


def _is_introspection_disabled(err: UnraidAPIError) -> bool:
"""Return whether the server refused a request as schema introspection."""
return any(
isinstance(e, dict)
and isinstance(e.get("extensions"), dict)
and e["extensions"].get("code") == "INTROSPECTION_DISABLED"
for e in err.errors
)


class UnraidClient:
"""Async client for interacting with Unraid GraphQL API.

Expand Down Expand Up @@ -678,6 +690,14 @@ async def _make_request(
allow_redirects=False,
**request_options,
) as redirect_response:
if (
HTTP_BAD_REQUEST
<= redirect_response.status
< HTTP_SERVER_ERROR
):
await self._raise_for_graphql_rejection(
redirect_response
)
redirect_response.raise_for_status()
result: dict[str, Any] = await redirect_response.json()
return result
Expand All @@ -690,6 +710,8 @@ async def _make_request(
"Invalid API key or insufficient permissions"
)

if HTTP_BAD_REQUEST <= response.status < HTTP_SERVER_ERROR:
await self._raise_for_graphql_rejection(response)
response.raise_for_status()
json_result: dict[str, Any] = await response.json()
return json_result
Expand All @@ -712,6 +734,27 @@ async def _make_request(
except aiohttp.ClientError as err:
raise UnraidConnectionError(f"Connection failed: {err}") from err

@staticmethod
async def _raise_for_graphql_rejection(response: aiohttp.ClientResponse) -> None:
"""Raise the GraphQL errors of a 4xx response, keeping their codes.

Unraid API 4.37.8+ rejects some operations before execution, e.g.
schema introspection with ``extensions.code`` ``INTROSPECTION_DISABLED``.
Returns without raising when the body has no GraphQL errors, or for
401/403, so the caller's authentication and HTTP error handling applies.
"""
if response.status in (HTTP_UNAUTHORIZED, HTTP_FORBIDDEN):
return
try:
body = await response.json(content_type=None)
except (aiohttp.ContentTypeError, ValueError):
return
errors = body.get("errors") if isinstance(body, dict) else None
if isinstance(errors, list) and errors:
raise UnraidAPIError(
f"GraphQL request rejected (HTTP {response.status})", errors=errors
)

async def query(
self, query: str, variables: dict[str, Any] | None = None
) -> dict[str, Any]:
Expand Down Expand Up @@ -765,7 +808,10 @@ async def _execute_query(

_LOGGER.debug("GraphQL returned %d error(s)", len(errors))

if data:
# Since Unraid API 4.37.8 the docker and vms roots are nullable, so a
# failed service returns {"docker": null} with errors instead of
# data: null. Nothing usable came back when every root is null.
if isinstance(data, dict) and any(v is not None for v in data.values()):
# Partial failure - log and return data
_LOGGER.debug(
"Some optional features unavailable: %s",
Expand Down Expand Up @@ -838,6 +884,23 @@ async def get_capabilities(self) -> ServerCapabilities:
response = await self.query(build_introspection_query())
except UnraidAuthenticationError:
raise
except UnraidAPIError as err:
if not _is_introspection_disabled(err):
_LOGGER.debug(
"Capability introspection failed (%s); "
"falling back to permissive",
err,
)
self._capabilities = ServerCapabilities.permissive()
return self._capabilities
# Only Unraid API 4.37.8+ refuses introspection, so the server
# has every current field; fields retired upstream are absent.
_LOGGER.debug(
"Server disables introspection (Unraid API 4.37.8+); "
"assuming current capabilities"
)
self._capabilities = ServerCapabilities.introspection_disabled()
return self._capabilities
except Exception as err:
_LOGGER.debug(
"Capability introspection failed (%s); falling back to permissive",
Expand Down Expand Up @@ -1434,7 +1497,7 @@ async def typed_get_containers(self) -> list[DockerContainer]:
caps = await self.get_capabilities()
query_str = self._build_containers_query(caps)
result = await self.query(query_str)
containers = result.get("docker", {}).get("containers", []) or []
containers = (result.get("docker") or {}).get("containers", []) or []
return [DockerContainer.from_api_response(c) for c in containers]

def _build_containers_query(self, caps: ServerCapabilities) -> str:
Expand Down Expand Up @@ -1566,7 +1629,7 @@ async def typed_get_containers_safe(self) -> list[DockerContainer]:
caps = await self.get_capabilities()
query_str = self._build_containers_query_safe(caps)
result = await self.query(query_str)
containers = result.get("docker", {}).get("containers", []) or []
containers = (result.get("docker") or {}).get("containers", []) or []
return [DockerContainer.from_api_response(c) for c in containers]

def _build_containers_query_safe(self, caps: ServerCapabilities) -> str:
Expand Down Expand Up @@ -1644,7 +1707,7 @@ async def typed_get_vms(self) -> list[VmDomain]:
}}
"""
result = await self.query(query_str)
domains = result.get("vms", {}).get("domains", []) or []
domains = (result.get("vms") or {}).get("domains", []) or []
return [VmDomain(**vm) for vm in domains]

async def typed_get_ups_devices(self) -> list[UPSDevice]:
Expand Down Expand Up @@ -1803,7 +1866,7 @@ async def get_container_update_statuses(self) -> list[ContainerUpdateStatus]:
}
"""
result = await self.query(query_str)
statuses = result.get("docker", {}).get("containerUpdateStatuses", []) or []
statuses = (result.get("docker") or {}).get("containerUpdateStatuses", []) or []
return [ContainerUpdateStatus(**s) for s in statuses]

async def get_ups_configuration(self) -> UPSConfiguration:
Expand Down Expand Up @@ -1895,7 +1958,7 @@ async def get_docker_port_conflicts(self) -> DockerPortConflicts:
}
"""
result = await self.query(query_str)
conflicts_data = result.get("docker", {}).get("portConflicts", {}) or {}
conflicts_data = (result.get("docker") or {}).get("portConflicts", {}) or {}
return DockerPortConflicts(**conflicts_data)

async def get_notification_overview(self) -> NotificationOverview:
Expand Down Expand Up @@ -2458,7 +2521,7 @@ async def get_containers(self) -> list[dict[str, Any]]:
}
"""
result = await self.query(query_str)
return list(result.get("docker", {}).get("containers", []))
return list((result.get("docker") or {}).get("containers", []))

# =========================================================================
# Additional VM Methods
Expand Down Expand Up @@ -2559,7 +2622,7 @@ async def get_vms(self) -> list[dict[str, Any]]:
}
"""
result = await self.query(query_str)
return list(result.get("vms", {}).get("domains", []) or [])
return list((result.get("vms") or {}).get("domains", []) or [])

# =========================================================================
# System Metrics Methods
Expand Down Expand Up @@ -3378,7 +3441,7 @@ async def get_docker_networks(self) -> list[dict[str, Any]]:
}
"""
result = await self.query(query_str)
return list(result.get("docker", {}).get("networks", []))
return list((result.get("docker") or {}).get("networks", []))

async def typed_get_docker_networks(self) -> list[DockerNetwork]:
"""Get Docker networks as Pydantic models.
Expand Down Expand Up @@ -3932,7 +3995,7 @@ async def get_container_logs(
if since is not None:
variables["since"] = since
result = await self.query(query, variables)
logs: dict[str, Any] = dict(result.get("docker", {}).get("logs", {}))
logs: dict[str, Any] = dict((result.get("docker") or {}).get("logs", {}))
return logs

async def typed_get_container_logs(
Expand Down
Loading
Loading