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
81 changes: 81 additions & 0 deletions dotnet/tests/CareEvolution.Orchestrate.Tests/ConfigurationTests.cs
Original file line number Diff line number Diff line change
Expand Up @@ -290,6 +290,87 @@ public async Task HttpErrorsShouldBeConvertedToOrchestrateClientExceptions()
Assert.Single(exception.OperationOutcome!.Issue);
}

[Fact]
public async Task MultipleIssueOperationOutcomeShouldExposeAllStructuredIssues()
{
using var environment = new EnvironmentVariableScope(
new Dictionary<string, string?>
{
["ORCHESTRATE_API_KEY"] = null,
["ORCHESTRATE_ADDITIONAL_HEADERS"] = null,
}
);

const string responseJson = """
{
"resourceType": "OperationOutcome",
"issue": [
{
"severity": "error",
"code": "invalid",
"diagnostics": "Missing recordTarget in ClinicalDocument"
},
{
"severity": "information",
"code": "informational",
"details": {
"coding": [{ "system": "https://quality.rosetta.careevolution.com/v1/CodeSystems/CDAProcessingMessage", "code": "DocumentId" }],
"text": "fb04306a-0834-432d-90c3-251ed7d3401d"
}
},
{
"severity": "information",
"code": "informational",
"details": {
"coding": [{ "system": "https://quality.rosetta.careevolution.com/v1/CodeSystems/CDAProcessingMessage", "code": "DocumentEffectiveTime" }],
"text": "2011-05-27T01:44:27-05:00"
}
}
]
}
""";

var handler = new FakeHttpMessageHandler(
(_, _) => Task.FromResult(FakeResponses.Json(responseJson, HttpStatusCode.BadRequest))
);

using var httpClient = new HttpClient(handler);
var api = new OrchestrateApi(
httpClient,
new OrchestrateClientOptions
{
BaseUrl = "https://api.example.com",
ApiKey = "test-api-key",
}
);

var exception = await Assert.ThrowsAsync<OrchestrateClientException>(() =>
api.Convert.CdaToFhirR4Async(
new ConvertCdaToFhirR4Request { Content = "<ClinicalDocument/>" }
)
);

Assert.Equal(HttpStatusCode.BadRequest, exception.StatusCode);
Assert.NotNull(exception.OperationOutcome);
Assert.Equal(3, exception.OperationOutcome!.Issue.Count);
Assert.Equal(3, exception.Issues.Count);

// Error issue: diagnostics only, no details
Assert.Contains(
"error: invalid - Missing recordTarget in ClinicalDocument",
exception.Issues[0]
);

// Information issues: details.text extracted
Assert.Contains("fb04306a-0834-432d-90c3-251ed7d3401d", exception.Issues[1]);
Assert.Contains("2011-05-27T01:44:27-05:00", exception.Issues[2]);

// Message covers all issues
Assert.Contains("Missing recordTarget in ClinicalDocument", exception.Message);
Assert.Contains("fb04306a-0834-432d-90c3-251ed7d3401d", exception.Message);
Assert.Contains("2011-05-27T01:44:27-05:00", exception.Message);
}

[Fact]
public async Task MalformedJsonHttpErrorsShouldPreserveRawResponseText()
{
Expand Down
39 changes: 33 additions & 6 deletions python/orchestrate/_internal/exceptions.py
Original file line number Diff line number Diff line change
@@ -1,3 +1,5 @@
from dataclasses import dataclass

from requests import HTTPError


Expand All @@ -13,12 +15,37 @@ class OrchestrateHttpError(OrchestrateError, HTTPError):
pass


class OrchestrateClientError(OrchestrateHttpError):
"""Raised when the Orchestrate API returns a 400 status code."""
@dataclass
class OperationOutcomeIssue:
"""A single issue from a FHIR OperationOutcome."""

severity: str
code: str
diagnostics: str
details: str
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

code is just one of the values from https://build.fhir.org/valueset-issue-type.html

I think we need to know the detail.coding as well?


def __init__(self, response_text: str, issues: list[str]) -> None:
def __str__(self) -> str:
s = f"{self.severity}: {self.code}"
message = "; ".join(m for m in [self.details, self.diagnostics] if m)
if message:
s += f" - {message}"
return s


class OrchestrateClientError(OrchestrateHttpError):
"""Raised when the Orchestrate API returns a 4xx or 5xx status code."""

def __init__(
self,
response_text: str,
issues: list[OperationOutcomeIssue],
status_code: int = 0,
) -> None:
self.response_text = response_text
self.issues = issues
self.status_code = status_code
if issues:
string_outcomes = "\n * ".join(issues)
string_outcomes = "\n * ".join(str(i) for i in issues)
super().__init__(f"Issues: \n * {string_outcomes}")
return
super().__init__(f"Client error: {response_text}")
else:
super().__init__(f"Client error: {response_text}")
37 changes: 6 additions & 31 deletions python/orchestrate/_internal/http_handler.py
Original file line number Diff line number Diff line change
@@ -1,11 +1,11 @@
import json
import os
from ctypes import ArgumentError
from dataclasses import dataclass
from typing import Any, Mapping, Optional, Union

import requests
from orchestrate._internal.exceptions import (
OperationOutcomeIssue,
OrchestrateClientError,
OrchestrateHttpError,
)
Expand Down Expand Up @@ -60,29 +60,12 @@ def _get_additional_headers() -> Mapping[str, str]:
)


