Skip to content

Commit 1601a42

Browse files
fix(billing-cost-management-mcp-server): map business-support's rejected next_token to invalid_next_token (#4953)
ListBusinessSupportAccountCharges and ListBusinessSupportSubscriptionHistory reject a bad caller next_token with HTTP 400 ValidationException: "Invalid nextToken" for a well-formed token, or a constraint message naming nextToken for one that breaks the pattern or length. Neither PageSpec named that code, so the rejection came back as a plain validation error with no hint to restart without next_token. Both specs now set caller_token_error_codes={'ValidationException'}, like the other Billing paginators. The message must still name the token, so a ValidationException about another argument is unchanged.
1 parent 8ec81ab commit 1601a42

3 files changed

Lines changed: 84 additions & 0 deletions

File tree

‎src/billing-cost-management-mcp-server/awslabs/billing_cost_management_mcp_server/tools/business_support_operations.py‎

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -71,6 +71,10 @@
7171

7272
# Note: ``'nextToken'`` below is the Billing API's pagination field name, not a
7373
# credential; the same PageSpec shape is used by enterprise_support_operations.
74+
# Both operations reject a bad caller token with HTTP 400 ValidationException
75+
# and a message naming nextToken ("Invalid nextToken", or a constraint message
76+
# for a malformed one), so that code counts as a rejected token on the caller's
77+
# own next_token.
7478

7579
# How ListBusinessSupportAccountCharges pages: the per-account charge rows come
7680
# back under ``accountCharges``, continued by ``nextToken``, at up to 100 per
@@ -81,6 +85,7 @@
8185
token_key_in_response='nextToken',
8286
page_max=100,
8387
page_size_param='maxResults',
88+
caller_token_error_codes=frozenset({'ValidationException'}),
8489
)
8590

8691
# How ListBusinessSupportSubscriptionHistory pages: the contract rows come back
@@ -92,6 +97,7 @@
9297
token_key_in_response='nextToken',
9398
page_max=100,
9499
page_size_param='maxResults',
100+
caller_token_error_codes=frozenset({'ValidationException'}),
95101
)
96102

97103

‎src/billing-cost-management-mcp-server/tests/tools/test_business_support_operations.py‎

Lines changed: 67 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -24,6 +24,7 @@
2424
list_subscription_history,
2525
)
2626
from awslabs.billing_cost_management_mcp_server.utilities import sql_utils
27+
from awslabs.billing_cost_management_mcp_server.utilities.error_contract import add_retry_guidance
2728
from botocore.exceptions import ClientError, EndpointConnectionError
2829
from botocore.stub import Stubber
2930
from datetime import datetime, timezone
@@ -976,6 +977,72 @@ async def test_subscription_history_with_window(self, mock_context):
976977
assert result['data']['pagination']['items_returned'] == 1
977978

978979

980+
# The service rejects a bad caller token with ValidationException (HTTP 400) for
981+
# both operations: "Invalid nextToken" for a well-formed token, a constraint
982+
# message naming nextToken for a malformed one. The PageSpecs must name the code.
983+
INVALID_TOKEN_CASES = (
984+
(list_account_charges, 'list_business_support_account_charges', {'billing_month': '2026-06'}),
985+
(list_subscription_history, 'list_business_support_subscription_history', {}),
986+
)
987+
988+
989+
async def _run_with_stubbed_error(mock_context, handler, operation, message, **handler_kwargs):
990+
"""Run a handler through the real paginator against a client that raises ValidationException."""
991+
client = boto3.client('billing', region_name='us-east-1')
992+
stubber = Stubber(client)
993+
stubber.add_client_error(
994+
operation,
995+
service_error_code='ValidationException',
996+
service_message=message,
997+
http_status_code=400,
998+
)
999+
stubber.activate()
1000+
with patch(CREATE_CLIENT_PATH, return_value=client):
1001+
result = await handler(mock_context, **handler_kwargs)
1002+
stubber.assert_no_pending_responses()
1003+
return result
1004+
1005+
1006+
class TestRejectedCallerToken:
1007+
"""A rejected caller next_token is reported as invalid_next_token, not a generic error."""
1008+
1009+
@pytest.mark.asyncio
1010+
@pytest.mark.parametrize('handler,operation,kwargs', INVALID_TOKEN_CASES)
1011+
@pytest.mark.parametrize(
1012+
'message',
1013+
[
1014+
'Invalid nextToken',
1015+
"1 validation error detected: Value at 'nextToken' failed to satisfy constraint: "
1016+
'Member must satisfy regular expression pattern: [-a-zA-Z0-9+=/_]+',
1017+
],
1018+
)
1019+
async def test_rejected_caller_token_is_invalid_next_token(
1020+
self, mock_context, handler, operation, kwargs, message
1021+
):
1022+
"""A ValidationException naming the caller's token maps to invalid_next_token."""
1023+
result = await _run_with_stubbed_error(
1024+
mock_context, handler, operation, message, next_token='stale', **kwargs
1025+
)
1026+
1027+
assert result['status'] == 'error'
1028+
assert result['error_type'] == 'invalid_next_token'
1029+
# The server's error middleware adds the hint the caller sees.
1030+
assert 'without next_token' in add_retry_guidance(result)['hint']
1031+
1032+
@pytest.mark.asyncio
1033+
@pytest.mark.parametrize('handler,operation,kwargs', INVALID_TOKEN_CASES)
1034+
async def test_validation_error_without_caller_token_is_unchanged(
1035+
self, mock_context, handler, operation, kwargs
1036+
):
1037+
"""Without a caller next_token the same code stays the service's validation error."""
1038+
result = await _run_with_stubbed_error(
1039+
mock_context, handler, operation, 'Invalid nextToken', **kwargs
1040+
)
1041+
1042+
assert result['status'] == 'error'
1043+
assert result['error_type'] != 'invalid_next_token'
1044+
1045+
9791046
# ---------------------------------------------------------------------------
9801047
# Real SQL offload (convert_response_if_needed is NOT mocked)
9811048
# ---------------------------------------------------------------------------

‎src/billing-cost-management-mcp-server/tests/utilities/test_caller_token_error_codes.py‎

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -27,6 +27,7 @@
2727
bcm_pricing_calculator_tools,
2828
billing_conductor_operations,
2929
billing_preferences_operations,
30+
business_support_operations,
3031
bvs_operations,
3132
compute_optimizer_automation_operations,
3233
cost_optimization_hub_helpers,
@@ -188,6 +189,16 @@
188189
VALIDATION,
189190
id='COH_LIST_EFFICIENCY_METRICS',
190191
),
192+
pytest.param(
193+
business_support_operations.LIST_ACCOUNT_CHARGES_PAGE_SPEC,
194+
VALIDATION,
195+
id='LIST_ACCOUNT_CHARGES_PAGE_SPEC',
196+
),
197+
pytest.param(
198+
business_support_operations.LIST_SUBSCRIPTION_HISTORY_PAGE_SPEC,
199+
VALIDATION,
200+
id='LIST_SUBSCRIPTION_HISTORY_PAGE_SPEC',
201+
),
191202
pytest.param(
192203
credits_operations.GET_CREDIT_ALLOCATION_HISTORY_PAGE_SPEC,
193204
VALIDATION,

0 commit comments

Comments
 (0)