fix(federation): relay FM's 409 when a partner already has an agreement

sequenceDiagram
  participant P as portal
  participant OEG
  participant FM
  P->>OEG: POST /platform/v1/federation/agreements (partner already has one)
  OEG->>FM: POST /internal/agreements
  FM-->>OEG: 409 agreement-exists, "Edit agreement {id} instead."
  Note over OEG: before: 409 not in FM's contract for this route, so 502<br/>now: relayed as is
  OEG-->>P: 409 + FM's ProblemDetails

FM is moving to one agreement per partner, edited in place (FM MR feat/one-agreement-per-partner, spec in architecture !15 (merged)). A second POST /agreements, or a PATCH that would make an expired agreement live next to the partner's current one, answers 409 agreement-exists. This relay listed no 409 for either route and turned it into a 502.

Two lines in the status allowlists, plus 409 in the OpenAPI responses of both routes. The PATCH summary changes because any term can now change in place. The body is still forwarded byte-for-byte, so the wider PATCH (zones, terms, API types, mappings) needs nothing else here.

Result
unit tests 1266 passed, 1 skipped; mypy, ruff clean
tests changed 409 joins the relayed FM errors; the "outside the contract" case now uses 403
testbed, ns oop, OP-1 → OEG → FM second agreement: 409 agreement-exists "Edit agreement 1a4d2b51… instead"; reviving an expired one next to a live one: 409

Safe to merge before the FM change: until FM sends 409, nothing reaches the new path.

🤖 Generated with Claude Code

Merge request reports

Loading
+7 −6
Changes for src/open_exposure_gateway/api/gsma/federation_manager/v1_4_0/router.py: 7 added lines, 6 removed lines.
Original line number Diff line number Diff line
@@ -121,10 +121,11 @@ _FM_PARTNER_GET_STATUSES = frozenset({200, 404, 500})
_FM_PARTNER_UPDATE_STATUSES = frozenset({200, 400, 404, 409, 422, 500, 501})

# Agreements are FM's own API too: 400 for a bad body, 404 for an unknown partner or
# agreement, 422 for a validity period that ends before it starts.
# agreement, 409 when the partner already has its one agreement, 422 for a validity period
# that ends before it starts.
_FM_LOCAL_ZONES_STATUSES = frozenset({200, 500})
_FM_AGREEMENT_CREATE_STATUSES = frozenset({201, 400, 404, 422, 500})
_FM_AGREEMENT_UPDATE_STATUSES = frozenset({200, 400, 404, 422, 500})
_FM_AGREEMENT_CREATE_STATUSES = frozenset({201, 400, 404, 409, 422, 500})
_FM_AGREEMENT_UPDATE_STATUSES = frozenset({200, 400, 404, 409, 422, 500})

_PROBLEM_JSON = "application/problem+json"

@@ -395,7 +396,7 @@ async def list_local_zones(
    summary="Record what a partner may use here: zones, their GSMA terms, allowed requests",
    operation_id="create_agreement",
    response_model=Agreement,
    responses=_responses(400, 401, 404, 422, 500, 502, 503),
    responses=_responses(400, 401, 404, 409, 422, 500, 502, 503),
)
async def create_agreement(
    request: Request,
@@ -415,10 +416,10 @@ async def create_agreement(
@router.patch(
    "/agreements/{agreementId}",
    tags=["Federation Manager"],
    summary="Change an agreement's status, usage limits or validity",
    summary="Change any of a partner's agreement terms in place",
    operation_id="update_agreement",
    response_model=Agreement,
    responses=_responses(400, 401, 404, 422, 500, 502, 503),
    responses=_responses(400, 401, 404, 409, 422, 500, 502, 503),
)
async def update_agreement(
    agreementId: UUID,
+2 −2
Changes for tests/unit/test_federation_manager_endpoints.py: 2 added lines, 2 removed lines.
Original line number Diff line number Diff line
@@ -951,7 +951,7 @@ def test_agreements_relay_to_fm_unaltered(


@pytest.mark.parametrize(("http_method", "path", "body", "status"), _AGREEMENT_ENDPOINTS)
@pytest.mark.parametrize("fm_status", [400, 404, 422, 500])
@pytest.mark.parametrize("fm_status", [400, 404, 409, 422, 500])
def test_agreement_errors_from_fm_are_relayed(
    client: TestClient,
    fake_fm_client: _FakeFmClient,
@@ -979,7 +979,7 @@ def test_agreement_errors_from_fm_are_relayed(

@pytest.mark.parametrize(("http_method", "path", "body", "status"), _AGREEMENT_ENDPOINTS)
@pytest.mark.parametrize(
    ("fm_status", "fm_body"), [(409, _AGREEMENT_BODY), (None, b"{}"), (None, _PARTNER_BODY)]
    ("fm_status", "fm_body"), [(403, _AGREEMENT_BODY), (None, b"{}"), (None, _PARTNER_BODY)]
)
def test_agreement_answers_outside_fm_contract_return_502(
    client: TestClient,
Loading