Loading README.md +7 −0 Changes for README.md: 7 added lines, 0 removed lines. Original line number Diff line number Diff line Loading @@ -291,3 +291,10 @@ Requests outside the configured list fail before HTTP. Other control paths keep their own lists, and backend refusals remain typed execution failures. The existing notification destination requirement still applies; configuring profiles does not implement a receiver or establish data-plane QoS guarantees. Network DomainExecutionFailed errors include the existing allowlisted backend status/reason diagnostics in details when available. HTTP400 carries `{"backend_status": 400, "reason": "bad_request"}`; transport errors carry a safe reason. Raw response bodies, URLs and exception text are not copied. Error classes, retryable flags and wrapped HTTP404 classification are unchanged. SRM's existing bridge can preserve these details without a new contract field. src/openop_tfsdk/_diagnostics.py +1 −1 Changes for src/openop_tfsdk/_diagnostics.py: 1 added line, 1 removed line. Original line number Diff line number Diff line """Small log-only summaries; never copy backend text, payloads or input values.""" """Allowlisted diagnostic summaries; never copy backend text, payloads or input values.""" from typing import get_args Loading src/openop_tfsdk/contract/adapters/network.py +6 −4 Changes for src/openop_tfsdk/contract/adapters/network.py: 6 added lines, 4 removed lines. Original line number Diff line number Diff line Loading @@ -409,23 +409,25 @@ class NetworkCapabilityAdapter: if isinstance(cause, requests.HTTPError) and cause.response is not None: if cause.response.status_code == 404: raise ResourceNotFound(f"{method}: not found") from error diagnostics = backend_diagnostics(error) log.error( "backend_call_failed", method=method, error_code=DomainExecutionFailed.error_code, error_type=type(error).__name__, **backend_diagnostics(error), **diagnostics, ) raise DomainExecutionFailed(f"{method} failed") from error raise DomainExecutionFailed(f"{method} failed", details=diagnostics or None) from error except Exception as error: diagnostics = backend_diagnostics(error) log.error( "backend_call_failed", method=method, error_code=DomainExecutionFailed.error_code, error_type=type(error).__name__, **backend_diagnostics(error), **diagnostics, ) raise DomainExecutionFailed(f"{method} failed") from error raise DomainExecutionFailed(f"{method} failed", details=diagnostics or None) from error class OaiCapabilityAdapter(NetworkCapabilityAdapter): Loading tests/contract/test_network_error_details.py 0 → 100644 +86 −0 Changes for tests/contract/test_network_error_details.py: 86 added lines, 0 removed lines. Original line number Diff line number Diff line """Safe network diagnostics reach consumers through the existing error contract.""" from types import SimpleNamespace import pytest import requests from structlog.testing import capture_logs from openop_tfsdk.contract.adapters.network import NetworkCapabilityAdapter from openop_tfsdk.contract.errors import DomainExecutionFailed, ResourceNotFound from openop_tfsdk.network.core.common import CoreHttpError @pytest.mark.parametrize("wrapped", [False, True]) @pytest.mark.parametrize("status, reason", [(400, "bad_request"), (503, "service_unavailable")]) def test_http_details_are_allowlisted_and_preserve_error_contract(wrapped, status, reason): marker = "PRIVATE_BACKEND_BODY_AND_URL" response = requests.Response() response.status_code = status response.url = "http://nef.invalid/" + marker response._content = marker.encode() error = requests.HTTPError(marker, response=response) def operation(): if wrapped: raise CoreHttpError(marker) from error raise error adapter = NetworkCapabilityAdapter.__new__(NetworkCapabilityAdapter) adapter._client = SimpleNamespace(operation=operation) with capture_logs() as events, pytest.raises(DomainExecutionFailed) as caught: adapter._call("operation") expected = {"backend_status": status, "reason": reason} assert caught.value.details == expected assert caught.value.error_code == "DOMAIN_EXECUTION_FAILED" assert caught.value.retryable is False assert caught.value.message == "operation failed" assert caught.value.__cause__ is not None assert all(events[0][key] == value for key, value in expected.items()) assert marker not in str(events) + str(caught.value) + str(caught.value.details) @pytest.mark.parametrize("wrapped", [False, True]) @pytest.mark.parametrize( "error_type, reason", [(requests.Timeout, "timeout"), (requests.ConnectionError, "connection_failed")], ) def test_transport_details_keep_only_safe_reason(wrapped, error_type, reason): error = error_type("PRIVATE_TRANSPORT_DESTINATION") def operation(): if wrapped: raise CoreHttpError("PRIVATE_WRAPPER_TEXT") from error raise error adapter = NetworkCapabilityAdapter.__new__(NetworkCapabilityAdapter) adapter._client = SimpleNamespace(operation=operation) with pytest.raises(DomainExecutionFailed) as caught: adapter._call("operation") assert caught.value.details == {"reason": reason} assert caught.value.retryable is False def test_unknown_failure_does_not_invent_details(): def operation(): raise RuntimeError("PRIVATE_UNRECOGNIZED_TEXT") adapter = NetworkCapabilityAdapter.__new__(NetworkCapabilityAdapter) adapter._client = SimpleNamespace(operation=operation) with pytest.raises(DomainExecutionFailed) as caught: adapter._call("operation") assert caught.value.details is None def test_wrapped_http_404_remains_resource_not_found(): response = requests.Response() response.status_code = 404 def operation(): raise CoreHttpError("private") from requests.HTTPError(response=response) adapter = NetworkCapabilityAdapter.__new__(NetworkCapabilityAdapter) adapter._client = SimpleNamespace(operation=operation) with pytest.raises(ResourceNotFound) as caught: adapter._call("operation") assert caught.value.details is None tests/contract/test_oai_qod_profiles.py +1 −0 Changes for tests/contract/test_oai_qod_profiles.py: 1 added line, 0 removed lines. Original line number Diff line number Diff line Loading @@ -186,4 +186,5 @@ def test_configured_profile_can_be_refused_by_nef_with_safe_details(nef): with pytest.raises(DomainExecutionFailed) as caught: adapter(qos_profiles=["qos2"]).activate(request("qos2")) assert len(nef.calls) == 1 assert caught.value.details == {"backend_status": 400, "reason": "bad_request"} assert "PRIVATE_NEF_BODY" not in str(caught.value) + str(caught.value.details) Loading
README.md +7 −0 Changes for README.md: 7 added lines, 0 removed lines. Original line number Diff line number Diff line Loading @@ -291,3 +291,10 @@ Requests outside the configured list fail before HTTP. Other control paths keep their own lists, and backend refusals remain typed execution failures. The existing notification destination requirement still applies; configuring profiles does not implement a receiver or establish data-plane QoS guarantees. Network DomainExecutionFailed errors include the existing allowlisted backend status/reason diagnostics in details when available. HTTP400 carries `{"backend_status": 400, "reason": "bad_request"}`; transport errors carry a safe reason. Raw response bodies, URLs and exception text are not copied. Error classes, retryable flags and wrapped HTTP404 classification are unchanged. SRM's existing bridge can preserve these details without a new contract field.
src/openop_tfsdk/_diagnostics.py +1 −1 Changes for src/openop_tfsdk/_diagnostics.py: 1 added line, 1 removed line. Original line number Diff line number Diff line """Small log-only summaries; never copy backend text, payloads or input values.""" """Allowlisted diagnostic summaries; never copy backend text, payloads or input values.""" from typing import get_args Loading
src/openop_tfsdk/contract/adapters/network.py +6 −4 Changes for src/openop_tfsdk/contract/adapters/network.py: 6 added lines, 4 removed lines. Original line number Diff line number Diff line Loading @@ -409,23 +409,25 @@ class NetworkCapabilityAdapter: if isinstance(cause, requests.HTTPError) and cause.response is not None: if cause.response.status_code == 404: raise ResourceNotFound(f"{method}: not found") from error diagnostics = backend_diagnostics(error) log.error( "backend_call_failed", method=method, error_code=DomainExecutionFailed.error_code, error_type=type(error).__name__, **backend_diagnostics(error), **diagnostics, ) raise DomainExecutionFailed(f"{method} failed") from error raise DomainExecutionFailed(f"{method} failed", details=diagnostics or None) from error except Exception as error: diagnostics = backend_diagnostics(error) log.error( "backend_call_failed", method=method, error_code=DomainExecutionFailed.error_code, error_type=type(error).__name__, **backend_diagnostics(error), **diagnostics, ) raise DomainExecutionFailed(f"{method} failed") from error raise DomainExecutionFailed(f"{method} failed", details=diagnostics or None) from error class OaiCapabilityAdapter(NetworkCapabilityAdapter): Loading
tests/contract/test_network_error_details.py 0 → 100644 +86 −0 Changes for tests/contract/test_network_error_details.py: 86 added lines, 0 removed lines. Original line number Diff line number Diff line """Safe network diagnostics reach consumers through the existing error contract.""" from types import SimpleNamespace import pytest import requests from structlog.testing import capture_logs from openop_tfsdk.contract.adapters.network import NetworkCapabilityAdapter from openop_tfsdk.contract.errors import DomainExecutionFailed, ResourceNotFound from openop_tfsdk.network.core.common import CoreHttpError @pytest.mark.parametrize("wrapped", [False, True]) @pytest.mark.parametrize("status, reason", [(400, "bad_request"), (503, "service_unavailable")]) def test_http_details_are_allowlisted_and_preserve_error_contract(wrapped, status, reason): marker = "PRIVATE_BACKEND_BODY_AND_URL" response = requests.Response() response.status_code = status response.url = "http://nef.invalid/" + marker response._content = marker.encode() error = requests.HTTPError(marker, response=response) def operation(): if wrapped: raise CoreHttpError(marker) from error raise error adapter = NetworkCapabilityAdapter.__new__(NetworkCapabilityAdapter) adapter._client = SimpleNamespace(operation=operation) with capture_logs() as events, pytest.raises(DomainExecutionFailed) as caught: adapter._call("operation") expected = {"backend_status": status, "reason": reason} assert caught.value.details == expected assert caught.value.error_code == "DOMAIN_EXECUTION_FAILED" assert caught.value.retryable is False assert caught.value.message == "operation failed" assert caught.value.__cause__ is not None assert all(events[0][key] == value for key, value in expected.items()) assert marker not in str(events) + str(caught.value) + str(caught.value.details) @pytest.mark.parametrize("wrapped", [False, True]) @pytest.mark.parametrize( "error_type, reason", [(requests.Timeout, "timeout"), (requests.ConnectionError, "connection_failed")], ) def test_transport_details_keep_only_safe_reason(wrapped, error_type, reason): error = error_type("PRIVATE_TRANSPORT_DESTINATION") def operation(): if wrapped: raise CoreHttpError("PRIVATE_WRAPPER_TEXT") from error raise error adapter = NetworkCapabilityAdapter.__new__(NetworkCapabilityAdapter) adapter._client = SimpleNamespace(operation=operation) with pytest.raises(DomainExecutionFailed) as caught: adapter._call("operation") assert caught.value.details == {"reason": reason} assert caught.value.retryable is False def test_unknown_failure_does_not_invent_details(): def operation(): raise RuntimeError("PRIVATE_UNRECOGNIZED_TEXT") adapter = NetworkCapabilityAdapter.__new__(NetworkCapabilityAdapter) adapter._client = SimpleNamespace(operation=operation) with pytest.raises(DomainExecutionFailed) as caught: adapter._call("operation") assert caught.value.details is None def test_wrapped_http_404_remains_resource_not_found(): response = requests.Response() response.status_code = 404 def operation(): raise CoreHttpError("private") from requests.HTTPError(response=response) adapter = NetworkCapabilityAdapter.__new__(NetworkCapabilityAdapter) adapter._client = SimpleNamespace(operation=operation) with pytest.raises(ResourceNotFound) as caught: adapter._call("operation") assert caught.value.details is None
tests/contract/test_oai_qod_profiles.py +1 −0 Changes for tests/contract/test_oai_qod_profiles.py: 1 added line, 0 removed lines. Original line number Diff line number Diff line Loading @@ -186,4 +186,5 @@ def test_configured_profile_can_be_refused_by_nef_with_safe_details(nef): with pytest.raises(DomainExecutionFailed) as caught: adapter(qos_profiles=["qos2"]).activate(request("qos2")) assert len(nef.calls) == 1 assert caught.value.details == {"backend_status": 400, "reason": "bad_request"} assert "PRIVATE_NEF_BODY" not in str(caught.value) + str(caught.value.details)