Skip to content
Open
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
120 changes: 120 additions & 0 deletions packages/traceloop-sdk/tests/test_http_client_post.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,120 @@
from unittest.mock import Mock, patch

import pytest
import requests

from traceloop.sdk.client.http import HTTPClient
from traceloop.sdk.datasets.dataset import Dataset
from traceloop.sdk.experiment.experiment import Experiment


def _http_client() -> HTTPClient:
return HTTPClient(base_url="https://api.example.com", api_key="test-key", version="1.0.0")


def test_http_client_post_returns_json_on_success():
client = _http_client()
mock_response = Mock()
mock_response.raise_for_status.return_value = None
mock_response.json.return_value = {"status": "ok"}

with patch("traceloop.sdk.client.http.requests.post", return_value=mock_response):
result = client.post("annotations", {"k": "v"})

assert result == {"status": "ok"}


def test_http_client_post_returns_none_on_http_error():
client = _http_client()
mock_response = Mock()
mock_response.raise_for_status.side_effect = requests.exceptions.HTTPError(
"500 Server Error"
)

with patch("traceloop.sdk.client.http.requests.post", return_value=mock_response):
result = client.post("annotations", {"k": "v"})

assert result is None


def test_http_client_post_returns_none_on_transport_error():
client = _http_client()

with patch(
"traceloop.sdk.client.http.requests.post",
side_effect=requests.exceptions.ConnectionError("connection refused"),
):
result = client.post("annotations", {"k": "v"})

assert result is None


def test_http_client_post_raises_http_error_with_opt_in():
client = _http_client()
mock_response = Mock()
http_error = requests.exceptions.HTTPError("500 Server Error")
mock_response.raise_for_status.side_effect = http_error

with patch("traceloop.sdk.client.http.requests.post", return_value=mock_response):
with pytest.raises(requests.exceptions.HTTPError):
client.post("annotations", {"k": "v"}, raise_on_error=True)


def test_http_client_post_raises_transport_error_with_opt_in():
client = _http_client()
connection_error = requests.exceptions.ConnectionError("connection refused")

with patch(
"traceloop.sdk.client.http.requests.post",
side_effect=connection_error,
):
with pytest.raises(requests.exceptions.ConnectionError):
client.post("annotations", {"k": "v"}, raise_on_error=True)


def test_http_client_post_treats_success_non_json_or_empty_as_success():
client = _http_client()

empty_body_response = Mock()
empty_body_response.raise_for_status.return_value = None
empty_body_response.content = b""

with patch("traceloop.sdk.client.http.requests.post", return_value=empty_body_response):
empty_result = client.post("annotations", {"k": "v"}, raise_on_error=True)

assert empty_result is None

non_json_response = Mock()
non_json_response.raise_for_status.return_value = None
non_json_response.content = b"ok"
non_json_response.json.side_effect = requests.exceptions.JSONDecodeError(
"Expecting value", "ok", 0
)
non_json_response.text = "ok"

with patch("traceloop.sdk.client.http.requests.post", return_value=non_json_response):
non_json_result = client.post("annotations", {"k": "v"}, raise_on_error=True)

assert non_json_result == "ok"


def test_dataset_publish_failure_handling_remains_compatible():
mock_http = Mock(spec=HTTPClient)
mock_http.post.return_value = None

dataset = Dataset(http=mock_http)
dataset.slug = "test-dataset"

with pytest.raises(Exception, match="Failed to publish dataset test-dataset"):
dataset.publish()


def test_experiment_create_task_failure_handling_remains_compatible():
mock_http_client = Mock(spec=HTTPClient)
mock_http_client.base_url = "https://api.example.com"
mock_http_client.post.return_value = None
mock_async_http_client = Mock()
experiment = Experiment(mock_http_client, mock_async_http_client, "test-experiment")

