diff --git a/README.md b/README.md index 1e2d10e..e4a6d6d 100644 --- a/README.md +++ b/README.md @@ -108,21 +108,38 @@ print(results.has_more) ## Error Handling +The SDK raises three top-level error types (see +[`040-codegen-policies.md`](https://github.com/devhelmhq/mono/blob/main/cowork/design/040-codegen-policies.md)): + +- `DevhelmValidationError` — local request/response shape validation failed. +- `DevhelmApiError` — the API returned a non-2xx status. Subclassed by HTTP + class for ergonomics: `DevhelmAuthError` (401/403), `DevhelmNotFoundError` + (404), `DevhelmConflictError` (409), `DevhelmRateLimitError` (429), + `DevhelmServerError` (5xx). +- `DevhelmTransportError` — the request never reached a server response + (connection refused, timeout, TLS failure, etc.). + +Every `DevhelmApiError` carries: + +- `status` — the HTTP status code +- `code` — coarse machine-readable category (e.g. `NOT_FOUND`, + `RATE_LIMITED`); switch on this, not the human-readable `message` +- `request_id` — the per-request id from the `X-Request-Id` response header; + always include this in support tickets + ```python -from devhelm import Devhelm, DevhelmError, AuthError +from devhelm import Devhelm, DevhelmAuthError, DevhelmError client = Devhelm(token="bad-token", org_id="1", workspace_id="1") try: client.monitors.list() -except AuthError as e: - print(f"Auth failed: {e.message} (HTTP {e.status})") +except DevhelmAuthError as e: + print(f"Auth failed: {e.message} (HTTP {e.status}, request_id={e.request_id})") except DevhelmError as e: print(f"API error [{e.code}]: {e.message}") ``` -Error codes: `AUTH`, `NOT_FOUND`, `CONFLICT`, `VALIDATION`, `API`. - ## Development ```bash diff --git a/docs/openapi/monitoring-api.json b/docs/openapi/monitoring-api.json index 97856bf..3cde442 100644 --- a/docs/openapi/monitoring-api.json +++ b/docs/openapi/monitoring-api.json @@ -10128,8 +10128,7 @@ "$ref": "#/components/schemas/TestNotificationPolicyRequest" } } - }, - "required": true + } }, "responses": { "200": { @@ -12921,6 +12920,7 @@ "Status Data" ], "summary": "Get a single service by slug or UUID with current status, components, and recent incidents", + "description": "When ``summary=true``, the inline ``components`` list is trimmed to groups + showcase leaves + currently-impacted leaves + ungrouped leaves, and a ``componentsSummary`` block is added with the trimmed counts. Powers SSR for vendors with hundreds of components (Snowflake, Cloudflare, DigitalOcean) without OOM-ing the renderer. Default false for full back-compat.", "operationId": "getService", "parameters": [ { @@ -12930,6 +12930,16 @@ "schema": { "type": "string" } + }, + { + "name": "summary", + "in": "query", + "description": "Return a curated subset of components (groups + showcase + impacted + ungrouped) and a componentsSummary block; default false", + "required": false, + "schema": { + "type": "boolean", + "default": false + } } ], "responses": { @@ -13032,6 +13042,7 @@ "Status Data" ], "summary": "List active components for a service with current status and inline uptime", + "description": "When ``groupId`` is supplied, only direct children of that group are returned — used by the pSEO renderer to lazy-load the leaves under a group that summary mode trimmed. Without ``groupId`` the response includes every active component for the service.", "operationId": "getComponents", "parameters": [ { @@ -13041,6 +13052,16 @@ "schema": { "type": "string" } + }, + { + "name": "groupId", + "in": "query", + "description": "Restrict result to direct children of this group component id", + "required": false, + "schema": { + "type": "string", + "format": "uuid" + } } ], "responses": { @@ -21894,6 +21915,35 @@ }, "description": "A single component position" }, + "ComponentsSummaryDto": { + "required": [ + "groupComponentCounts", + "includedCount", + "totalCount" + ], + "type": "object", + "properties": { + "totalCount": { + "type": "integer", + "description": "Total active components for this service across all groups", + "format": "int32" + }, + "includedCount": { + "type": "integer", + "description": "Number of components actually returned in the inline ``components`` list", + "format": "int32" + }, + "groupComponentCounts": { + "type": "object", + "additionalProperties": { + "type": "integer", + "description": "Per-group active leaf count, keyed by group component id (UUID stringified). Empty when the service has no groups; lets the UI render \"show all N\" affordances without a second round trip", + "format": "int32" + }, + "description": "Per-group active leaf count, keyed by group component id (UUID stringified). Empty when the service has no groups; lets the UI render \"show all N\" affordances without a second round trip" + } + } + }, "ComponentStatusDto": { "required": [ "id", @@ -22239,8 +22289,7 @@ "CreateEnvironmentRequest": { "required": [ "name", - "slug", - "isDefault" + "slug" ], "type": "object", "properties": { @@ -22269,7 +22318,8 @@ }, "isDefault": { "type": "boolean", - "description": "Whether this is the default environment for new monitors" + "description": "Whether this is the default environment for new monitors (default: false)", + "nullable": true } } }, @@ -22992,11 +23042,23 @@ "subscribedEvents": { "minItems": 1, "type": "array", - "description": "Event types to deliver, e.g. monitor.created, incident.resolved", + "description": "Event types to deliver", "items": { "minLength": 1, "type": "string", - "description": "Event types to deliver, e.g. monitor.created, incident.resolved" + "enum": [ + "monitor.created", + "monitor.updated", + "monitor.deleted", + "incident.created", + "incident.resolved", + "incident.reopened", + "service.status_changed", + "service.component_changed", + "service.incident_created", + "service.incident_updated", + "service.incident_resolved" + ] } } } @@ -23882,6 +23944,7 @@ }, "ErrorResponse": { "required": [ + "code", "message", "status", "timestamp" @@ -23894,6 +23957,11 @@ "format": "int32", "example": 404 }, + "code": { + "type": "string", + "description": "Coarse machine-readable error category (e.g. NOT_FOUND, RATE_LIMITED); stable per status", + "example": "NOT_FOUND" + }, "message": { "type": "string", "description": "Human-readable error message; safe to surface to end users", @@ -23904,13 +23972,21 @@ "description": "Server time when the error was produced (epoch milliseconds)", "format": "int64", "example": 1737302400000 + }, + "requestId": { + "type": "string", + "description": "Opaque per-request id; same value as the X-Request-Id response header. Use in support tickets.", + "nullable": true, + "example": "5b6f7a8c-1234-4d5e-9f0a-1b2c3d4e5f6a" } }, "description": "Uniform error envelope returned for every non-2xx response", "example": { "status": 404, + "code": "NOT_FOUND", "message": "Monitor not found", - "timestamp": 1737302400000 + "timestamp": 1737302400000, + "requestId": "5b6f7a8c-1234-4d5e-9f0a-1b2c3d4e5f6a" } }, "EscalationChain": { @@ -25430,7 +25506,18 @@ "properties": { "type": { "type": "string", - "description": "Rule type, e.g. severity_gte, monitor_id_in, region_in" + "description": "Rule type used to evaluate incidents and status events", + "enum": [ + "severity_gte", + "monitor_id_in", + "region_in", + "incident_status", + "monitor_type_in", + "service_id_in", + "resource_group_id_in", + "component_name_in", + "monitor_tag_in" + ] }, "value": { "type": "string", @@ -28149,6 +28236,14 @@ "$ref": "#/components/schemas/ServiceComponentDto" } }, + "componentsSummary": { + "nullable": true, + "allOf": [ + { + "$ref": "#/components/schemas/ComponentsSummaryDto" + } + ] + }, "uptime": { "nullable": true, "allOf": [ @@ -32518,7 +32613,19 @@ "nullable": true, "items": { "type": "string", - "description": "Replace subscribed events; null preserves current" + "enum": [ + "monitor.created", + "monitor.updated", + "monitor.deleted", + "incident.created", + "incident.resolved", + "incident.reopened", + "service.status_changed", + "service.component_changed", + "service.incident_created", + "service.incident_updated", + "service.incident_resolved" + ] } }, "enabled": { diff --git a/pyproject.toml b/pyproject.toml index 876821d..9ba4044 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,6 +1,6 @@ [project] name = "devhelm" -version = "0.1.2" +version = "0.1.3" description = "DevHelm SDK for Python — typed client for monitors, incidents, alerting, and more" authors = [{ name = "DevHelm", email = "hello@devhelm.io" }] license = "MIT" diff --git a/src/devhelm/__init__.py b/src/devhelm/__init__.py index 3848a5e..65b21d3 100644 --- a/src/devhelm/__init__.py +++ b/src/devhelm/__init__.py @@ -1,7 +1,6 @@ """DevHelm SDK for Python — typed client for monitors, incidents, alerting, and more.""" from devhelm._errors import ( - AuthError, DevhelmApiError, DevhelmAuthError, DevhelmConflictError, @@ -140,7 +139,6 @@ "DevhelmRateLimitError", "DevhelmServerError", "DevhelmTransportError", - "AuthError", # Pagination "Page", "CursorPage", diff --git a/src/devhelm/_errors.py b/src/devhelm/_errors.py index 0d4d6b5..83588c0 100644 --- a/src/devhelm/_errors.py +++ b/src/devhelm/_errors.py @@ -31,8 +31,17 @@ class DevhelmError(Exception): """Umbrella class — every typed SDK error inherits from this. Use this in catch-all sites; otherwise prefer the specific subclass. + + Every subclass populates ``code`` so callers can switch on the error + category without ``isinstance`` chains: + + - :class:`DevhelmValidationError` → ``"VALIDATION"`` + - :class:`DevhelmTransportError` → ``"TRANSPORT"`` + - :class:`DevhelmApiError` → server-supplied (e.g. ``"NOT_FOUND"``) """ + code: str = "ERROR" + class DevhelmValidationError(DevhelmError): """Raised when local validation of a request or response fails. @@ -42,6 +51,8 @@ class DevhelmValidationError(DevhelmError): `msg`, and `type`. """ + code = "VALIDATION" + def __init__( self, message: str, @@ -62,12 +73,26 @@ class DevhelmApiError(DevhelmError): Always carries the HTTP status code and the (best-effort parsed) error body. Use the subclasses below for HTTP-class-specific handling. + + The optional `code` field is the API's coarse machine-readable error + category (e.g. `NOT_FOUND`, `RATE_LIMITED`); see the `ErrorResponse` + schema in the OpenAPI spec. Surface clients should switch on `code`, + not the human-readable `message`. + + The optional `request_id` field is the per-request id emitted by the + API as the `X-Request-Id` response header and embedded in the JSON + error body. Always include it in support tickets. """ status: int message: str detail: str | None body: dict[str, Any] | str | None + # mypy infers `code: str` from the parent default, but we always populate + # it in __init__ — declaring it again here is documentation, not a + # narrowing. (Subclasses still inherit the same `str` type.) + code: str + request_id: str | None def __init__( self, @@ -76,12 +101,18 @@ def __init__( status: int, detail: str | None = None, body: dict[str, Any] | str | None = None, + code: str | None = None, + request_id: str | None = None, ) -> None: super().__init__(message) self.status = status self.message = message self.detail = detail self.body = body + # Server-supplied code wins; fall back to a generic API-error label so + # `err.code` is never ``None`` for callers switching on category. + self.code = code or "API_ERROR" + self.request_id = request_id class DevhelmAuthError(DevhelmApiError): @@ -112,6 +143,8 @@ class DevhelmTransportError(DevhelmError): on `__cause__` for full traceback. """ + code = "TRANSPORT" + def __init__(self, message: str, *, cause: Exception | None = None) -> None: super().__init__(message) self.message = message @@ -119,10 +152,20 @@ def __init__(self, message: str, *, cause: Exception | None = None) -> None: self.__cause__ = cause -def error_from_response(status: int, body: str) -> DevhelmApiError: - """Map an HTTP error response to a typed DevhelmApiError subclass.""" +def error_from_response( + status: int, body: str, *, request_id: str | None = None +) -> DevhelmApiError: + """Map an HTTP error response to a typed DevhelmApiError subclass. + + `request_id` is the value of the `X-Request-Id` response header. It is + pulled out at the call site (rather than re-parsed from the body) so the + SDK still surfaces the id even when the server returns a non-JSON body + (e.g. an HTML error page from a misconfigured proxy). + """ message = f"HTTP {status}" detail: str | None = None + code: str | None = None + body_request_id: str | None = None parsed_body: dict[str, Any] | str | None = body or None try: @@ -133,34 +176,35 @@ def error_from_response(status: int, body: str) -> DevhelmApiError: raw_detail = parsed.get("detail") if raw_detail is not None: detail = str(raw_detail) + raw_code = parsed.get("code") + if isinstance(raw_code, str): + code = raw_code + raw_request_id = parsed.get("requestId") or parsed.get("request_id") + if isinstance(raw_request_id, str): + body_request_id = raw_request_id except (json.JSONDecodeError, ValueError): pass + # Header value wins: the body may be missing/non-JSON, but the header is + # always present (set by RequestCorrelationFilter on the API side). + resolved_request_id = request_id or body_request_id + + kwargs: dict[str, Any] = { + "status": status, + "detail": detail, + "body": parsed_body, + "code": code, + "request_id": resolved_request_id, + } + if status in (401, 403): - return DevhelmAuthError(message, status=status, detail=detail, body=parsed_body) + return DevhelmAuthError(message, **kwargs) if status == 404: - return DevhelmNotFoundError( - message, status=status, detail=detail, body=parsed_body - ) + return DevhelmNotFoundError(message, **kwargs) if status == 409: - return DevhelmConflictError( - message, status=status, detail=detail, body=parsed_body - ) + return DevhelmConflictError(message, **kwargs) if status == 429: - return DevhelmRateLimitError( - message, status=status, detail=detail, body=parsed_body - ) + return DevhelmRateLimitError(message, **kwargs) if status >= 500: - return DevhelmServerError( - message, status=status, detail=detail, body=parsed_body - ) - return DevhelmApiError(message, status=status, detail=detail, body=parsed_body) - - -# --------------------------------------------------------------------------- -# Backwards-compatible aliases (no customers yet, but our own tests + scripts -# still import the legacy names; flip these to deprecation warnings once the -# rest of the codebase is migrated). -# --------------------------------------------------------------------------- - -AuthError = DevhelmAuthError + return DevhelmServerError(message, **kwargs) + return DevhelmApiError(message, **kwargs) diff --git a/src/devhelm/_generated.py b/src/devhelm/_generated.py index 9c59626..40171d6 100644 --- a/src/devhelm/_generated.py +++ b/src/devhelm/_generated.py @@ -1,6 +1,6 @@ # generated by datamodel-codegen: # filename: .openapi-preprocessed.json -# timestamp: 2026-04-20T20:13:19+00:00 +# timestamp: 2026-04-22T07:20:30+00:00 from __future__ import annotations from typing import Annotated, Any, Literal @@ -704,6 +704,31 @@ class ComponentPosition(BaseModel): ] = None +class ComponentsSummaryDto(BaseModel): + model_config = ConfigDict(extra="forbid") + total_count: Annotated[ + int, + Field( + alias="totalCount", + description="Total active components for this service across all groups", + ), + ] + included_count: Annotated[ + int, + Field( + alias="includedCount", + description="Number of components actually returned in the inline ``components`` list", + ), + ] + group_component_counts: Annotated[ + dict[str, int], + Field( + alias="groupComponentCounts", + description='Per-group active leaf count, keyed by group component id (UUID stringified). Empty when the service has no groups; lets the UI render "show all N" affordances without a second round trip', + ), + ] + + class ComponentStatusDto(BaseModel): model_config = ConfigDict(extra="forbid") id: Annotated[str, Field(description="Component UUID")] @@ -851,12 +876,12 @@ class CreateEnvironmentRequest(BaseModel): Field(description="Initial key-value variable pairs for this environment"), ] = None is_default: Annotated[ - bool, + bool | None, Field( alias="isDefault", - description="Whether this is the default environment for new monitors", + description="Whether this is the default environment for new monitors (default: false)", ), - ] + ] = None class RoleOffered(StrEnum): @@ -1188,14 +1213,18 @@ class CreateTagRequest(BaseModel): ] = None -class SubscribedEvent(RootModel[str]): - root: Annotated[ - str, - Field( - description="Event types to deliver, e.g. monitor.created, incident.resolved", - min_length=1, - ), - ] +class SubscribedEvent(StrEnum): + monitor_created = "monitor.created" + monitor_updated = "monitor.updated" + monitor_deleted = "monitor.deleted" + incident_created = "incident.created" + incident_resolved = "incident.resolved" + incident_reopened = "incident.reopened" + service_status_changed = "service.status_changed" + service_component_changed = "service.component_changed" + service_incident_created = "service.incident_created" + service_incident_updated = "service.incident_updated" + service_incident_resolved = "service.incident_resolved" class CreateWebhookEndpointRequest(BaseModel): @@ -1219,9 +1248,7 @@ class CreateWebhookEndpointRequest(BaseModel): subscribed_events: Annotated[ list[SubscribedEvent], Field( - alias="subscribedEvents", - description="Event types to deliver, e.g. monitor.created, incident.resolved", - min_length=1, + alias="subscribedEvents", description="Event types to deliver", min_length=1 ), ] @@ -1779,6 +1806,13 @@ class ErrorResponse(BaseModel): examples=[404], ), ] + code: Annotated[ + str, + Field( + description="Coarse machine-readable error category (e.g. NOT_FOUND, RATE_LIMITED); stable per status", + examples=["NOT_FOUND"], + ), + ] message: Annotated[ str, Field( @@ -1793,6 +1827,14 @@ class ErrorResponse(BaseModel): examples=[1737302400000], ), ] + request_id: Annotated[ + str | None, + Field( + alias="requestId", + description="Opaque per-request id; same value as the X-Request-Id response header. Use in support tickets.", + examples=["5b6f7a8c-1234-4d5e-9f0a-1b2c3d4e5f6a"], + ), + ] = None class EscalationStep(BaseModel): @@ -2702,10 +2744,23 @@ class MaintenanceWindowDto(BaseModel): ] +class Type30(StrEnum): + severity_gte = "severity_gte" + monitor_id_in = "monitor_id_in" + region_in = "region_in" + incident_status = "incident_status" + monitor_type_in = "monitor_type_in" + service_id_in = "service_id_in" + resource_group_id_in = "resource_group_id_in" + component_name_in = "component_name_in" + monitor_tag_in = "monitor_tag_in" + + class MatchRule(BaseModel): model_config = ConfigDict(extra="forbid") type: Annotated[ - str, Field(description="Rule type, e.g. severity_gte, monitor_id_in, region_in") + Type30, + Field(description="Rule type used to evaluate incidents and status events"), ] value: Annotated[ str | None, @@ -2727,22 +2782,22 @@ class MatchRule(BaseModel): ] = None -class Type30(StrEnum): +class Type31(StrEnum): mcp_connects = "mcp_connects" class McpConnectsAssertion(BaseModel): model_config = ConfigDict(extra="forbid") - type: Type30 + type: Type31 -class Type31(StrEnum): +class Type32(StrEnum): mcp_has_capability = "mcp_has_capability" class McpHasCapabilityAssertion(BaseModel): model_config = ConfigDict(extra="forbid") - type: Type31 + type: Type32 capability: Annotated[ str, Field( @@ -2752,25 +2807,25 @@ class McpHasCapabilityAssertion(BaseModel): ] -class Type32(StrEnum): +class Type33(StrEnum): mcp_min_tools = "mcp_min_tools" class McpMinToolsAssertion(BaseModel): model_config = ConfigDict(extra="forbid") - type: Type32 + type: Type33 min: Annotated[ int, Field(description="Minimum number of tools the server must expose") ] -class Type33(StrEnum): +class Type34(StrEnum): mcp_protocol_version = "mcp_protocol_version" class McpProtocolVersionAssertion(BaseModel): model_config = ConfigDict(extra="forbid") - type: Type33 + type: Type34 version: Annotated[ str, Field( @@ -2780,13 +2835,13 @@ class McpProtocolVersionAssertion(BaseModel): ] -class Type34(StrEnum): +class Type35(StrEnum): mcp_response_time = "mcp_response_time" class McpResponseTimeAssertion(BaseModel): model_config = ConfigDict(extra="forbid") - type: Type34 + type: Type35 max_ms: Annotated[ int, Field( @@ -2796,13 +2851,13 @@ class McpResponseTimeAssertion(BaseModel): ] -class Type35(StrEnum): +class Type36(StrEnum): mcp_response_time_warn = "mcp_response_time_warn" class McpResponseTimeWarnAssertion(BaseModel): model_config = ConfigDict(extra="forbid") - type: Type35 + type: Type36 warn_ms: Annotated[ int, Field( @@ -2855,13 +2910,13 @@ class McpServerMonitorConfig(BaseModel): ] = None -class Type36(StrEnum): +class Type37(StrEnum): mcp_tool_available = "mcp_tool_available" class McpToolAvailableAssertion(BaseModel): model_config = ConfigDict(extra="forbid") - type: Type36 + type: Type37 tool_name: Annotated[ str, Field( @@ -2872,13 +2927,13 @@ class McpToolAvailableAssertion(BaseModel): ] -class Type37(StrEnum): +class Type38(StrEnum): mcp_tool_count_changed = "mcp_tool_count_changed" class McpToolCountChangedAssertion(BaseModel): model_config = ConfigDict(extra="forbid") - type: Type37 + type: Type38 expected_count: Annotated[ int, Field( @@ -2957,7 +3012,7 @@ class MonitorAuthDto(BaseModel): config: ApiKeyAuthConfig | BasicAuthConfig | BearerAuthConfig | HeaderAuthConfig -class Type38(StrEnum): +class Type39(StrEnum): http = "HTTP" dns = "DNS" mcp_server = "MCP_SERVER" @@ -3413,13 +3468,13 @@ class RecoveryPolicy(BaseModel): ] -class Type40(StrEnum): +class Type41(StrEnum): redirect_count = "redirect_count" class RedirectCountAssertion(BaseModel): model_config = ConfigDict(extra="forbid") - type: Type40 + type: Type41 max_count: Annotated[ int, Field( @@ -3429,13 +3484,13 @@ class RedirectCountAssertion(BaseModel): ] -class Type41(StrEnum): +class Type42(StrEnum): redirect_target = "redirect_target" class RedirectTargetAssertion(BaseModel): model_config = ConfigDict(extra="forbid") - type: Type41 + type: Type42 expected: Annotated[ str, Field(description="Expected final URL after following redirects", min_length=1), @@ -3448,13 +3503,13 @@ class RedirectTargetAssertion(BaseModel): ] -class Type42(StrEnum): +class Type43(StrEnum): regex_body = "regex_body" class RegexBodyAssertion(BaseModel): model_config = ConfigDict(extra="forbid") - type: Type42 + type: Type43 pattern: Annotated[ str, Field( @@ -3695,13 +3750,13 @@ class ResourceGroupMemberDto(BaseModel): ] = None -class Type43(StrEnum): +class Type44(StrEnum): response_size = "response_size" class ResponseSizeAssertion(BaseModel): model_config = ConfigDict(extra="forbid") - type: Type43 + type: Type44 max_bytes: Annotated[ int, Field( @@ -3711,13 +3766,13 @@ class ResponseSizeAssertion(BaseModel): ] -class Type44(StrEnum): +class Type45(StrEnum): response_time = "response_time" class ResponseTimeAssertion(BaseModel): model_config = ConfigDict(extra="forbid") - type: Type44 + type: Type45 threshold_ms: Annotated[ int, Field( @@ -3727,13 +3782,13 @@ class ResponseTimeAssertion(BaseModel): ] -class Type45(StrEnum): +class Type46(StrEnum): response_time_warn = "response_time_warn" class ResponseTimeWarnAssertion(BaseModel): model_config = ConfigDict(extra="forbid") - type: Type45 + type: Type46 warn_ms: Annotated[ int, Field( @@ -4469,13 +4524,13 @@ class SlackChannelConfig(BaseModel): ] = None -class Type46(StrEnum): +class Type47(StrEnum): ssl_expiry = "ssl_expiry" class SslExpiryAssertion(BaseModel): model_config = ConfigDict(extra="forbid") - type: Type46 + type: Type47 min_days_remaining: Annotated[ int, Field( @@ -4485,13 +4540,13 @@ class SslExpiryAssertion(BaseModel): ] -class Type47(StrEnum): +class Type48(StrEnum): status_code = "status_code" class StatusCodeAssertion(BaseModel): model_config = ConfigDict(extra="forbid") - type: Type47 + type: Type48 expected: Annotated[ str, Field( @@ -4633,7 +4688,7 @@ class StatusPageBranding(BaseModel): ] = None -class Type48(StrEnum): +class Type49(StrEnum): monitor = "MONITOR" group = "GROUP" static = "STATIC" @@ -4654,7 +4709,7 @@ class StatusPageComponentDto(BaseModel): group_id: Annotated[UUID | None, Field(alias="groupId")] = None name: Annotated[str, Field(min_length=1)] description: str | None = None - type: Type48 + type: Type49 monitor_id: Annotated[UUID | None, Field(alias="monitorId")] = None resource_group_id: Annotated[UUID | None, Field(alias="resourceGroupId")] = None current_status: Annotated[CurrentStatus1, Field(alias="currentStatus")] @@ -5089,13 +5144,13 @@ class Tcp(BaseModel): ] -class Type49(StrEnum): +class Type50(StrEnum): tcp_connects = "tcp_connects" class TcpConnectsAssertion(BaseModel): model_config = ConfigDict(extra="forbid") - type: Type49 + type: Type50 class TcpMonitorConfig(BaseModel): @@ -5110,13 +5165,13 @@ class TcpMonitorConfig(BaseModel): ] = None -class Type50(StrEnum): +class Type51(StrEnum): tcp_response_time = "tcp_response_time" class TcpResponseTimeAssertion(BaseModel): model_config = ConfigDict(extra="forbid") - type: Type50 + type: Type51 max_ms: Annotated[ int, Field( @@ -5126,13 +5181,13 @@ class TcpResponseTimeAssertion(BaseModel): ] -class Type51(StrEnum): +class Type52(StrEnum): tcp_response_time_warn = "tcp_response_time_warn" class TcpResponseTimeWarnAssertion(BaseModel): model_config = ConfigDict(extra="forbid") - type: Type51 + type: Type52 warn_ms: Annotated[ int, Field( @@ -5309,7 +5364,7 @@ class TlsInfoDto(BaseModel): ] = None -class Type52(StrEnum): +class Type53(StrEnum): consecutive_failures = "consecutive_failures" failures_in_window = "failures_in_window" response_time = "response_time" @@ -5335,7 +5390,7 @@ class AggregationType(StrEnum): class TriggerRule(BaseModel): model_config = ConfigDict(extra="forbid") type: Annotated[ - Type52, + Type53, Field( description="Condition that opens or escalates an incident from check results" ), @@ -5883,7 +5938,7 @@ class UpdateWebhookEndpointRequest(BaseModel): ), ] = None subscribed_events: Annotated[ - list[str] | None, + list[SubscribedEvent] | None, Field( alias="subscribedEvents", description="Replace subscribed events; null preserves current", @@ -6759,7 +6814,7 @@ class MonitorDto(BaseModel): name: Annotated[ str, Field(description="Human-readable name for this monitor", min_length=1) ] - type: Type38 + type: Type39 config: ( DnsMonitorConfig | HeartbeatMonitorConfig @@ -6825,7 +6880,7 @@ class MonitorDto(BaseModel): class MonitorTestRequest(BaseModel): model_config = ConfigDict(extra="forbid") - type: Annotated[Type38, Field(description="Monitor protocol type to test")] + type: Annotated[Type39, Field(description="Monitor protocol type to test")] config: ( DnsMonitorConfig | HeartbeatMonitorConfig @@ -7033,6 +7088,9 @@ class ServiceDetailDto(BaseModel): list[ServiceIncidentDto], Field(alias="recentIncidents") ] components: list[ServiceComponentDto] + components_summary: Annotated[ + ComponentsSummaryDto | None, Field(alias="componentsSummary") + ] = None uptime: ComponentUptimeSummaryDto | None = None active_maintenances: Annotated[ list[ScheduledMaintenanceDto], Field(alias="activeMaintenances") diff --git a/src/devhelm/_http.py b/src/devhelm/_http.py index 4ee7eb2..d4d20a2 100644 --- a/src/devhelm/_http.py +++ b/src/devhelm/_http.py @@ -116,7 +116,11 @@ def checked_fetch(response: httpx.Response) -> _JsonResponse: if response.status_code == 204: return None return _decode_body(response) - raise error_from_response(response.status_code, response.text) + raise error_from_response( + response.status_code, + response.text, + request_id=response.headers.get("x-request-id"), + ) # --------------------------------------------------------------------------- diff --git a/tests/test_errors.py b/tests/test_errors.py index 77673e6..6d9512a 100644 --- a/tests/test_errors.py +++ b/tests/test_errors.py @@ -99,6 +99,57 @@ def test_json_without_detail(self) -> None: err = error_from_response(400, json.dumps({"message": "Bad request"})) assert err.detail is None + def test_json_with_code_and_request_id(self) -> None: + # The new error envelope (post-Batch A contract lockdown) carries + # `code` and `requestId` alongside `message`. Both must round-trip + # so callers can switch on `code` and surface `request_id` in + # support tickets. + err = error_from_response( + 404, + json.dumps( + { + "status": 404, + "code": "NOT_FOUND", + "message": "Monitor not found", + "requestId": "5b6f7a8c-1234-4d5e-9f0a-1b2c3d4e5f6a", + } + ), + ) + assert isinstance(err, DevhelmNotFoundError) + assert err.code == "NOT_FOUND" + assert err.request_id == "5b6f7a8c-1234-4d5e-9f0a-1b2c3d4e5f6a" + + def test_request_id_header_overrides_body(self) -> None: + # If a non-JSON body comes through (e.g. an upstream proxy returning + # HTML) we still need the request_id, so the header wins by design. + err = error_from_response( + 502, "Bad Gateway", request_id="hdr-uuid-1" + ) + assert err.request_id == "hdr-uuid-1" + # No server-supplied code → the SDK falls back to the generic + # API_ERROR sentinel so `err.code` is always populated. + assert err.code == "API_ERROR" + + def test_request_id_header_takes_precedence_over_body(self) -> None: + err = error_from_response( + 500, + json.dumps({"message": "boom", "requestId": "body-uuid"}), + request_id="hdr-uuid-2", + ) + assert err.request_id == "hdr-uuid-2" + + def test_request_id_falls_back_to_body_when_header_absent(self) -> None: + err = error_from_response( + 500, json.dumps({"message": "boom", "requestId": "body-uuid-3"}) + ) + assert err.request_id == "body-uuid-3" + + def test_no_code_or_request_id_for_non_json_body(self) -> None: + err = error_from_response(503, "") + # Server didn't supply a code → SDK falls back to API_ERROR sentinel. + assert err.code == "API_ERROR" + assert err.request_id is None + class TestDevhelmErrorInheritance: def test_api_error_is_devhelm_error(self) -> None: diff --git a/tests/test_schemas.py b/tests/test_schemas.py index f947b86..ba19b26 100644 --- a/tests/test_schemas.py +++ b/tests/test_schemas.py @@ -660,29 +660,28 @@ def test_invalid_component_type(self) -> None: ) -class TestRootModelStrictness: - """`scripts/inject_strict_config.py` deliberately skips RootModel classes - because Pydantic raises ``root-model-extra`` if you try to set - ``extra='forbid'`` on them. Strictness on ``RootModel[Inner]`` is - delegated to the *inner* type. These tests pin that contract — if the - inner variants ever lose their ``extra='forbid'`` config, ``RootModel`` - discriminated unions would silently accept unknown fields, which is the - exact spec-drift class we're guarding against (P1).""" - - def test_subscribed_event_accepts_valid_string(self) -> None: - # `SubscribedEvent(RootModel[str])` — the inner type is a scalar, so - # the strictness contract reduces to type/length validation. - ev = SubscribedEvent.model_validate("monitor.created") - assert ev.root == "monitor.created" +class TestSubscribedEventStrictness: + """`SubscribedEvent` is a closed enum in the spec — datamodel-codegen + emits it as a `StrEnum`, so unknown values fail at construction and + inside Pydantic models. These tests pin the closed-set contract so a + spec rename or type-loosening trips CI rather than silently accepting + new event names (P1).""" + + def test_subscribed_event_accepts_known_value(self) -> None: + ev = SubscribedEvent("monitor.created") + assert ev.value == "monitor.created" def test_subscribed_event_rejects_non_string(self) -> None: - with pytest.raises(ValidationError): - SubscribedEvent.model_validate(42) + with pytest.raises(ValueError): + SubscribedEvent(42) # type: ignore[arg-type] + + def test_subscribed_event_rejects_unknown_event(self) -> None: + with pytest.raises(ValueError): + SubscribedEvent("monitor.exploded") def test_subscribed_event_rejects_empty_string(self) -> None: - # min_length=1 from the spec. - with pytest.raises(ValidationError): - SubscribedEvent.model_validate("") + with pytest.raises(ValueError): + SubscribedEvent("") def test_check_type_details_routes_by_discriminator(self) -> None: details = CheckTypeDetailsDto.model_validate({"check_type": "http"}) diff --git a/uv.lock b/uv.lock index 1383e54..29f7416 100644 --- a/uv.lock +++ b/uv.lock @@ -331,7 +331,7 @@ wheels = [ [[package]] name = "devhelm" -version = "0.1.2" +version = "0.1.3" source = { editable = "." } dependencies = [ { name = "httpx" },