@dataclass
class _OperationalOutcomeIssue:
severity: str
code: str
diagnostics: str
details: str

def __str__(self) -> str:
s = f"{self.severity}: {self.code}"
message = "; ".join(
message for message in [self.details, self.diagnostics] if message
)
if message:
s += f" - {message}"
return s


def _read_json_outcomes(response: requests.Response) -> list[_OperationalOutcomeIssue]:
def _read_json_outcomes(response: requests.Response) -> list[OperationOutcomeIssue]:
try:
json_response = response.json()
if "issue" in json_response:
return [
Comment on lines +63 to 67
Copy link

Copilot AI Apr 17, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

In _read_json_outcomes, non-JSON / non-OperationOutcome responses now result in an empty issues list (see function return behavior), whereas previously the fallback produced a single “issue” containing response.text. If keeping exception message formatting unchanged is important, consider restoring an equivalent fallback so callers still get a consistent “Issues:”/bullet-list message for non-structured errors.

Copilot uses AI. Check for mistakes.
_OperationalOutcomeIssue(
OperationOutcomeIssue(
issue.get("severity", ""),
issue.get("code", ""),
issue.get("diagnostics", ""),
Expand All @@ -95,7 +78,7 @@ def _read_json_outcomes(response: requests.Response) -> list[_OperationalOutcome
== "https://tools.ietf.org/html/rfc9110#section-15.5.1"
):
return [
_OperationalOutcomeIssue(
OperationOutcomeIssue(
severity="error",
code=json_response.get("title", ""),
diagnostics=json_response.get("detail", ""),
Expand All @@ -122,18 +105,10 @@ def _get_detail_coding_string(coding: dict) -> str:
)


def _read_operational_outcomes(response: requests.Response) -> list[str]:
outcomes = _read_json_outcomes(response)
if outcomes:
return [str(outcome) for outcome in outcomes]

return [response.text]


def _exception_from_response(response: requests.Response) -> OrchestrateHttpError:
operational_outcomes = _read_operational_outcomes(response)
issues = _read_json_outcomes(response)
if response.status_code >= 400 and response.status_code < 600:
return OrchestrateClientError(response.text, operational_outcomes)
return OrchestrateClientError(response.text, issues, response.status_code)
return OrchestrateHttpError()
Comment on lines 108 to 112
Copy link

Copilot AI Apr 17, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Because _exception_from_response now passes an empty issues list through to OrchestrateClientError, non-JSON error responses will use the “Client error: …” message path instead of the prior “Issues: …” formatting. This is a user-visible behavior change; either preserve the prior formatting or adjust the PR description/tests accordingly.

Copilot uses AI. Check for mistakes.


Expand Down
4 changes: 3 additions & 1 deletion python/orchestrate/exceptions.py
Original file line number Diff line number Diff line change
@@ -1,10 +1,12 @@
from orchestrate._internal.exceptions import (
OrchestrateError,
OperationOutcomeIssue,
OrchestrateClientError,
OrchestrateError,
OrchestrateHttpError,
)

__all__ = [
"OperationOutcomeIssue",
"OrchestrateError",
"OrchestrateClientError",
"OrchestrateHttpError",
Expand Down
112 changes: 112 additions & 0 deletions python/tests/test_error_parsing.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,112 @@
import json
from unittest.mock import Mock

import pytest
from orchestrate._internal.exceptions import OrchestrateClientError
from orchestrate._internal.http_handler import _exception_from_response

pytestmark = pytest.mark.default

_CDA_TO_FHIR_OPERATION_OUTCOME = {
"resourceType": "OperationOutcome",
"issue": [
{
"severity": "error",
"code": "invalid",
"diagnostics": "Missing recordTarget in ClinicalDocument",
},
{
"severity": "information",
"code": "informational",
"details": {
"coding": [
{
"system": "https://quality.rosetta.careevolution.com/v1/CodeSystems/CDAProcessingMessage",
"code": "DocumentId",
}
],
"text": "fb04306a-0834-432d-90c3-251ed7d3401d",
},
},
{
"severity": "information",
"code": "informational",
"details": {
"coding": [
{
"system": "https://quality.rosetta.careevolution.com/v1/CodeSystems/CDAProcessingMessage",
"code": "DocumentEffectiveTime",
}
],
"text": "2011-05-27T01:44:27-05:00",
},
},
],
}


def _make_mock_response(payload: dict, status_code: int) -> Mock:
response = Mock()
response.status_code = status_code
response.text = json.dumps(payload)
response.json.return_value = payload
return response


def test_multiple_issues_should_all_be_captured():
response = _make_mock_response(_CDA_TO_FHIR_OPERATION_OUTCOME, 400)

exc = _exception_from_response(response)

assert isinstance(exc, OrchestrateClientError)
assert len(exc.issues) == 3


def test_error_issue_with_diagnostics_only():
response = _make_mock_response(_CDA_TO_FHIR_OPERATION_OUTCOME, 400)

exc = _exception_from_response(response)

assert isinstance(exc, OrchestrateClientError)
issue = exc.issues[0]
assert issue.severity == "error"
assert issue.code == "invalid"
assert issue.diagnostics == "Missing recordTarget in ClinicalDocument"
assert issue.details == ""


def test_information_issue_with_details_text():
response = _make_mock_response(_CDA_TO_FHIR_OPERATION_OUTCOME, 400)

exc = _exception_from_response(response)

assert isinstance(exc, OrchestrateClientError)
doc_id_issue = exc.issues[1]
assert doc_id_issue.severity == "information"
assert doc_id_issue.code == "informational"
assert doc_id_issue.details == "fb04306a-0834-432d-90c3-251ed7d3401d"
assert doc_id_issue.diagnostics == ""

effective_time_issue = exc.issues[2]
assert effective_time_issue.details == "2011-05-27T01:44:27-05:00"


def test_status_code_and_response_text_are_preserved():
response = _make_mock_response(_CDA_TO_FHIR_OPERATION_OUTCOME, 400)

exc = _exception_from_response(response)

assert isinstance(exc, OrchestrateClientError)
assert exc.status_code == 400
assert exc.response_text == response.text


def test_message_string_includes_all_issues():
response = _make_mock_response(_CDA_TO_FHIR_OPERATION_OUTCOME, 400)

exc = _exception_from_response(response)

message = str(exc)
assert "Missing recordTarget in ClinicalDocument" in message
assert "fb04306a-0834-432d-90c3-251ed7d3401d" in message
assert "2011-05-27T01:44:27-05:00" in message
44 changes: 36 additions & 8 deletions typescript/src/exceptions.ts
Original file line number Diff line number Diff line change
Expand Up @@ -12,16 +12,44 @@ export class OrchestrateHttpError extends OrchestrateError {
}
}

export class OrchestrateClientError extends OrchestrateError {
constructor(response_text: string, issues: string[]) {
let message;
if (issues.length > 0) {
message = `\n * ${issues.join(" \n * ")}`;
}
else {
message = response_text;
export class OperationOutcomeIssue {
severity: string;
code: string;
diagnostics: string;
details: string;

constructor(severity: string, code: string, diagnostics: string, details: string) {
this.severity = severity;
this.code = code;
this.diagnostics = diagnostics;
this.details = details;
}

toString(): string {
let s = `${this.severity}: ${this.code}`;
const message = [this.details, this.diagnostics]
.filter(msg => msg)
.join("; ");
if (message) {
s += ` - ${message}`;
}
return s;
}
}

export class OrchestrateClientError extends OrchestrateError {
responseText: string;
issues: OperationOutcomeIssue[];
statusCode: number;

constructor(responseText: string, issues: OperationOutcomeIssue[], statusCode: number = 0) {
const message = issues.length > 0
? `\n * ${issues.map(i => i.toString()).join(" \n * ")}`
: responseText;
super(message);
this.name = this.constructor.name;
this.responseText = responseText;
this.issues = issues;
Comment on lines +45 to +52
Copy link

Copilot AI Apr 17, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

OrchestrateClientError's constructor now requires OperationOutcomeIssue[] for issues, which is a breaking TypeScript API change for any callers that previously constructed the error with a string[]. If the goal is “no breaking change for callers constructing the exception directly”, consider constructor overloads (or a union type) that accepts string[] and converts them internally.

Suggested change
constructor(responseText: string, issues: OperationOutcomeIssue[], statusCode: number = 0) {
const message = issues.length > 0
? `\n * ${issues.map(i => i.toString()).join(" \n * ")}`
: responseText;
super(message);
this.name = this.constructor.name;
this.responseText = responseText;
this.issues = issues;
constructor(responseText: string, issues: OperationOutcomeIssue[], statusCode?: number);
constructor(responseText: string, issues: string[], statusCode?: number);
constructor(responseText: string, issues: OperationOutcomeIssue[] | string[], statusCode: number = 0) {
const normalizedIssues = issues.map(issue =>
typeof issue === "string"
? new OperationOutcomeIssue("error", "unknown", issue, "")
: issue
);
const message = normalizedIssues.length > 0
? `\n * ${normalizedIssues.map(i => i.toString()).join(" \n * ")}`
: responseText;
super(message);
this.name = this.constructor.name;
this.responseText = responseText;
this.issues = normalizedIssues;

Copilot uses AI. Check for mistakes.
this.statusCode = statusCode;
}
}
Loading
Loading