Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
15 changes: 14 additions & 1 deletion datadog_sync/model/spans_metrics.py
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@

from datadog_sync.utils.base_resource import BaseResource, ResourceConfig
from datadog_sync.utils.custom_client import CustomClient
from datadog_sync.utils.resource_utils import CustomClientHTTPError


class SpansMetrics(BaseResource):
Expand Down Expand Up @@ -43,7 +44,19 @@ async def pre_apply_hook(self) -> None:
async def create_resource(self, _id: str, resource: Dict) -> Tuple[str, Dict]:
destination_client = self.config.destination_client
payload = {"data": resource}
resp = await destination_client.post(self.resource_config.base_path, payload)
try:
resp = await destination_client.post(self.resource_config.base_path, payload)
except CustomClientHTTPError as e:
if e.status_code != 409:
raise
# Destination already has a spans_metric with this name. The v2 API
# keys metrics by name (metric_id path param == metric name), so a 409
# on POST means the same _id exists on the destination. `skip_resource_mapping`
# is True for this type, so `state.destination` was never populated from a
# live LIST — hydrate it via a GET-by-id and fall through to update.
existing = await destination_client.get(self.resource_config.base_path + f"/{_id}")
self.config.state.destination[self.resource_type][_id] = existing["data"]
return await self.update_resource(_id, resource)

return _id, resp["data"]

Expand Down
99 changes: 99 additions & 0 deletions tests/unit/test_spans_metrics_409_fallback.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,99 @@
# Unless explicitly stated otherwise all files in this repository are licensed
# under the 3-clause BSD style license (see LICENSE).
# This product includes software developed at Datadog (https://www.datadoghq.com/).
# Copyright 2019 Datadog, Inc.

"""
Unit tests for spans_metrics create_resource 409 fallback.

Pins the behavior that when the destination `/api/v2/apm/config/metrics` POST
returns 409 (metric name already exists), create_resource does a GET-by-id,
hydrates state.destination, and delegates to update_resource. Without this
fallback, `skip_resource_mapping=True` leaves state.destination empty and
first-run against a pre-populated destination fails permanently on 409.
"""

import asyncio
from unittest.mock import AsyncMock, MagicMock

import pytest

from datadog_sync.model.spans_metrics import SpansMetrics
from datadog_sync.utils.resource_utils import CustomClientHTTPError


def _run(coro):
# Fresh loop per call: pytest-asyncio strict mode closes the ambient loop
# between tests. See other test files in this suite for the pattern.
loop = asyncio.new_event_loop()
try:
return loop.run_until_complete(coro)
finally:
loop.close()


def _make_http_error(status: int) -> CustomClientHTTPError:
resp = MagicMock()
resp.status = status
resp.message = "Conflict" if status == 409 else "Error"
return CustomClientHTTPError(resp, message="metric already exists with that name")


def test_create_resource_happy_path(mock_config):
"""POST succeeds → return (_id, resp['data']). No GET, no PATCH."""
sm = SpansMetrics(mock_config)
dest = AsyncMock()
dest.post = AsyncMock(return_value={"data": {"id": "m1", "attributes": {}}})
dest.get = AsyncMock()
dest.patch = AsyncMock()
mock_config.destination_client = dest

_id, data = _run(sm.create_resource("m1", {"id": "m1", "attributes": {}}))

assert _id == "m1"
assert data == {"id": "m1", "attributes": {}}
dest.post.assert_awaited_once()
dest.get.assert_not_awaited()
dest.patch.assert_not_awaited()


def test_create_resource_409_falls_back_to_get_then_patch(mock_config):
"""POST 409 → GET-by-id, state.destination[type][_id] populated, PATCH called."""
sm = SpansMetrics(mock_config)
dest = AsyncMock()
dest.post = AsyncMock(side_effect=_make_http_error(409))
existing_body = {"id": "ProcessDepositMessage", "attributes": {"managed": False}}
dest.get = AsyncMock(return_value={"data": existing_body})
dest.patch = AsyncMock(return_value={"data": {"id": "ProcessDepositMessage", "attributes": {"managed": True}}})
mock_config.destination_client = dest

_id, data = _run(sm.create_resource("ProcessDepositMessage", {"id": "ProcessDepositMessage", "attributes": {}}))

dest.post.assert_awaited_once()
dest.get.assert_awaited_once_with("/api/v2/apm/config/metrics/ProcessDepositMessage")
assert mock_config.state.destination["spans_metrics"]["ProcessDepositMessage"] == existing_body, (
"state.destination must be hydrated with the GET body so update_resource can resolve the PATCH URL"
)
dest.patch.assert_awaited_once()
patch_url = dest.patch.await_args.args[0]
assert patch_url == "/api/v2/apm/config/metrics/ProcessDepositMessage", (
f"PATCH must target the id-scoped path, got: {patch_url}"
)
assert _id == "ProcessDepositMessage"
assert data == {"id": "ProcessDepositMessage", "attributes": {"managed": True}}


def test_create_resource_non_409_reraises(mock_config):
"""Non-409 errors (400/403/500) propagate unchanged — no GET, no PATCH."""
sm = SpansMetrics(mock_config)
dest = AsyncMock()
dest.post = AsyncMock(side_effect=_make_http_error(500))
dest.get = AsyncMock()
dest.patch = AsyncMock()
mock_config.destination_client = dest

with pytest.raises(CustomClientHTTPError) as excinfo:
_run(sm.create_resource("m1", {"id": "m1", "attributes": {}}))
assert excinfo.value.status_code == 500
dest.get.assert_not_awaited()
dest.patch.assert_not_awaited()
Loading