From c9e18e569c41be3a2b5f293da3a0011b0fb2fe28 Mon Sep 17 00:00:00 2001 From: Sunanda Venumuddula Date: Fri, 17 Apr 2026 11:42:28 -0400 Subject: [PATCH 1/3] Expose structured OperationOutcome issues on client errors ( python and typescript ) --- .../ConfigurationTests.cs | 79 ++++++++++++ python/orchestrate/_internal/exceptions.py | 39 +++++- python/orchestrate/_internal/http_handler.py | 37 +----- python/orchestrate/exceptions.py | 4 +- python/tests/test_error_parsing.py | 112 +++++++++++++++++ typescript/src/exceptions.ts | 44 +++++-- typescript/src/httpHandler.ts | 45 +------ typescript/tests/errorParsing.test.ts | 116 ++++++++++++++++++ 8 files changed, 391 insertions(+), 85 deletions(-) create mode 100644 python/tests/test_error_parsing.py create mode 100644 typescript/tests/errorParsing.test.ts diff --git a/dotnet/tests/CareEvolution.Orchestrate.Tests/ConfigurationTests.cs b/dotnet/tests/CareEvolution.Orchestrate.Tests/ConfigurationTests.cs index 07bc1a7..e67ab8b 100644 --- a/dotnet/tests/CareEvolution.Orchestrate.Tests/ConfigurationTests.cs +++ b/dotnet/tests/CareEvolution.Orchestrate.Tests/ConfigurationTests.cs @@ -290,6 +290,85 @@ public async Task HttpErrorsShouldBeConvertedToOrchestrateClientExceptions() Assert.Single(exception.OperationOutcome!.Issue); } + [Fact] + public async Task MultipleIssueOperationOutcomeShouldExposeAllStructuredIssues() + { + using var environment = new EnvironmentVariableScope( + new Dictionary + { + ["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(() => + api.Convert.CdaToFhirR4Async( + new ConvertCdaToFhirR4Request { Content = "" } + ) + ); + + 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() { diff --git a/python/orchestrate/_internal/exceptions.py b/python/orchestrate/_internal/exceptions.py index 81e9492..8ea81dc 100644 --- a/python/orchestrate/_internal/exceptions.py +++ b/python/orchestrate/_internal/exceptions.py @@ -1,3 +1,5 @@ +from dataclasses import dataclass + from requests import HTTPError @@ -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 - 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}") diff --git a/python/orchestrate/_internal/http_handler.py b/python/orchestrate/_internal/http_handler.py index 0c03e0d..0932e72 100644 --- a/python/orchestrate/_internal/http_handler.py +++ b/python/orchestrate/_internal/http_handler.py @@ -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, ) @@ -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 [ - _OperationalOutcomeIssue( + OperationOutcomeIssue( issue.get("severity", ""), issue.get("code", ""), issue.get("diagnostics", ""), @@ -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", ""), @@ -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() diff --git a/python/orchestrate/exceptions.py b/python/orchestrate/exceptions.py index aec44fb..69ee5c7 100644 --- a/python/orchestrate/exceptions.py +++ b/python/orchestrate/exceptions.py @@ -1,10 +1,12 @@ from orchestrate._internal.exceptions import ( - OrchestrateError, + OperationOutcomeIssue, OrchestrateClientError, + OrchestrateError, OrchestrateHttpError, ) __all__ = [ + "OperationOutcomeIssue", "OrchestrateError", "OrchestrateClientError", "OrchestrateHttpError", diff --git a/python/tests/test_error_parsing.py b/python/tests/test_error_parsing.py new file mode 100644 index 0000000..3278811 --- /dev/null +++ b/python/tests/test_error_parsing.py @@ -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 diff --git a/typescript/src/exceptions.ts b/typescript/src/exceptions.ts index 63ed3b0..0839ef8 100644 --- a/typescript/src/exceptions.ts +++ b/typescript/src/exceptions.ts @@ -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; + this.statusCode = statusCode; } } diff --git a/typescript/src/httpHandler.ts b/typescript/src/httpHandler.ts index 6deaa2b..a5d51c3 100644 --- a/typescript/src/httpHandler.ts +++ b/typescript/src/httpHandler.ts @@ -1,35 +1,10 @@ -import { OrchestrateClientError, OrchestrateHttpError } from "./exceptions.js"; +import { OperationOutcomeIssue, OrchestrateClientError, OrchestrateHttpError } from "./exceptions.js"; export interface IHttpHandler { post(path: string, body: TIn, headers?: { [key: string]: string; }): Promise; get(path: string, headers?: { [key: string]: string; }): Promise; } -class OperationalOutcomeIssue { - 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; - } -} - function getIssueDetailString(detail: any): string { // eslint-disable-line @typescript-eslint/no-explicit-any if (detail?.text) { return detail.text; @@ -50,12 +25,12 @@ function getDetailCodingString(coding: any): string { // eslint-disable-line @ty return parts.join(": "); } -async function readJsonOutcomes(responseText: string): Promise { +async function readJsonOutcomes(responseText: string): Promise { try { const json = JSON.parse(responseText); if (json.issue) { return json.issue.map((issue: any) => // eslint-disable-line @typescript-eslint/no-explicit-any - new OperationalOutcomeIssue( + new OperationOutcomeIssue( issue.severity || "", issue.code || "", issue.diagnostics || "", @@ -64,7 +39,7 @@ async function readJsonOutcomes(responseText: string): Promise { - const outcomes = await readJsonOutcomes(responseText); - if (outcomes.length > 0) { - return outcomes.map((o) => o.toString()); - } - return [responseText]; -} - async function errorFromResponse(response: Response): Promise { const responseText = await response.text(); - const operationalOutcomes = await readOperationalOutcomes(responseText); + const issues = await readJsonOutcomes(responseText); if (response.status >= 400 && response.status < 600) { - throw new OrchestrateClientError(responseText, operationalOutcomes); + throw new OrchestrateClientError(responseText, issues, response.status); } throw new OrchestrateHttpError(responseText); diff --git a/typescript/tests/errorParsing.test.ts b/typescript/tests/errorParsing.test.ts new file mode 100644 index 0000000..3023076 --- /dev/null +++ b/typescript/tests/errorParsing.test.ts @@ -0,0 +1,116 @@ +import { describe, it, expect, vi, afterEach } from "vitest"; +import { HttpHandler } from "../src/httpHandler"; +import { OrchestrateClientError, OperationOutcomeIssue } from "../src/exceptions"; + +const 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", + }, + }, + ], +}; + +function makeFakeResponse(payload: object, status: number) { + return { + ok: false, + status, + text: async () => JSON.stringify(payload), + }; +} + +describe("OrchestrateClientError structured issue parsing", () => { + const handler = new HttpHandler( + "https://api.example.com", + { "Content-Type": "application/json", Accept: "application/json" }, + 10_000, + ); + + afterEach(() => { + vi.unstubAllGlobals(); + }); + + async function getErrorFromFakeResponse(payload: object, status: number): Promise { + vi.stubGlobal("fetch", vi.fn().mockResolvedValue(makeFakeResponse(payload, status))); + try { + await handler.post("/convert/v1/cdatofhirr4", ""); + throw new Error("Expected OrchestrateClientError to be thrown"); + } catch (e) { + if (e instanceof OrchestrateClientError) return e; + throw e; + } + } + + it("should capture all issues from a multi-issue OperationOutcome", async () => { + const error = await getErrorFromFakeResponse(CDA_TO_FHIR_OPERATION_OUTCOME, 400); + + expect(error.issues).toHaveLength(3); + expect(error.issues[0]).toBeInstanceOf(OperationOutcomeIssue); + }); + + it("should parse an error issue with diagnostics and no details", async () => { + const error = await getErrorFromFakeResponse(CDA_TO_FHIR_OPERATION_OUTCOME, 400); + + const issue = error.issues[0]; + expect(issue.severity).toBe("error"); + expect(issue.code).toBe("invalid"); + expect(issue.diagnostics).toBe("Missing recordTarget in ClinicalDocument"); + expect(issue.details).toBe(""); + }); + + it("should extract details.text for information issues", async () => { + const error = await getErrorFromFakeResponse(CDA_TO_FHIR_OPERATION_OUTCOME, 400); + + const docIdIssue = error.issues[1]; + expect(docIdIssue.severity).toBe("information"); + expect(docIdIssue.code).toBe("informational"); + expect(docIdIssue.details).toBe("fb04306a-0834-432d-90c3-251ed7d3401d"); + expect(docIdIssue.diagnostics).toBe(""); + + const effectiveTimeIssue = error.issues[2]; + expect(effectiveTimeIssue.details).toBe("2011-05-27T01:44:27-05:00"); + }); + + it("should preserve statusCode and responseText", async () => { + const error = await getErrorFromFakeResponse(CDA_TO_FHIR_OPERATION_OUTCOME, 400); + + expect(error.statusCode).toBe(400); + expect(error.responseText).toBe(JSON.stringify(CDA_TO_FHIR_OPERATION_OUTCOME)); + }); + + it("should include all issue texts in the error message", async () => { + const error = await getErrorFromFakeResponse(CDA_TO_FHIR_OPERATION_OUTCOME, 400); + + expect(error.message).toContain("Missing recordTarget in ClinicalDocument"); + expect(error.message).toContain("fb04306a-0834-432d-90c3-251ed7d3401d"); + expect(error.message).toContain("2011-05-27T01:44:27-05:00"); + }); +}); From 2dde488ecc42361862c0972fd62a54c947a67e0b Mon Sep 17 00:00:00 2001 From: Sunanda Venumuddula Date: Fri, 17 Apr 2026 12:48:35 -0400 Subject: [PATCH 2/3] format --- .../CareEvolution.Orchestrate.Tests/ConfigurationTests.cs | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/dotnet/tests/CareEvolution.Orchestrate.Tests/ConfigurationTests.cs b/dotnet/tests/CareEvolution.Orchestrate.Tests/ConfigurationTests.cs index e67ab8b..735c023 100644 --- a/dotnet/tests/CareEvolution.Orchestrate.Tests/ConfigurationTests.cs +++ b/dotnet/tests/CareEvolution.Orchestrate.Tests/ConfigurationTests.cs @@ -331,8 +331,7 @@ public async Task MultipleIssueOperationOutcomeShouldExposeAllStructuredIssues() """; var handler = new FakeHttpMessageHandler( - (_, _) => - Task.FromResult(FakeResponses.Json(responseJson, HttpStatusCode.BadRequest)) + (_, _) => Task.FromResult(FakeResponses.Json(responseJson, HttpStatusCode.BadRequest)) ); using var httpClient = new HttpClient(handler); From d53c3835479aa20bb8a18daa5d133e5de30a9896 Mon Sep 17 00:00:00 2001 From: Sunanda Venumuddula Date: Fri, 17 Apr 2026 13:28:40 -0400 Subject: [PATCH 3/3] format --- .../CareEvolution.Orchestrate.Tests/ConfigurationTests.cs | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/dotnet/tests/CareEvolution.Orchestrate.Tests/ConfigurationTests.cs b/dotnet/tests/CareEvolution.Orchestrate.Tests/ConfigurationTests.cs index 735c023..debe605 100644 --- a/dotnet/tests/CareEvolution.Orchestrate.Tests/ConfigurationTests.cs +++ b/dotnet/tests/CareEvolution.Orchestrate.Tests/ConfigurationTests.cs @@ -356,7 +356,10 @@ public async Task MultipleIssueOperationOutcomeShouldExposeAllStructuredIssues() Assert.Equal(3, exception.Issues.Count); // Error issue: diagnostics only, no details - Assert.Contains("error: invalid - Missing recordTarget in ClinicalDocument", exception.Issues[0]); + 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]);