api: expose structured runtime error context
This commit is contained in:
@@ -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
@@ -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"
|
||||
}
|
||||
})
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user