api: expose structured runtime error context

This commit is contained in:
a.tolmachev
2026-04-19 21:32:56 +00:00
parent 5debf4dd05
commit af511316ba
2 changed files with 230 additions and 9 deletions
+90 -2
View File
@@ -231,10 +231,14 @@ impl From<StorageError> for ApiError {
}
pub fn runtime_test_failure(error: &RuntimeError) -> Value {
json!({
let mut payload = json!({
"code": runtime_test_failure_code(error),
"message": error.to_string()
})
});
if let Some(context) = runtime_error_context(error) {
payload["context"] = context;
}
payload
}
fn runtime_test_failure_code(error: &RuntimeError) -> &'static str {
@@ -259,3 +263,87 @@ fn runtime_test_failure_code(error: &RuntimeError) -> &'static str {
RuntimeError::SecretCrypto { .. } => "runtime_secret_crypto_error",
}
}
pub fn runtime_error_context(error: &RuntimeError) -> Option<Value> {
match error {
RuntimeError::InvalidPreparedRequest { field, reason } => Some(json!({
"field": field,
"reason": reason,
})),
RuntimeError::InvalidStreamingPayload { field, reason } => Some(json!({
"field": field,
"reason": reason,
})),
RuntimeError::InvalidAuthSecretValue { secret_id, reason } => Some(json!({
"secret_id": secret_id,
"reason": reason,
})),
RuntimeError::SecretCrypto { operation, details } => Some(json!({
"operation": operation,
"details": details,
})),
RuntimeError::MissingAuthProfile { auth_profile_id } => Some(json!({
"auth_profile_id": auth_profile_id,
})),
RuntimeError::MissingSecret { secret_id } => Some(json!({
"secret_id": secret_id,
})),
RuntimeError::MissingSecretVersion { secret_id, version } => Some(json!({
"secret_id": secret_id,
"version": version,
})),
RuntimeError::MissingStreamingConfig { operation_id } => Some(json!({
"operation_id": operation_id,
})),
RuntimeError::UnsupportedExecutionMode { operation_id, mode } => Some(json!({
"operation_id": operation_id,
"mode": mode,
})),
RuntimeError::UnsupportedProtocol { protocol } => Some(json!({
"protocol": protocol,
})),
_ => None,
}
}
#[cfg(test)]
mod tests {
use serde_json::json;
use super::{runtime_error_context, runtime_test_failure};
use crank_runtime::RuntimeError;
#[test]
fn runtime_test_failure_includes_structured_context() {
let payload = runtime_test_failure(&RuntimeError::InvalidPreparedRequest {
field: "request.headers".to_owned(),
reason: "must be an object".to_owned(),
});
assert_eq!(payload["code"], "runtime_request_error");
assert_eq!(
payload["context"],
json!({
"field": "request.headers",
"reason": "must be an object"
})
);
}
#[test]
fn runtime_error_context_includes_secret_crypto_operation() {
let context = runtime_error_context(&RuntimeError::SecretCrypto {
operation: "decode secret envelope",
details: "bad base64".to_owned(),
})
.unwrap();
assert_eq!(
context,
json!({
"operation": "decode secret envelope",
"details": "bad base64"
})
);
}
}
+140 -7
View File
@@ -693,6 +693,7 @@ async fn handle_base_tool_call(
&session.protocol_version,
runtime_error_code(&error),
error.to_string(),
runtime_error_context(&error),
)
}
}
@@ -716,6 +717,7 @@ async fn handle_session_start_call(
&session.protocol_version,
"streaming_config_error",
"streaming config is required for session tools".to_owned(),
None,
);
};
@@ -841,6 +843,7 @@ async fn handle_session_start_call(
&session.protocol_version,
runtime_error_code(&error),
error.to_string(),
runtime_error_context(&error),
)
}
}
@@ -874,6 +877,7 @@ async fn handle_session_poll_call(
&session.protocol_version,
"streaming_config_error",
"streaming config is required for session tools".to_owned(),
None,
);
};
@@ -891,6 +895,7 @@ async fn handle_session_poll_call(
&session.protocol_version,
"stream_session_not_found",
format!("stream session {} was not found", control.session_id),
None,
);
}
Err(error) => return internal_jsonrpc_error(message, error),
@@ -903,6 +908,7 @@ async fn handle_session_poll_call(
&session.protocol_version,
"stream_session_not_found",
format!("stream session {} was not found", control.session_id),
None,
);
}
@@ -927,6 +933,7 @@ async fn handle_session_poll_call(
&session.protocol_version,
"stream_session_expired",
format!("stream session {} has expired", control.session_id),
None,
);
}
@@ -937,6 +944,7 @@ async fn handle_session_poll_call(
&session.protocol_version,
"stream_session_not_found",
format!("stream session {} is not running", control.session_id),
None,
);
}
@@ -949,6 +957,7 @@ async fn handle_session_poll_call(
&session.protocol_version,
"streaming_payload_error",
error.to_string(),
None,
);
}
};
@@ -1054,6 +1063,7 @@ async fn handle_session_stop_call(
&session.protocol_version,
"stream_session_not_found",
format!("stream session {} was not found", control.session_id),
None,
);
}
Err(error) => return internal_jsonrpc_error(message, error),
@@ -1066,6 +1076,7 @@ async fn handle_session_stop_call(
&session.protocol_version,
"stream_session_not_found",
format!("stream session {} was not found", control.session_id),
None,
);
}
@@ -1107,6 +1118,7 @@ async fn handle_async_job_start_call(
&session.protocol_version,
"streaming_config_error",
"streaming config is required for async job tools".to_owned(),
None,
);
};
@@ -1188,7 +1200,8 @@ async fn handle_async_job_start_call(
result: None,
error: Some(&json!({
"code": runtime_error_code(&error),
"message": error.to_string()
"message": error.to_string(),
"context": runtime_error_context(&error)
})),
expires_at: None,
updated_at: &finished_at,
@@ -1265,6 +1278,7 @@ async fn handle_async_job_status_call(
&session.protocol_version,
"async_job_not_found",
format!("async job {} was not found", control.job_id),
None,
);
}
Err(error) => return internal_jsonrpc_error(message, error),
@@ -1277,6 +1291,7 @@ async fn handle_async_job_status_call(
&session.protocol_version,
"async_job_not_found",
format!("async job {} was not found", control.job_id),
None,
);
}
@@ -1328,6 +1343,7 @@ async fn handle_async_job_result_call(
&session.protocol_version,
"async_job_not_found",
format!("async job {} was not found", control.job_id),
None,
);
}
Err(error) => return internal_jsonrpc_error(message, error),
@@ -1340,6 +1356,7 @@ async fn handle_async_job_result_call(
&session.protocol_version,
"async_job_not_found",
format!("async job {} was not found", control.job_id),
None,
);
}
@@ -1356,9 +1373,16 @@ async fn handle_async_job_result_call(
&session.protocol_version,
"async_job_failed",
job.error
.and_then(|value| value.get("message").cloned())
.and_then(|value| value.as_str().map(ToOwned::to_owned))
.as_ref()
.and_then(|value| value.get("message"))
.and_then(Value::as_str)
.map(ToOwned::to_owned)
.unwrap_or_else(|| "async job failed".to_owned()),
job.error
.as_ref()
.and_then(|value| value.get("context"))
.filter(|value| !value.is_null())
.cloned(),
),
JobStatus::Cancelled => tool_error_response(
message,
@@ -1366,6 +1390,7 @@ async fn handle_async_job_result_call(
&session.protocol_version,
"async_job_cancelled",
"async job was cancelled".to_owned(),
None,
),
_ => tool_error_response(
message,
@@ -1373,6 +1398,7 @@ async fn handle_async_job_result_call(
&session.protocol_version,
"async_job_not_ready",
"async job result is not ready".to_owned(),
None,
),
}
}
@@ -1411,6 +1437,7 @@ async fn handle_async_job_cancel_call(
&session.protocol_version,
"async_job_not_found",
format!("async job {} was not found", control.job_id),
None,
);
}
}
@@ -1421,6 +1448,7 @@ async fn handle_async_job_cancel_call(
&session.protocol_version,
"async_job_not_found",
format!("async job {} was not found", control.job_id),
None,
);
}
Err(error) => return internal_jsonrpc_error(message, error),
@@ -1885,7 +1913,16 @@ fn tool_error_response(
protocol_version: &str,
code: &str,
error_message: String,
error_context: Option<Value>,
) -> Response {
let mut error = json!({
"code": code,
"message": error_message
});
if let Some(context) = error_context {
error["context"] = context;
}
transport_response(
StatusCode::OK,
jsonrpc_result(
@@ -1898,10 +1935,7 @@ fn tool_error_response(
}
],
"structuredContent": {
"error": {
"code": code,
"message": error_message
}
"error": error
},
"isError": true
}),
@@ -1968,6 +2002,48 @@ fn runtime_error_code(error: &RuntimeError) -> &'static str {
}
}
fn runtime_error_context(error: &RuntimeError) -> Option<Value> {
match error {
RuntimeError::InvalidPreparedRequest { field, reason } => Some(json!({
"field": field,
"reason": reason,
})),
RuntimeError::InvalidStreamingPayload { field, reason } => Some(json!({
"field": field,
"reason": reason,
})),
RuntimeError::InvalidAuthSecretValue { secret_id, reason } => Some(json!({
"secret_id": secret_id,
"reason": reason,
})),
RuntimeError::SecretCrypto { operation, details } => Some(json!({
"operation": operation,
"details": details,
})),
RuntimeError::MissingAuthProfile { auth_profile_id } => Some(json!({
"auth_profile_id": auth_profile_id,
})),
RuntimeError::MissingSecret { secret_id } => Some(json!({
"secret_id": secret_id,
})),
RuntimeError::MissingSecretVersion { secret_id, version } => Some(json!({
"secret_id": secret_id,
"version": version,
})),
RuntimeError::MissingStreamingConfig { operation_id } => Some(json!({
"operation_id": operation_id,
})),
RuntimeError::UnsupportedExecutionMode { operation_id, mode } => Some(json!({
"operation_id": operation_id,
"mode": mode,
})),
RuntimeError::UnsupportedProtocol { protocol } => Some(json!({
"protocol": protocol,
})),
_ => None,
}
}
fn hash_access_secret(secret: &str) -> String {
let digest = Sha256::digest(secret.as_bytes());
URL_SAFE_NO_PAD.encode(digest)
@@ -2320,3 +2396,60 @@ fn extract_origin(url: &str) -> Option<String> {
None
}
#[cfg(test)]
mod tests {
use axum::body::to_bytes;
use serde_json::{Value, json};
use super::{ResponseMode, runtime_error_context, tool_error_response};
use crate::jsonrpc::CURRENT_PROTOCOL_VERSION;
use crank_runtime::RuntimeError;
#[test]
fn runtime_error_context_includes_secret_crypto_operation() {
let context = runtime_error_context(&RuntimeError::SecretCrypto {
operation: "decode secret envelope",
details: "bad base64".to_owned(),
})
.unwrap();
assert_eq!(
context,
json!({
"operation": "decode secret envelope",
"details": "bad base64"
})
);
}
#[tokio::test]
async fn tool_error_response_includes_structured_context() {
let response = tool_error_response(
&json!({"jsonrpc": "2.0", "id": "req-1"}),
ResponseMode::Json,
CURRENT_PROTOCOL_VERSION,
"streaming_payload_error",
"request root must be an object".to_owned(),
Some(json!({
"field": "request",
"reason": "must be an object"
})),
);
let body = to_bytes(response.into_body(), usize::MAX).await.unwrap();
let payload: Value = serde_json::from_slice(&body).unwrap();
assert_eq!(
payload["result"]["structuredContent"]["error"],
json!({
"code": "streaming_payload_error",
"message": "request root must be an object",
"context": {
"field": "request",
"reason": "must be an object"
}
})
);
}
}