Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
25 changes: 22 additions & 3 deletions crates/server/src/response.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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"}"#;
Expand All @@ -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!({
Expand All @@ -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() {
Expand Down Expand Up @@ -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.
Expand Down
57 changes: 57 additions & 0 deletions crates/server/src/response_tests.rs
Original file line number Diff line number Diff line change
@@ -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<String> {
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<String> = 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:?}");
}
}
2 changes: 1 addition & 1 deletion docs/design/01-requirements.md
Original file line number Diff line number Diff line change
Expand Up @@ -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)

Expand Down
112 changes: 112 additions & 0 deletions tests/test_error_message_key.py
Original file line number Diff line number Diff line change
@@ -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"]
Loading