with pytest.raises(Exception, match="Failed to create task for experiment 'test-experiment'"):
experiment._create_task("test-experiment", "run-123", {}, {"output": "value"})
73 changes: 72 additions & 1 deletion packages/traceloop-sdk/tests/test_user_feedback.py
Original file line number Diff line number Diff line change
Expand Up @@ -9,7 +9,11 @@
"""

import pytest
import requests
from unittest.mock import Mock
from traceloop.sdk.annotation.base_annotation import (
AnnotationCreateStatus,
)
from traceloop.sdk.annotation.user_feedback import UserFeedback
from traceloop.sdk.client.http import HTTPClient

Expand Down Expand Up @@ -37,10 +41,15 @@ def test_user_feedback_initialization(mock_http):

def test_create_basic_feedback(user_feedback: UserFeedback, mock_http: Mock):
"""Test creating basic user feedback"""
user_feedback.create(
result = user_feedback.create(
annotation_task="task_123", entity_id="instance_456", tags={"sentiment": "positive"}
)

assert result.status == AnnotationCreateStatus.DELIVERED
assert result.payload == {"status": "success"}
assert result.status_code is None
assert result.error is None

mock_http.post.assert_called_once_with(
"annotation-tasks/task_123/annotations",
{
Expand All @@ -53,6 +62,7 @@ def test_create_basic_feedback(user_feedback: UserFeedback, mock_http: Mock):
"id": "test-app",
},
},
raise_on_error=True,
)


Expand All @@ -74,8 +84,69 @@ def test_create_feedback_complex_tags(user_feedback: UserFeedback, mock_http: Mo
"id": "test-app",
},
},
raise_on_error=True,
)


def test_create_feedback_returns_refused_on_http_error(
user_feedback: UserFeedback, mock_http: Mock
):
"""Test failed feedback write is surfaced as REFUSED."""
mock_response = Mock()
mock_response.status_code = 500
mock_response.json.return_value = {"error": "server failure"}
http_error = requests.exceptions.HTTPError("500 Server Error", response=mock_response)
mock_http.post.side_effect = http_error

result = user_feedback.create(
annotation_task="task_123",
entity_id="instance_456",
tags={"sentiment": "positive"},
)

assert result.status == AnnotationCreateStatus.REFUSED
assert result.payload == {"error": "server failure"}
assert result.status_code == 500
assert result.error is http_error


def test_create_feedback_returns_unreachable_on_transport_error(
user_feedback: UserFeedback, mock_http: Mock
):
"""Test transport failures are surfaced as UNREACHABLE."""
connection_error = requests.exceptions.ConnectionError("connection refused")
mock_http.post.side_effect = connection_error

result = user_feedback.create(
annotation_task="task_123",
entity_id="instance_456",
tags={"sentiment": "positive"},
)

assert result.status == AnnotationCreateStatus.UNREACHABLE
assert result.payload is None
assert result.status_code is None
assert result.error is connection_error


def test_create_feedback_returns_refused_without_response_details(
user_feedback: UserFeedback, mock_http: Mock
):
"""Test HTTPError without response is surfaced as REFUSED with empty details."""
http_error = requests.exceptions.HTTPError("request failed")
mock_http.post.side_effect = http_error

result = user_feedback.create(
annotation_task="task_123",
entity_id="instance_456",
tags={"sentiment": "positive"},
)

assert result.status == AnnotationCreateStatus.REFUSED
assert result.payload is None
assert result.status_code is None
assert result.error is http_error


def test_create_feedback_parameter_validation(user_feedback: UserFeedback):
"""Test parameter validation for feedback creation"""
Expand Down
84 changes: 70 additions & 14 deletions packages/traceloop-sdk/traceloop/sdk/annotation/base_annotation.py
Original file line number Diff line number Diff line change
@@ -1,8 +1,26 @@
from typing import Dict, Any
from dataclasses import dataclass
from enum import Enum
from typing import Any, Dict, Optional

import requests

from ..client.http import HTTPClient


class AnnotationCreateStatus(str, Enum):
DELIVERED = "delivered"
REFUSED = "refused"
UNREACHABLE = "unreachable"


@dataclass
class AnnotationCreateResult:
status: AnnotationCreateStatus
payload: Optional[Any] = None
status_code: Optional[int] = None
error: Optional[requests.exceptions.RequestException] = None


class BaseAnnotation:
"""
Annotation class for creating annotations in Traceloop.
Expand All @@ -23,7 +41,7 @@ def create(
annotation_task: str,
entity_id: str,
tags: Dict[str, Any],
) -> None:
) -> AnnotationCreateResult:
"""Create an user feedback annotation for a specific task.

Args:
Expand All @@ -34,6 +52,12 @@ def create(
tags (Dict[str, Any]): Dictionary containing the tags to be reported.
Should match the tags defined in the annotation task

Returns:
AnnotationCreateResult: Result of annotation delivery.
- DELIVERED: request succeeded and payload contains decoded response body.
- REFUSED: server responded with a non-2xx status code.
- UNREACHABLE: request failed before receiving an HTTP response.

Example:
```python
client = Client(api_key="your-key")
Expand All @@ -56,16 +80,48 @@ def create(
if not tags:
raise ValueError("tags cannot be empty")

self._http.post(
f"annotation-tasks/{annotation_task}/annotations",
{
"entity_instance_id": entity_id,
"tags": tags,
"source": "sdk",
"flow": self._flow,
"actor": {
"type": "service",
"id": self._app_name,
try:
payload = self._http.post(
f"annotation-tasks/{annotation_task}/annotations",
{
"entity_instance_id": entity_id,
"tags": tags,
"source": "sdk",
"flow": self._flow,
"actor": {
"type": "service",
"id": self._app_name,
},
},
},
)
raise_on_error=True,
)
return AnnotationCreateResult(
status=AnnotationCreateStatus.DELIVERED, payload=payload
)
except requests.exceptions.HTTPError as error:
status_code = (
error.response.status_code if error.response is not None else None
)
return AnnotationCreateResult(
status=AnnotationCreateStatus.REFUSED,
payload=self._extract_response_payload(error.response),
status_code=status_code,
error=error,
)
except requests.exceptions.RequestException as error:
return AnnotationCreateResult(
status=AnnotationCreateStatus.UNREACHABLE,
error=error,
)

@staticmethod
def _extract_response_payload(response: Optional[requests.Response]) -> Optional[Any]:
if response is None:
return None

try:
return response.json()
except ValueError:
if response.text:
return response.text
return None
13 changes: 11 additions & 2 deletions packages/traceloop-sdk/traceloop/sdk/annotation/user_feedback.py
Original file line number Diff line number Diff line change
@@ -1,7 +1,10 @@
from typing import Any, Dict

from traceloop.sdk.client.http import HTTPClient
from .base_annotation import BaseAnnotation
from .base_annotation import (
AnnotationCreateResult,
BaseAnnotation,
)


class UserFeedback(BaseAnnotation):
Expand All @@ -13,7 +16,7 @@ def create(
annotation_task: str,
entity_id: str,
tags: Dict[str, Any],
) -> None:
) -> AnnotationCreateResult:
"""Create an annotation for a specific task.

Args:
Expand All @@ -24,6 +27,12 @@ def create(
tags (Dict[str, Any]): Dictionary containing the tags to be reported.
Should match the tags defined in the annotation task

Returns:
AnnotationCreateResult: Result of annotation delivery.
- DELIVERED: request succeeded and payload contains decoded response body.
- REFUSED: server responded with a non-2xx status code.
- UNREACHABLE: request failed before receiving an HTTP response.

Example:
```python
client = Client(api_key="your-key")
Expand Down
Loading