From d3aaee1096cc15067f6c8b8f30b5cdf3ca4357ab Mon Sep 17 00:00:00 2001 From: Pierre Jacquier Date: Tue, 25 Aug 2026 15:38:55 +0200 Subject: [PATCH 1/3] Add typed modeling connection errors --- modeling-cmds/src/websocket.rs | 162 +++++++++++++++++++++++++++++++++ 1 file changed, 162 insertions(+) diff --git a/modeling-cmds/src/websocket.rs b/modeling-cmds/src/websocket.rs index 1fbf3826..d42f8750 100644 --- a/modeling-cmds/src/websocket.rs +++ b/modeling-cmds/src/websocket.rs @@ -48,6 +48,36 @@ pub enum ErrorCode { MessageTypeNotAcceptedForWebRTC, } +/// Stable machine-readable reasons that a modeling websocket connection cannot +/// continue. Clients should use `retryable`, rather than parsing the human-readable +/// detail, to decide whether to reconnect automatically. +#[derive(Display, FromStr, Copy, Eq, PartialEq, Debug, JsonSchema, Deserialize, Serialize, Clone, Ord, PartialOrd)] +#[display(style = "snake_case")] +#[serde(rename_all = "snake_case")] +#[cfg_attr(not(feature = "unstable_exhaustive"), non_exhaustive)] +pub enum ModelingConnectionErrorCode { + /// The authentication token is invalid or malformed. + AuthTokenInvalid, + /// The token does not grant access to the modeling API. + InsufficientScope, + /// The account has no payment method on file. + MissingPaymentMethod, + /// The account's payment method failed. + PaymentMethodFailed, + /// The account reached its configured billing threshold. + BillingThresholdReached, + /// The account exhausted its credits without enabling pay-as-you-go. + PayAsYouGoDisabled, + /// The account was blocked after repeated plan changes recycled credits. + UpgradeDowngradeAbuse, + /// Zoo support explicitly blocked the account. + Admin, + /// The account has reached its concurrent modeling-session limit. + TooManyConnections, + /// The selected modeling backend disconnected from the API. + BackendDisconnected, +} + /// Because [`EngineErrorCode`] is a subset of [`ErrorCode`], you can trivially map /// each variant of the former to a variant of the latter. impl From for ErrorCode { @@ -272,6 +302,31 @@ pub struct FailureWebSocketResponse { pub errors: Vec, } +/// A connection-level error that tells clients whether reconnecting can succeed +/// without the user or service state changing first. +#[derive(JsonSchema, Debug, Serialize, Deserialize, Clone, PartialEq)] +#[serde(rename_all = "snake_case")] +pub struct ModelingConnectionError { + /// Stable machine-readable reason for the connection failure. + pub code: ModelingConnectionErrorCode, + /// Human-readable detail suitable for display to the user. + pub detail: String, + /// Whether a client should automatically try to reconnect. + pub retryable: bool, +} + +/// Websocket response for a connection-level error. +#[derive(JsonSchema, Debug, Serialize, Deserialize, Clone, PartialEq)] +#[serde(rename_all = "snake_case")] +pub struct ConnectionErrorWebSocketResponse { + /// Always false. + pub success: bool, + /// Which request this is a response to, if any. + pub request_id: Option, + /// The connection-level error. + pub connection_error: ModelingConnectionError, +} + /// Websocket responses can either be successful or unsuccessful. /// Slightly different schemas in either case. #[derive(Debug, Serialize, Deserialize, Clone, PartialEq)] @@ -284,6 +339,21 @@ pub enum WebSocketResponse { Failure(FailureWebSocketResponse), } +/// Modeling API websocket responses, including connection-level errors emitted +/// by the API itself. The legacy [`WebSocketResponse`] remains unchanged for +/// engine and downstream compatibility. +#[derive(Debug, Serialize, Deserialize, Clone, PartialEq)] +#[cfg_attr(feature = "derive-jsonschema-on-enums", derive(schemars::JsonSchema))] +#[serde(rename_all = "snake_case", untagged)] +pub enum ModelingWebSocketResponse { + /// Response sent when a request succeeded. + Success(SuccessWebSocketResponse), + /// Response sent when a request did not succeed. + Failure(FailureWebSocketResponse), + /// Response sent when the connection cannot continue. + ConnectionError(ConnectionErrorWebSocketResponse), +} + /// Websocket responses can either be successful or unsuccessful. /// Slightly different schemas in either case. #[derive(Debug, Serialize, Deserialize, Clone, PartialEq)] @@ -340,6 +410,72 @@ impl WebSocketResponse { } } +impl ModelingWebSocketResponse { + /// Make a new success response. + pub fn success(request_id: Option, resp: OkWebSocketResponseData) -> Self { + Self::Success(SuccessWebSocketResponse { + success: true, + request_id, + resp, + }) + } + + /// Make a new failure response. + pub fn failure(request_id: Option, errors: Vec) -> Self { + Self::Failure(FailureWebSocketResponse { + success: false, + request_id, + errors, + }) + } + + /// Make a new connection-level error response. + pub fn connection_error( + request_id: Option, + code: ModelingConnectionErrorCode, + detail: impl Into, + retryable: bool, + ) -> Self { + Self::ConnectionError(ConnectionErrorWebSocketResponse { + success: false, + request_id, + connection_error: ModelingConnectionError { + code, + detail: detail.into(), + retryable, + }, + }) + } + + /// Did the request succeed? + pub fn is_success(&self) -> bool { + matches!(self, Self::Success(_)) + } + + /// Did the request fail? + pub fn is_failure(&self) -> bool { + matches!(self, Self::Failure(_) | Self::ConnectionError(_)) + } + + /// Get the ID of whichever request this response is for. + pub fn request_id(&self) -> Option { + match self { + Self::Success(x) => x.request_id, + Self::Failure(x) => x.request_id, + Self::ConnectionError(x) => x.request_id, + } + } +} + +impl From for ModelingWebSocketResponse { + fn from(response: WebSocketResponse) -> Self { + match response { + WebSocketResponse::Success(success) => Self::Success(success), + WebSocketResponse::Failure(failure) => Self::Failure(failure), + } + } +} + /// A raw file with unencoded contents. /// /// See the command that emits this type for its response encoding. @@ -1000,6 +1136,32 @@ mod tests { assert_json_eq(actual, expected); } + #[test] + fn serialize_websocket_connection_error() { + let actual = ModelingWebSocketResponse::connection_error( + Some(REQ_ID), + ModelingConnectionErrorCode::TooManyConnections, + "This account has reached its concurrent modeling-session limit.", + false, + ); + let expected = serde_json::json!({ + "success": false, + "request_id": "cc30d5e2-482b-4498-b5d2-6131c30a50a4", + "connection_error": { + "code": "too_many_connections", + "detail": "This account has reached its concurrent modeling-session limit.", + "retryable": false + } + }); + assert_json_eq(&actual, expected); + + let serialized = serde_json::to_value(&actual).unwrap(); + let round_tripped: ModelingWebSocketResponse = serde_json::from_value(serialized).unwrap(); + assert_eq!(round_tripped, actual); + assert!(actual.is_failure()); + assert_eq!(actual.request_id(), Some(REQ_ID)); + } + #[test] fn serialize_websocket_metrics() { let actual = WebSocketRequest::MetricsResponse { From d51152ba90e1ce36db7789dca08b54f8528de2d6 Mon Sep 17 00:00:00 2001 From: Pierre Jacquier Date: Mon, 7 Sep 2026 10:44:52 -0400 Subject: [PATCH 2/3] Prepare modeling-cmds 0.2.232 --- Cargo.lock | 2 +- modeling-cmds/Cargo.toml | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index 5676eb9a..8881df08 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -2598,7 +2598,7 @@ dependencies = [ [[package]] name = "kittycad-modeling-cmds" -version = "0.2.230" +version = "0.2.232" dependencies = [ "anyhow", "arbitrary", diff --git a/modeling-cmds/Cargo.toml b/modeling-cmds/Cargo.toml index 1f584360..733c6cc0 100644 --- a/modeling-cmds/Cargo.toml +++ b/modeling-cmds/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "kittycad-modeling-cmds" -version = "0.2.230" +version = "0.2.232" edition = "2021" authors = ["KittyCAD, Inc."] description = "Commands in the KittyCAD Modeling API" From e9d200ebdb49f5bb853dbc83054cbf0576606128 Mon Sep 17 00:00:00 2001 From: Pierre Jacquier Date: Mon, 7 Sep 2026 11:13:50 -0400 Subject: [PATCH 3/3] Keep KCL version schema API-compatible --- modeling-cmds/src/session.rs | 1 + 1 file changed, 1 insertion(+) diff --git a/modeling-cmds/src/session.rs b/modeling-cmds/src/session.rs index 15637e1a..f3507747 100644 --- a/modeling-cmds/src/session.rs +++ b/modeling-cmds/src/session.rs @@ -41,6 +41,7 @@ pub struct EngineParams { /// Which KCL+Engine version should the engine use? /// Clients use this to opt into updated algorithms and behaviours. #[serde(default)] + #[schemars(with = "String")] pub kcl_version: KclVersion, }