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

## [Unreleased]

## [1.14.1] - 2026-10-30

### 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 Expand Up @@ -535,7 +543,9 @@ Return dict/list for flexible access:
- `PhysicalDisk`
- `Notification`, `NotificationOverview`

[Unreleased]: https://github.com/tinkerologie/unraid-api/compare/v1.13.2...HEAD
[Unreleased]: https://github.com/tinkerologie/unraid-api/compare/v1.14.1...HEAD
[1.14.1]: https://github.com/tinkerologie/unraid-api/compare/v1.14.0...v1.14.1
[1.14.0]: https://github.com/tinkerologie/unraid-api/compare/v1.13.2...v1.14.0
[1.13.2]: https://github.com/tinkerologie/unraid-api/compare/v1.13.1...v1.13.2
[1.8.0]: https://github.com/tinkerologie/unraid-api/compare/v1.7.1...v1.8.0
[1.7.1]: https://github.com/tinkerologie/unraid-api/compare/v1.7.0...v1.7.1
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
2 changes: 1 addition & 1 deletion src/unraid_api/_version.py
Original file line number Diff line number Diff line change
@@ -1,3 +1,3 @@
"""Version information for unraid-api."""

__version__ = "1.14.0"
__version__ = "1.14.1"
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