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 {
|
pub fn runtime_test_failure(error: &RuntimeError) -> Value {
|
||||||
json!({
|
let mut payload = json!({
|
||||||
"code": runtime_test_failure_code(error),
|
"code": runtime_test_failure_code(error),
|
||||||
"message": error.to_string()
|
"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 {
|
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",
|
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,
|
&session.protocol_version,
|
||||||
runtime_error_code(&error),
|
runtime_error_code(&error),
|
||||||
error.to_string(),
|
error.to_string(),
|
||||||
|
runtime_error_context(&error),
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -716,6 +717,7 @@ async fn handle_session_start_call(
|
|||||||
&session.protocol_version,
|
&session.protocol_version,
|
||||||
"streaming_config_error",
|
"streaming_config_error",
|
||||||
"streaming config is required for session tools".to_owned(),
|
"streaming config is required for session tools".to_owned(),
|
||||||
|
None,
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -841,6 +843,7 @@ async fn handle_session_start_call(
|
|||||||
&session.protocol_version,
|
&session.protocol_version,
|
||||||
runtime_error_code(&error),
|
runtime_error_code(&error),
|
||||||
error.to_string(),
|
error.to_string(),
|
||||||
|
runtime_error_context(&error),
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -874,6 +877,7 @@ async fn handle_session_poll_call(
|
|||||||
&session.protocol_version,
|
&session.protocol_version,
|
||||||
"streaming_config_error",
|
"streaming_config_error",
|
||||||
"streaming config is required for session tools".to_owned(),
|
"streaming config is required for session tools".to_owned(),
|
||||||
|
None,
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -891,6 +895,7 @@ async fn handle_session_poll_call(
|
|||||||
&session.protocol_version,
|
&session.protocol_version,
|
||||||
"stream_session_not_found",
|
"stream_session_not_found",
|
||||||
format!("stream session {} was not found", control.session_id),
|
format!("stream session {} was not found", control.session_id),
|
||||||
|
None,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
Err(error) => return internal_jsonrpc_error(message, error),
|
Err(error) => return internal_jsonrpc_error(message, error),
|
||||||
@@ -903,6 +908,7 @@ async fn handle_session_poll_call(
|
|||||||
&session.protocol_version,
|
&session.protocol_version,
|
||||||
"stream_session_not_found",
|
"stream_session_not_found",
|
||||||
format!("stream session {} was not found", control.session_id),
|
format!("stream session {} was not found", control.session_id),
|
||||||
|
None,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -927,6 +933,7 @@ async fn handle_session_poll_call(
|
|||||||
&session.protocol_version,
|
&session.protocol_version,
|
||||||
"stream_session_expired",
|
"stream_session_expired",
|
||||||
format!("stream session {} has expired", control.session_id),
|
format!("stream session {} has expired", control.session_id),
|
||||||
|
None,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -937,6 +944,7 @@ async fn handle_session_poll_call(
|
|||||||
&session.protocol_version,
|
&session.protocol_version,
|
||||||
"stream_session_not_found",
|
"stream_session_not_found",
|
||||||
format!("stream session {} is not running", control.session_id),
|
format!("stream session {} is not running", control.session_id),
|
||||||
|
None,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -949,6 +957,7 @@ async fn handle_session_poll_call(
|
|||||||
&session.protocol_version,
|
&session.protocol_version,
|
||||||
"streaming_payload_error",
|
"streaming_payload_error",
|
||||||
error.to_string(),
|
error.to_string(),
|
||||||
|
None,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
@@ -1054,6 +1063,7 @@ async fn handle_session_stop_call(
|
|||||||
&session.protocol_version,
|
&session.protocol_version,
|
||||||
"stream_session_not_found",
|
"stream_session_not_found",
|
||||||
format!("stream session {} was not found", control.session_id),
|
format!("stream session {} was not found", control.session_id),
|
||||||
|
None,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
Err(error) => return internal_jsonrpc_error(message, error),
|
Err(error) => return internal_jsonrpc_error(message, error),
|
||||||
@@ -1066,6 +1076,7 @@ async fn handle_session_stop_call(
|
|||||||
&session.protocol_version,
|
&session.protocol_version,
|
||||||
"stream_session_not_found",
|
"stream_session_not_found",
|
||||||
format!("stream session {} was not found", control.session_id),
|
format!("stream session {} was not found", control.session_id),
|
||||||
|
None,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1107,6 +1118,7 @@ async fn handle_async_job_start_call(
|
|||||||
&session.protocol_version,
|
&session.protocol_version,
|
||||||
"streaming_config_error",
|
"streaming_config_error",
|
||||||
"streaming config is required for async job tools".to_owned(),
|
"streaming config is required for async job tools".to_owned(),
|
||||||
|
None,
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -1188,7 +1200,8 @@ async fn handle_async_job_start_call(
|
|||||||
result: None,
|
result: None,
|
||||||
error: Some(&json!({
|
error: Some(&json!({
|
||||||
"code": runtime_error_code(&error),
|
"code": runtime_error_code(&error),
|
||||||
"message": error.to_string()
|
"message": error.to_string(),
|
||||||
|
"context": runtime_error_context(&error)
|
||||||
})),
|
})),
|
||||||
expires_at: None,
|
expires_at: None,
|
||||||
updated_at: &finished_at,
|
updated_at: &finished_at,
|
||||||
@@ -1265,6 +1278,7 @@ async fn handle_async_job_status_call(
|
|||||||
&session.protocol_version,
|
&session.protocol_version,
|
||||||
"async_job_not_found",
|
"async_job_not_found",
|
||||||
format!("async job {} was not found", control.job_id),
|
format!("async job {} was not found", control.job_id),
|
||||||
|
None,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
Err(error) => return internal_jsonrpc_error(message, error),
|
Err(error) => return internal_jsonrpc_error(message, error),
|
||||||
@@ -1277,6 +1291,7 @@ async fn handle_async_job_status_call(
|
|||||||
&session.protocol_version,
|
&session.protocol_version,
|
||||||
"async_job_not_found",
|
"async_job_not_found",
|
||||||
format!("async job {} was not found", control.job_id),
|
format!("async job {} was not found", control.job_id),
|
||||||
|
None,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1328,6 +1343,7 @@ async fn handle_async_job_result_call(
|
|||||||
&session.protocol_version,
|
&session.protocol_version,
|
||||||
"async_job_not_found",
|
"async_job_not_found",
|
||||||
format!("async job {} was not found", control.job_id),
|
format!("async job {} was not found", control.job_id),
|
||||||
|
None,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
Err(error) => return internal_jsonrpc_error(message, error),
|
Err(error) => return internal_jsonrpc_error(message, error),
|
||||||
@@ -1340,6 +1356,7 @@ async fn handle_async_job_result_call(
|
|||||||
&session.protocol_version,
|
&session.protocol_version,
|
||||||
"async_job_not_found",
|
"async_job_not_found",
|
||||||
format!("async job {} was not found", control.job_id),
|
format!("async job {} was not found", control.job_id),
|
||||||
|
None,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1356,9 +1373,16 @@ async fn handle_async_job_result_call(
|
|||||||
&session.protocol_version,
|
&session.protocol_version,
|
||||||
"async_job_failed",
|
"async_job_failed",
|
||||||
job.error
|
job.error
|
||||||
.and_then(|value| value.get("message").cloned())
|
.as_ref()
|
||||||
.and_then(|value| value.as_str().map(ToOwned::to_owned))
|
.and_then(|value| value.get("message"))
|
||||||
|
.and_then(Value::as_str)
|
||||||
|
.map(ToOwned::to_owned)
|
||||||
.unwrap_or_else(|| "async job failed".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(
|
JobStatus::Cancelled => tool_error_response(
|
||||||
message,
|
message,
|
||||||
@@ -1366,6 +1390,7 @@ async fn handle_async_job_result_call(
|
|||||||
&session.protocol_version,
|
&session.protocol_version,
|
||||||
"async_job_cancelled",
|
"async_job_cancelled",
|
||||||
"async job was cancelled".to_owned(),
|
"async job was cancelled".to_owned(),
|
||||||
|
None,
|
||||||
),
|
),
|
||||||
_ => tool_error_response(
|
_ => tool_error_response(
|
||||||
message,
|
message,
|
||||||
@@ -1373,6 +1398,7 @@ async fn handle_async_job_result_call(
|
|||||||
&session.protocol_version,
|
&session.protocol_version,
|
||||||
"async_job_not_ready",
|
"async_job_not_ready",
|
||||||
"async job result is not ready".to_owned(),
|
"async job result is not ready".to_owned(),
|
||||||
|
None,
|
||||||
),
|
),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -1411,6 +1437,7 @@ async fn handle_async_job_cancel_call(
|
|||||||
&session.protocol_version,
|
&session.protocol_version,
|
||||||
"async_job_not_found",
|
"async_job_not_found",
|
||||||
format!("async job {} was not found", control.job_id),
|
format!("async job {} was not found", control.job_id),
|
||||||
|
None,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -1421,6 +1448,7 @@ async fn handle_async_job_cancel_call(
|
|||||||
&session.protocol_version,
|
&session.protocol_version,
|
||||||
"async_job_not_found",
|
"async_job_not_found",
|
||||||
format!("async job {} was not found", control.job_id),
|
format!("async job {} was not found", control.job_id),
|
||||||
|
None,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
Err(error) => return internal_jsonrpc_error(message, error),
|
Err(error) => return internal_jsonrpc_error(message, error),
|
||||||
@@ -1885,7 +1913,16 @@ fn tool_error_response(
|
|||||||
protocol_version: &str,
|
protocol_version: &str,
|
||||||
code: &str,
|
code: &str,
|
||||||
error_message: String,
|
error_message: String,
|
||||||
|
error_context: Option<Value>,
|
||||||
) -> Response {
|
) -> Response {
|
||||||
|
let mut error = json!({
|
||||||
|
"code": code,
|
||||||
|
"message": error_message
|
||||||
|
});
|
||||||
|
if let Some(context) = error_context {
|
||||||
|
error["context"] = context;
|
||||||
|
}
|
||||||
|
|
||||||
transport_response(
|
transport_response(
|
||||||
StatusCode::OK,
|
StatusCode::OK,
|
||||||
jsonrpc_result(
|
jsonrpc_result(
|
||||||
@@ -1898,10 +1935,7 @@ fn tool_error_response(
|
|||||||
}
|
}
|
||||||
],
|
],
|
||||||
"structuredContent": {
|
"structuredContent": {
|
||||||
"error": {
|
"error": error
|
||||||
"code": code,
|
|
||||||
"message": error_message
|
|
||||||
}
|
|
||||||
},
|
},
|
||||||
"isError": true
|
"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 {
|
fn hash_access_secret(secret: &str) -> String {
|
||||||
let digest = Sha256::digest(secret.as_bytes());
|
let digest = Sha256::digest(secret.as_bytes());
|
||||||
URL_SAFE_NO_PAD.encode(digest)
|
URL_SAFE_NO_PAD.encode(digest)
|
||||||
@@ -2320,3 +2396,60 @@ fn extract_origin(url: &str) -> Option<String> {
|
|||||||
|
|
||||||
None
|
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