Skip to content

Commit 465295e

Browse files
fix(billing-cost-management-mcp-server): explain empty-message DataUnavailableException (#4883)
Cost Explorer returns DataUnavailableException with an empty Message for Savings Plans and Reserved Instance reports when there is no data for the requested window and filter. The error handler replaced the empty message with "No error message provided", and the error contract mapped the code to do_not_retry with no hint, so a caller could not tell "no data" from a service failure. - handle_aws_error: a DataUnavailableException with an empty or missing message now says the service has no data for the request. A message the service does send is still passed through unchanged. - error_contract: DataUnavailableException gets a service-neutral hint (no data; the same request fails the same way; common causes; report no data, not a failure or a zero). It is keyed by error_type, so it also reaches Pricing Calculator, which models the same code. retry_strategy is unchanged (do_not_retry). - sp-performance, cost-explorer (getSavingsPlansUtilization) and ri-performance (utilization) descriptions: the data lag is 2-3 days counting today, not one, so excluding only today is not enough; a start_date in those days returns the error; a start_date at least 3 days before today is usually past that delay; a window that reaches into them succeeds but leaves them out; end_date may be today (it is exclusive); an unknown SAVINGS_PLANS_TYPE filter value returns DataUnavailableException rather than a ValidationException; report no data for the window, not zero. - Tests: the hint and message (empty and missing Message), pass-through of a non-empty message, a service-neutral hint, a guard that every mapped error type has a hint, and the description text.
1 parent a2f8a7a commit 465295e

10 files changed

Lines changed: 175 additions & 11 deletions

File tree

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

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -212,7 +212,7 @@ def _with_example_dates(description: str) -> str:
212212
Required: operation="getSavingsPlansUtilization", start_date, end_date
213213
Optional: granularity, filter
214214
Example: {"operation": "getSavingsPlansUtilization", "granularity": "MONTHLY"}
215-
Notes: Supports only DAILY and MONTHLY granularity. start_date must be within the last 13 months, and end_date must be before the current date. Data lags ~1 day: a window ending today (an incomplete UTC day) may come back empty or as a DataUnavailableException. A DataUnavailableException here means there is no Savings Plans data for THIS window (no active plans, or the window is outside the plan period) — report none/zero and do NOT retry the identical window; change the window (e.g. set end_date to yesterday) instead.
215+
Notes: Supports only DAILY and MONTHLY granularity. start_date must be within the last 13 months, and end_date is exclusive and may be today. Data for the most recent 2-3 days (counting today) is not processed yet: a start_date in those days returns a DataUnavailableException, and a window that reaches into them silently leaves them out; a start_date at least 3 days before today is usually past that delay. A DataUnavailableException here means there is no Savings Plans data for THIS window and filter (the window is too recent, no plan was active in it, or a filter value such as an unknown SAVINGS_PLANS_TYPE matches nothing) — report that no data is available for that window (not zero) and do NOT resend the identical request; correct an unknown filter value, and query an earlier window only if the question allows it.
216216
Returns: Savings Plans utilization for the specified time period
217217
218218
DIMENSION REFERENCE:

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

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -89,6 +89,14 @@
8989
dimensions and nests one level deep; multiple values for one dimension are OR'd.
9090
The SERVICE filter defaults to Amazon Elastic Compute Cloud - Compute when omitted.
9191
92+
DATA FRESHNESS: Reserved Instance utilization data for the most recent 2-3 days
93+
(counting today) is not processed yet. A get_reservation_utilization start_date inside
94+
those days returns a DataUnavailableException, which the service sends without a
95+
message. It means there is no data for that window, not a failure and not zero
96+
utilization: report that, and do not resend the identical request. A start_date at
97+
least 3 days before today is usually past that delay; query an earlier window only if
98+
the question allows it.
99+
92100
SCOPING LARGE QUERIES: a grouped or DAILY query over a wide window or many
93101
subscriptions can exceed the tool result size cap and return a ToolResultOversized
94102
error. Scope such queries with `filter` (e.g. one SERVICE or one SUBSCRIPTION_ID),

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

Lines changed: 13 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -64,12 +64,18 @@
6464
DATES AND DATA FRESHNESS (all three operations):
6565
- start_date must be within the last 13 months (the Cost Explorer lookback); an
6666
older start_date returns a ValidationException.
67-
- Savings Plans data lags roughly one day. A window whose end_date is today (an
68-
incomplete UTC day) commonly comes back as a DataUnavailableException with an
69-
empty message. Set end_date to exclude today (end_date is exclusive), then the
70-
window resolves. A DataUnavailableException means there is no data for THIS
71-
window (no active plans, or the window is outside the plan period); report none
72-
and do NOT retry the identical request — change the window instead.
67+
- Savings Plans data for the most recent 2-3 days (counting today) is not processed
68+
yet: yesterday is usually still missing, so excluding only today is not enough. A
69+
start_date inside those days returns a DataUnavailableException, which the service
70+
sends without a message. A window that only reaches into those days succeeds but
71+
silently leaves them out, so the last entry can end before end_date. A start_date at
72+
least 3 days before today is usually past that delay.
73+
- A DataUnavailableException means there is no data for THIS window and filter: the
74+
window is too recent, no plan was active in it, or a filter value matches nothing
75+
(an unknown SAVINGS_PLANS_TYPE, for example, returns this error, not a
76+
ValidationException). Report that no data is available for that window (not zero
77+
utilization or coverage) and do NOT resend the identical request. Correct an unknown
78+
filter value; query an earlier window only if the question allows it.
7379
7480
UTILIZATION DETAILS filters (get_savings_plans_utilization_details):
7581
- filter accepts LINKED_ACCOUNT, SAVINGS_PLAN_ARN, REGION, PAYMENT_OPTION,
@@ -126,7 +132,7 @@ async def sp_performance(
126132
ctx: The MCP context object
127133
operation: The operation to perform: 'get_savings_plans_coverage', 'get_savings_plans_utilization', or 'get_savings_plans_utilization_details'
128134
start_date: Start date in YYYY-MM-DD format (inclusive). Defaults to 30 days ago if not provided. Must be within the last 13 months.
129-
end_date: End date in YYYY-MM-DD format (exclusive). Defaults to today if not provided. Savings Plans data lags ~1 day; a window ending today may return an empty-message DataUnavailableException, so set end_date to exclude today.
135+
end_date: End date in YYYY-MM-DD format (exclusive). Defaults to today if not provided. Savings Plans data for the last 2-3 days (counting today) is not processed yet: a start_date in those days returns a DataUnavailableException, and a window reaching into them leaves them out.
130136
granularity: Time granularity of the data (DAILY or MONTHLY). Defaults to DAILY. For coverage, ignored when group_by is provided (granularity and group_by are mutually exclusive).
131137
metrics: List of metrics to retrieve as a JSON string. For coverage, only 'SpendCoveredBySavingsPlans' is valid.
132138
group_by: Optional grouping of results as a JSON string. For coverage, supports SERVICE, REGION, or INSTANCE_FAMILY (NOT LINKED_ACCOUNT). Each entry is {"Type": ..., "Key": ...}; there is no "Value" field.

‎src/billing-cost-management-mcp-server/awslabs/billing_cost_management_mcp_server/utilities/aws_service_base.py‎

Lines changed: 7 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -272,7 +272,13 @@ async def handle_aws_error(
272272
# Get AWS error details
273273
aws_error = error.response.get('Error', {})
274274
error_code = aws_error.get('Code', 'UnknownError')
275-
aws_message = aws_error.get('Message') or 'No error message provided'
275+
aws_message = aws_error.get('Message') or (
276+
# Cost Explorer sends DataUnavailableException with an empty Message.
277+
f'{service_name} has no data for this request '
278+
'(DataUnavailableException; the service sent no message).'
279+
if error_code == 'DataUnavailableException'
280+
else 'No error message provided'
281+
)
276282
request_id = error.response.get('ResponseMetadata', {}).get('RequestId', 'Unknown')
277283
http_status = error.response.get('ResponseMetadata', {}).get('HTTPStatusCode', 0)
278284

‎src/billing-cost-management-mcp-server/awslabs/billing_cost_management_mcp_server/utilities/error_contract.py‎

Lines changed: 11 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -18,7 +18,8 @@
1818
1919
- ``retry_same_request``: the failure is transient (throttling, connection, 5xx).
2020
- ``modify_request``: the arguments must change before trying again.
21-
- ``do_not_retry``: the failure cannot be fixed during this run (permissions, opt-in).
21+
- ``do_not_retry``: the failure cannot be fixed during this run (permissions, opt-in, no data
22+
for the request).
2223
2324
The strategy is derived from the existing ``error_type`` values, which are left
2425
unchanged. Unknown or missing types get ``do_not_retry``.
@@ -49,6 +50,14 @@
4950
'The account is not opted in or enrolled for this feature. Do not retry; '
5051
'tell the user how to enable it.'
5152
)
53+
# Mapped by error_type, so it reaches every service that raises the code (Cost Explorer,
54+
# Pricing Calculator): keep it service-neutral. Tool descriptions carry the specifics.
55+
_HINT_DATA_UNAVAILABLE = (
56+
'The service has no data for this request, so sending it again unchanged usually returns '
57+
'the same error. Common causes are a time period too recent to have been processed yet, or '
58+
'a filter value that matches nothing; the tool description may say when this happens. '
59+
'Report that no data is available, not a failure and not a zero.'
60+
)
5261

5362
# error_type -> (retry_strategy, hint). Covers both AWS error codes passed through by
5463
# handle_aws_error and the lowercase values set on local format_response errors.
@@ -101,7 +110,7 @@
101110
'OptInRequiredException': (DO_NOT_RETRY, _HINT_OPT_IN),
102111
'opt_in_required': (DO_NOT_RETRY, _HINT_OPT_IN),
103112
'enrollment_error': (DO_NOT_RETRY, _HINT_OPT_IN),
104-
'DataUnavailableException': (DO_NOT_RETRY, None),
113+
'DataUnavailableException': (DO_NOT_RETRY, _HINT_DATA_UNAVAILABLE),
105114
}
106115

107116
# Families of AWS codes matched by prefix, checked after the exact table.

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

Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2105,3 +2105,19 @@ def test_description_documents_max_items_paging():
21052105
assert 'next_token' in description
21062106
for removed in ('max_pages', 'max_results', 'automatic pagination', 'Pagination is automatic'):
21072107
assert removed not in description
2108+
2109+
2110+
@pytest.mark.asyncio
2111+
async def test_description_states_savings_plans_data_lag():
2112+
"""The getSavingsPlansUtilization notes state the 2-3 day lag and the unmatched-filter case."""
2113+
tool = await cost_explorer_server.get_tool('cost-explorer')
2114+
assert tool is not None and tool.description
2115+
assert 'Data lags ~1 day' not in tool.description
2116+
assert 'set end_date to yesterday' not in tool.description
2117+
assert 'end_date must be before the current date' not in tool.description
2118+
assert 'report none/zero' not in tool.description
2119+
assert 'Data for the most recent 2-3 days (counting today) is not processed yet' in (
2120+
tool.description
2121+
)
2122+
assert 'an unknown SAVINGS_PLANS_TYPE matches nothing' in tool.description
2123+
assert 'report that no data is available for that window (not zero)' in tool.description

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

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1355,3 +1355,13 @@ async def test_description_names_max_pages(self):
13551355
description = tool.description or ''
13561356
assert 'get_reservation_utilization takes max_pages (pages of 500 groups)' in description
13571357
assert 'rounded up' not in description
1358+
1359+
1360+
@pytest.mark.asyncio
1361+
async def test_description_states_utilization_data_lag():
1362+
"""Recent-day utilization windows return an empty-message DataUnavailableException."""
1363+
description = ' '.join((await _ri_description()).split())
1364+
assert 'utilization data for the most recent 2-3 days (counting today)' in description
1365+
assert 'which the service sends without a message' in description
1366+
assert 'not a failure and not zero utilization' in description
1367+
assert 'do not resend the identical request' in description

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

Lines changed: 22 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1577,6 +1577,28 @@ async def _description() -> str:
15771577
assert tool is not None and tool.description
15781578
return tool.description
15791579

1580+
async def test_states_the_data_lag_and_data_unavailable_causes(self):
1581+
"""The lag is 2-3 days (excluding today is not enough), and unmatched filters fail too."""
1582+
description = ' '.join((await self._description()).split())
1583+
assert 'lags roughly one day' not in description
1584+
assert 'then the window resolves' not in description
1585+
assert 'most recent 2-3 days (counting today) is not processed yet' in description
1586+
assert 'excluding only today is not enough' in description
1587+
assert 'succeeds but silently leaves them out' in description
1588+
assert (
1589+
'A start_date at least 3 days before today is usually past that delay' in description
1590+
)
1591+
assert 'an unknown SAVINGS_PLANS_TYPE, for example, returns this error' in description
1592+
assert 'not zero utilization or coverage' in description
1593+
1594+
async def test_end_date_doc_states_the_data_lag(self):
1595+
"""The end_date arg doc no longer says excluding today is enough."""
1596+
tool = await sp_performance_server.get_tool('sp-performance')
1597+
assert tool is not None
1598+
end_date_doc = tool.parameters['properties']['end_date'].get('description', '')
1599+
assert 'lags ~1 day' not in end_date_doc
1600+
assert 'last 2-3 days (counting today) is not processed yet' in end_date_doc
1601+
15801602
async def test_lists_utilization_details_filter_dimensions(self):
15811603
"""Every GetSavingsPlansUtilizationDetails filter dimension is named."""
15821604
description = await self._description()

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

Lines changed: 58 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -332,6 +332,64 @@ async def test_handle_client_error_with_empty_message(self):
332332

333333
assert result['message'] == 'No error message provided'
334334

335+
@pytest.mark.asyncio
336+
async def test_data_unavailable_with_empty_message_says_no_data(self):
337+
"""Cost Explorer's empty-message DataUnavailableException is reported as no data."""
338+
from awslabs.billing_cost_management_mcp_server.utilities.error_contract import (
339+
add_retry_guidance,
340+
)
341+
from botocore.exceptions import ClientError
342+
343+
error = ClientError(
344+
{
345+
'Error': {'Code': 'DataUnavailableException', 'Message': ''},
346+
'ResponseMetadata': {'HTTPStatusCode': 400},
347+
},
348+
'GetSavingsPlansUtilization',
349+
)
350+
351+
result = add_retry_guidance(
352+
await handle_aws_error(
353+
AsyncMock(), error, 'get_savings_plans_utilization', 'Cost Explorer'
354+
)
355+
)
356+
357+
assert result['error_type'] == 'DataUnavailableException'
358+
assert result['message'] == (
359+
'Cost Explorer has no data for this request '
360+
'(DataUnavailableException; the service sent no message).'
361+
)
362+
assert result['retry_strategy'] == 'do_not_retry'
363+
assert 'The service has no data for this request' in result['hint']
364+
365+
@pytest.mark.asyncio
366+
async def test_data_unavailable_without_message_key_says_no_data(self):
367+
"""A DataUnavailableException with no Message key at all is reported the same way."""
368+
from botocore.exceptions import ClientError
369+
370+
error = ClientError({'Error': {'Code': 'DataUnavailableException'}}, 'GetCostForecast')
371+
372+
result = await handle_aws_error(AsyncMock(), error, 'getCostForecast', 'Cost Explorer')
373+
374+
assert result['message'] == (
375+
'Cost Explorer has no data for this request '
376+
'(DataUnavailableException; the service sent no message).'
377+
)
378+
379+
@pytest.mark.asyncio
380+
async def test_data_unavailable_keeps_a_service_message(self):
381+
"""A DataUnavailableException that carries a message passes it through unchanged."""
382+
from botocore.exceptions import ClientError
383+
384+
error = ClientError(
385+
{'Error': {'Code': 'DataUnavailableException', 'Message': 'No data yet.'}},
386+
'GetCostAndUsage',
387+
)
388+
389+
result = await handle_aws_error(AsyncMock(), error, 'getCostAndUsage', 'Cost Explorer')
390+
391+
assert result['message'] == 'No data yet.'
392+
335393
@pytest.mark.asyncio
336394
async def test_handle_value_error(self):
337395
"""Test handling ValueError."""

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

Lines changed: 29 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -19,6 +19,8 @@
1919
format_response,
2020
)
2121
from awslabs.billing_cost_management_mcp_server.utilities.error_contract import (
22+
_ERROR_TYPE_GUIDANCE,
23+
_ERROR_TYPE_PREFIX_GUIDANCE,
2224
DO_NOT_RETRY,
2325
MODIFY_REQUEST,
2426
RETRY_SAME_REQUEST,
@@ -107,6 +109,33 @@ def test_account_state_is_not_retried(self, error_type):
107109
"""Opt-in and data availability failures cannot be fixed during the run."""
108110
assert classify_error(error_type)[0] == DO_NOT_RETRY
109111

112+
def test_data_unavailable_hint_says_no_data_not_failure(self):
113+
"""DataUnavailableException tells the caller there is no data, and the usual causes."""
114+
strategy, hint = classify_error('DataUnavailableException')
115+
assert strategy == DO_NOT_RETRY
116+
assert hint is not None
117+
assert 'The service has no data for this request' in hint
118+
assert 'too recent to have been processed yet' in hint
119+
assert 'a filter value that matches nothing' in hint
120+
assert 'not a failure and not a zero' in hint
121+
122+
def test_data_unavailable_hint_is_service_neutral(self):
123+
"""Pricing Calculator raises the same code, so the hint names no Cost Explorer report."""
124+
hint = classify_error('DataUnavailableException')[1] or ''
125+
for specific in ('Savings Plans', 'Reserved Instance', '2-3 days'):
126+
assert specific not in hint
127+
128+
@pytest.mark.parametrize(
129+
'error_type, guidance',
130+
[*_ERROR_TYPE_GUIDANCE.items(), *_ERROR_TYPE_PREFIX_GUIDANCE],
131+
)
132+
def test_every_mapped_error_type_has_a_hint(self, error_type, guidance):
133+
"""A mapped error type always carries recovery guidance, not just a strategy.
134+
135+
Hints are keyed by error_type, so each must hold for every service that raises it.
136+
"""
137+
assert guidance[1], f'{error_type} is mapped without a hint'
138+
110139
@pytest.mark.parametrize(
111140
'error_type',
112141
[

0 commit comments

Comments
 (0)