|
20 | 20 |
|
21 | 21 | import asyncio |
22 | 22 | import contextlib |
| 23 | +import inspect |
| 24 | +import warnings |
23 | 25 | from importlib.metadata import PackageNotFoundError |
24 | 26 | from unittest.mock import AsyncMock, patch |
25 | 27 |
|
|
43 | 45 | from mlbstatsapi.mlb_dataadapter import PACKAGE_DISTRIBUTION_NAME # noqa: E402 |
44 | 46 |
|
45 | 47 | from http_contract_support import ( # noqa: E402 |
| 48 | + HTTP_REASON_BY_STATUS, |
46 | 49 | RETRYABLE_STATUS_CODES, |
47 | 50 | SERVER_ERRORS, |
48 | 51 | assert_library_retry_policy, |
|
61 | 64 | MOCKED_PACKAGE_VERSION = "9.8.7" |
62 | 65 | MOCKED_USER_AGENT = f"python-mlb-statsapi/{MOCKED_PACKAGE_VERSION}" |
63 | 66 |
|
| 67 | +# Obvious sentinels, so a leak into a compatibility warning is unmistakable. |
| 68 | +SECRET_BODY_MARKER = "SUPER_SECRET_RESPONSE" |
| 69 | +SECRET_HEADER_MARKER = "SUPER_SECRET_HEADER" |
| 70 | + |
64 | 71 |
|
65 | 72 | # Adapters built by _owned_adapter(); run_async() closes them inside the same |
66 | 73 | # event loop that used them, so no AsyncClient is left open by a test. |
@@ -683,6 +690,140 @@ async def scenario(): |
683 | 690 | assert handler.call_count == 1 |
684 | 691 |
|
685 | 692 |
|
| 693 | +# --- Final non-404 4xx contract --- |
| 694 | + |
| 695 | + |
| 696 | +def test_final_non_404_client_error_raises_under_strict_http(): |
| 697 | + """Strict mode raises MlbHttpError with the sync structured context. |
| 698 | +
|
| 699 | + test_400_is_not_retried already proves a 4xx is final on the first |
| 700 | + response; this asserts the #298 decision-table outcome for an explicit |
| 701 | + strict_http=True adapter, including the structured error context. |
| 702 | + """ |
| 703 | + handler = _ScriptedHandler(_response(403)) |
| 704 | + |
| 705 | + async def scenario(): |
| 706 | + adapter = _owned_adapter(handler, strict_http=True) |
| 707 | + with pytest.raises(MlbHttpError) as exc_info: |
| 708 | + await adapter.get(endpoint="sports") |
| 709 | + return exc_info.value |
| 710 | + |
| 711 | + error = run_async(scenario()) |
| 712 | + assert error.status_code == 403 |
| 713 | + assert error.reason == HTTP_REASON_BY_STATUS[403] |
| 714 | + assert error.method == "GET" |
| 715 | + assert error.url == f"{BASE_URL}sports" |
| 716 | + assert handler.call_count == 1 |
| 717 | + |
| 718 | + |
| 719 | +def test_final_non_404_client_error_returns_empty_result_in_compatibility_mode(): |
| 720 | + """strict_http=False suppresses a non-404 4xx into a warned empty result.""" |
| 721 | + handler = _ScriptedHandler(_response(403, text='{"message": "denied"}')) |
| 722 | + |
| 723 | + async def scenario(): |
| 724 | + adapter = _owned_adapter(handler, strict_http=False) |
| 725 | + with pytest.warns(MlbHttpCompatibilityWarning) as warning_info: |
| 726 | + result = await adapter.get(endpoint="sports") |
| 727 | + return result, [str(warning.message) for warning in warning_info] |
| 728 | + |
| 729 | + result, messages = run_async(scenario()) |
| 730 | + assert result.status_code == 403 |
| 731 | + assert result.message == HTTP_REASON_BY_STATUS[403] |
| 732 | + assert result.data == {} |
| 733 | + assert len(messages) == 1 |
| 734 | + assert "403" in messages[0] |
| 735 | + assert f"{BASE_URL}sports" in messages[0] |
| 736 | + assert handler.call_count == 1 |
| 737 | + |
| 738 | + |
| 739 | +# --- Compatibility warning safety --- |
| 740 | + |
| 741 | + |
| 742 | +def test_compatibility_warning_does_not_leak_response_body_or_headers(): |
| 743 | + """Response bodies and headers must never reach the warning message.""" |
| 744 | + handler = _ScriptedHandler( |
| 745 | + _response( |
| 746 | + 403, |
| 747 | + headers={"X-Debug-Token": SECRET_HEADER_MARKER}, |
| 748 | + text=f'{{"message": "{SECRET_BODY_MARKER}"}}', |
| 749 | + ), |
| 750 | + ) |
| 751 | + |
| 752 | + async def scenario(): |
| 753 | + adapter = _owned_adapter(handler, strict_http=False) |
| 754 | + with pytest.warns(MlbHttpCompatibilityWarning) as warning_info: |
| 755 | + await adapter.get(endpoint="sports") |
| 756 | + return [str(warning.message) for warning in warning_info] |
| 757 | + |
| 758 | + messages = run_async(scenario()) |
| 759 | + assert len(messages) == 1 |
| 760 | + assert SECRET_BODY_MARKER not in messages[0] |
| 761 | + assert SECRET_HEADER_MARKER not in messages[0] |
| 762 | + assert "X-Debug-Token" not in messages[0] |
| 763 | + |
| 764 | + |
| 765 | +def test_compatibility_warning_points_to_awaiting_caller_line(): |
| 766 | + """The warning is attributed to the awaiting caller, not package internals. |
| 767 | +
|
| 768 | + Mirrors test_http_warnings.test_compatibility_warning_points_to_direct_ |
| 769 | + adapter_caller_line for an awaited call. |
| 770 | + """ |
| 771 | + handler = _ScriptedHandler(_response(403)) |
| 772 | + |
| 773 | + async def scenario(): |
| 774 | + adapter = _owned_adapter(handler, strict_http=False) |
| 775 | + with warnings.catch_warnings(record=True) as caught: |
| 776 | + warnings.simplefilter("always", MlbHttpCompatibilityWarning) |
| 777 | + expected_lineno = inspect.currentframe().f_lineno + 1 |
| 778 | + await adapter.get(endpoint="sports") |
| 779 | + return caught, expected_lineno |
| 780 | + |
| 781 | + caught, expected_lineno = run_async(scenario()) |
| 782 | + compatibility = [ |
| 783 | + warning |
| 784 | + for warning in caught |
| 785 | + if issubclass(warning.category, MlbHttpCompatibilityWarning) |
| 786 | + ] |
| 787 | + assert len(compatibility) == 1 |
| 788 | + assert compatibility[0].filename == __file__ |
| 789 | + assert compatibility[0].lineno == expected_lineno |
| 790 | + |
| 791 | + |
| 792 | +# --- Structured MlbHttpError context --- |
| 793 | + |
| 794 | + |
| 795 | +def test_error_context_extraction_failure_does_not_replace_http_error(): |
| 796 | + """A broken optional-context extraction must not hide the HTTP failure. |
| 797 | +
|
| 798 | + The best-effort response context is a debugging aid, so a failure while |
| 799 | + collecting it degrades that one field instead of raising something other |
| 800 | + than the original MlbHttpError. |
| 801 | + """ |
| 802 | + handler = _ScriptedHandler( |
| 803 | + _response(500, text='{"message": "Internal error occurred"}'), |
| 804 | + ) |
| 805 | + |
| 806 | + async def scenario(): |
| 807 | + adapter = _owned_adapter(handler) |
| 808 | + with patch( |
| 809 | + "mlbstatsapi._http._extract_error_response_data", |
| 810 | + side_effect=RuntimeError("error-context extraction failed"), |
| 811 | + ): |
| 812 | + with patch(SLEEP_TARGET, new_callable=AsyncMock): |
| 813 | + with pytest.raises(MlbHttpError) as exc_info: |
| 814 | + await adapter.get(endpoint="sports") |
| 815 | + return exc_info.value |
| 816 | + |
| 817 | + error = run_async(scenario()) |
| 818 | + assert error.status_code == 500 |
| 819 | + assert error.reason == HTTP_REASON_BY_STATUS[500] |
| 820 | + assert error.method == "GET" |
| 821 | + assert error.url == f"{BASE_URL}sports" |
| 822 | + assert error.response_data is None |
| 823 | + # The independent excerpt extraction still succeeds. |
| 824 | + assert "Internal error occurred" in (error.body_excerpt or "") |
| 825 | + |
| 826 | + |
686 | 827 | # --- Versioned User-Agent --- |
687 | 828 |
|
688 | 829 |
|
|
0 commit comments