Commit 69580d8a authored by Konstantinos Togias's avatar Konstantinos Togias
Browse files

fix: retain safe network diagnostics in execution error details

parent 5b2c5e25
Loading
Loading
Loading
Loading
+7 −0
Changes for README.md: 7 added lines, 0 removed lines.
Original line number Diff line number Diff line
@@ -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.
+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

+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
@@ -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):
+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
+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
@@ -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)