diff --git a/crates/server/src/response.rs b/crates/server/src/response.rs index af495d1b4..32c9797b9 100755 --- a/crates/server/src/response.rs +++ b/crates/server/src/response.rs @@ -8,6 +8,10 @@ use axum::response::{IntoResponse, Response}; use extenddb_core::error::DynamoDbError; use serde_json::Value; +#[cfg(test)] +#[path = "response_tests.rs"] +mod tests; + /// Hardcoded fallback body for when response serialization itself fails. const SERIALIZATION_FAILURE_BODY: &[u8] = br#"{"__type":"com.amazonaws.dynamodb.v20120810#InternalServerError","message":"Internal error"}"#; @@ -33,8 +37,9 @@ pub(crate) fn success_response(body: &Value, request_id: &str) -> Response { .into_response() } -/// REQ-ERR-001: `__type` with prefix. REQ-ERR-002: message field. -/// REQ-ERR-003: Omit `message` when empty (real `DynamoDB` behavior, verified 2026-05-04). +/// REQ-ERR-001: `__type` with prefix. REQ-ERR-002: message field, under the +/// key [`message_key`] picks. REQ-ERR-003: Omit the message field when empty +/// (real `DynamoDB` behavior, verified 2026-05-04). pub(crate) fn error_response(error: &DynamoDbError, request_id: &str) -> Response { // Fix #14: Use full_error_type() which includes the prefix let mut body = serde_json::json!({ @@ -44,7 +49,7 @@ pub(crate) fn error_response(error: &DynamoDbError, request_id: &str) -> Respons // UnknownOperationException and other errors with no message). let msg = error.message(); if !msg.is_empty() { - body["message"] = serde_json::Value::String(msg.to_owned()); + body[message_key(error)] = serde_json::Value::String(msg.to_owned()); } // TransactionCanceledException includes per-item CancellationReasons if let Some(reasons) = error.cancellation_reasons() { @@ -72,6 +77,20 @@ pub(crate) fn error_response(error: &DynamoDbError, request_id: &str) -> Respons .into_response() } +/// The key that carries the error message. `DynamoDB` sends `Message` on +/// `TransactionCanceledException` and `IdempotentParameterMismatchException` +/// and `message` on every other error measured (us-east-1, raw bodies, +/// 2026-10-29 and 2026-11-03). `TransactionInProgressException` also models +/// its member as `Message`, but it was not measured and ExtendDB does not +/// raise it, so it keeps `message` here. +fn message_key(error: &DynamoDbError) -> &'static str { + match error { + DynamoDbError::TransactionCanceledException { .. } + | DynamoDbError::IdempotentParameterMismatchException(_) => "Message", + _ => "message", + } +} + /// Classify and record error metrics for a failed `DynamoDB` request. /// /// M-2: `ServiceUnavailable` is a system error (503), not a user error. diff --git a/crates/server/src/response_tests.rs b/crates/server/src/response_tests.rs new file mode 100644 index 000000000..32924969b --- /dev/null +++ b/crates/server/src/response_tests.rs @@ -0,0 +1,57 @@ +// Copyright 2026 ExtendDB contributors +// SPDX-License-Identifier: Apache-2.0 + +//! Tests of the error bodies [`super::error_response`] sends. + +use extenddb_core::error::DynamoDbError; +use extenddb_core::types::CancellationReason; + +use super::error_response; + +/// The top-level keys of the body `error_response` sends for `error`. +async fn body_keys(error: &DynamoDbError) -> Vec { + let response = error_response(error, "request-1"); + let bytes = axum::body::to_bytes(response.into_body(), usize::MAX) + .await + .expect("body"); + let body: serde_json::Value = serde_json::from_slice(&bytes).expect("json body"); + let mut keys: Vec = body.as_object().expect("object").keys().cloned().collect(); + keys.sort_unstable(); + keys +} + +/// The two errors measured with `Message` carry it under that key, as the +/// service sends them; the errors measured with `message` keep it. +#[tokio::test] +async fn the_message_key_follows_the_service_per_error() { + let cancelled = DynamoDbError::TransactionCanceledException { + message: "Transaction cancelled, please refer cancellation reasons for specific \ + reasons [ConditionalCheckFailed]" + .to_owned(), + cancellation_reasons: vec![CancellationReason { + code: "ConditionalCheckFailed".to_owned(), + message: Some("The conditional request failed".to_owned()), + item: None, + }], + }; + assert_eq!( + body_keys(&cancelled).await, + ["CancellationReasons", "Message", "__type"] + ); + let mismatch = DynamoDbError::IdempotentParameterMismatchException( + "Specified idempotent token was used with different request parameters within \ + the idempotency window" + .to_owned(), + ); + assert_eq!(body_keys(&mismatch).await, ["Message", "__type"]); + for other in [ + DynamoDbError::ValidationException("One or more parameter values were invalid".into()), + DynamoDbError::ConditionalCheckFailedException( + "The conditional request failed".into(), + None, + ), + DynamoDbError::TransactionConflictException("Transaction is ongoing".into()), + ] { + assert_eq!(body_keys(&other).await, ["__type", "message"], "{other:?}"); + } +} diff --git a/docs/design/01-requirements.md b/docs/design/01-requirements.md index 394412bd2..e8d756e12 100755 --- a/docs/design/01-requirements.md +++ b/docs/design/01-requirements.md @@ -98,7 +98,7 @@ Error responses must be byte-for-byte compatible with DynamoDB: **Requirements:** - REQ-ERR-001: Error JSON must contain `__type` field with `com.amazonaws.dynamodb.v20120810#` prefix -- REQ-ERR-002: Error JSON must contain `message` field with descriptive text +- REQ-ERR-002: Error JSON must contain a message field with descriptive text, under the key DynamoDB uses: `Message` on `TransactionCanceledException` and `IdempotentParameterMismatchException`, `message` on the other errors measured - REQ-ERR-003: HTTP status codes must match DynamoDB exactly (see error catalog below) - REQ-ERR-004: SDK retry behavior must work identically (throttling errors return 400, not 429) diff --git a/tests/test_error_message_key.py b/tests/test_error_message_key.py new file mode 100644 index 000000000..8d0b422c2 --- /dev/null +++ b/tests/test_error_message_key.py @@ -0,0 +1,112 @@ +# Copyright 2026 ExtendDB contributors +# SPDX-License-Identifier: Apache-2.0 + +"""The key that carries an error's message in the raw response body. + +Amazon DynamoDB names it ``Message`` on TransactionCanceledException and +IdempotentParameterMismatchException, whose modeled member is ``Message``, +and ``message`` on the other errors (raw bodies measured in us-east-1 on +2026-10-29 and 2026-11-03). botocore reads either key, so these tests read +the raw body from botocore's ``before-parse`` event before it normalizes it. + +Dual-target against Amazon DynamoDB and extenddb. +""" + +from __future__ import annotations + +import json +import uuid + +import pytest +from botocore.exceptions import ClientError + +from conftest import scoped_table + + +@pytest.fixture(scope="module") +def hash_table(dynamodb_client): + """Hash-only table for the module, deleted on teardown.""" + with scoped_table(dynamodb_client) as name: + yield name + + +def _error_code_and_keys(client, operation: str, call) -> tuple[str, list[str]]: + """Run ``call``, which must fail, and return the error code and the sorted + top-level keys of the raw response body.""" + raw: list[bytes] = [] + + def capture(response_dict, **_kwargs): + raw.append(response_dict["body"]) + + event = f"before-parse.dynamodb.{operation}" + client.meta.events.register(event, capture) + try: + with pytest.raises(ClientError) as exc_info: + call() + finally: + client.meta.events.unregister(event, capture) + return exc_info.value.response["Error"]["Code"], sorted(json.loads(raw[-1])) + + +def test_transaction_canceled_carries_message_under_capital_m(dynamodb_client, hash_table): + code, keys = _error_code_and_keys( + dynamodb_client, + "TransactWriteItems", + lambda: dynamodb_client.transact_write_items( + TransactItems=[ + { + "ConditionCheck": { + "TableName": hash_table, + "Key": {"pk": {"S": f"missing-{uuid.uuid4().hex}"}}, + "ConditionExpression": "attribute_exists(pk)", + } + } + ] + ), + ) + assert code == "TransactionCanceledException" + assert keys == ["CancellationReasons", "Message", "__type"] + + +def test_idempotent_parameter_mismatch_carries_message_under_capital_m( + dynamodb_client, hash_table +): + token = uuid.uuid4().hex + pk = f"token-{token}" + + def put(value: str): + return dynamodb_client.transact_write_items( + ClientRequestToken=token, + TransactItems=[ + {"Put": {"TableName": hash_table, "Item": {"pk": {"S": pk}, "v": {"S": value}}}} + ], + ) + + put("first") + code, keys = _error_code_and_keys(dynamodb_client, "TransactWriteItems", lambda: put("second")) + assert code == "IdempotentParameterMismatchException" + assert keys == ["Message", "__type"] + + +def test_conditional_check_failed_carries_message_under_lower_case(dynamodb_client, hash_table): + code, keys = _error_code_and_keys( + dynamodb_client, + "PutItem", + lambda: dynamodb_client.put_item( + TableName=hash_table, + Item={"pk": {"S": f"missing-{uuid.uuid4().hex}"}}, + ConditionExpression="attribute_exists(pk)", + ), + ) + assert code == "ConditionalCheckFailedException" + assert keys == ["__type", "message"] + + +def test_validation_error_carries_message_under_lower_case(dynamodb_client, hash_table): + code, keys = _error_code_and_keys( + dynamodb_client, + "GetItem", + lambda: dynamodb_client.get_item(TableName=hash_table, Key={"pk": {"N": "1"}}), + ) + assert code == "ValidationException" + assert keys == ["__type", "message"]