From 170ae32d6e1c403f6a7cbef35e5f832ce24837ef Mon Sep 17 00:00:00 2001 From: winnisx7 Date: Fri, 21 Nov 2025 17:34:05 +0900 Subject: [PATCH] Add centralized error handling through Configuration This introduces ErrorHandler protocols that allow observation and logging of errors for both client and server operations, enabling centralized error management, logging, and monitoring capabilities. ## Changes ### New Error Handler Protocols - Add `ClientErrorHandler` protocol for observing client-side errors - Add `ServerErrorHandler` protocol for observing server-side errors - Handlers receive fully-wrapped `ClientError` or `ServerError` instances - Simple observation pattern: handlers don't transform or return errors ### Configuration Integration - Add optional `clientErrorHandler` property to `Configuration` (default: `nil`) - Add optional `serverErrorHandler` property to `Configuration` (default: `nil`) - Zero overhead when not configured - no default implementations or allocations - Completely backward compatible - existing code requires no changes ### Runtime Integration - Update `UniversalClient` to call configured `ClientErrorHandler` after wrapping errors - Update `UniversalServer` to call configured `ServerErrorHandler` after wrapping errors - Error wrapping logic remains unchanged from existing behavior - Handlers are invoked only if configured, with no performance impact otherwise ### Testing - Add comprehensive unit tests for error handler protocols (3 tests) - Add integration tests for end-to-end error handling flow (9 tests) - All 218 tests pass, demonstrating full backward compatibility ## Usage Example ```swift // Custom error handler with logging struct LoggingClientErrorHandler: ClientErrorHandler { func handleClientError(_ error: ClientError) { logger.error("Client error in \(error.operationID): \(error.causeDescription)") analytics.track("client_error", metadata: [ "operation": error.operationID, "status": error.response?.status.code ]) } } // Configure client with custom handler let config = Configuration( clientErrorHandler: LoggingClientErrorHandler() ) let client = UniversalClient(configuration: config, transport: transport) ``` ## Design Rationale - **Observation-Only**: Handlers observe but don't transform errors, keeping the API simple - **RuntimeError Stays Internal**: No exposure of internal error types to public API - **Zero Overhead**: Optional handlers mean no cost when not used - **Backward Compatible**: Existing code works unchanged; opt-in for new functionality - **Type-Safe**: Protocol-based design with compile-time verification --- .../Conversion/Configuration.swift | 22 +- .../OpenAPIRuntime/Errors/ErrorHandler.swift | 54 +++ .../Interface/UniversalClient.swift | 6 +- .../Interface/UniversalServer.swift | 6 +- .../Errors/Test_ErrorHandler.swift | 134 +++++++ .../Test_ErrorHandlerIntegration.swift | 332 ++++++++++++++++++ 6 files changed, 551 insertions(+), 3 deletions(-) create mode 100644 Sources/OpenAPIRuntime/Errors/ErrorHandler.swift create mode 100644 Tests/OpenAPIRuntimeTests/Errors/Test_ErrorHandler.swift create mode 100644 Tests/OpenAPIRuntimeTests/Integration/Test_ErrorHandlerIntegration.swift diff --git a/Sources/OpenAPIRuntime/Conversion/Configuration.swift b/Sources/OpenAPIRuntime/Conversion/Configuration.swift index 2ee7ab00..97a31544 100644 --- a/Sources/OpenAPIRuntime/Conversion/Configuration.swift +++ b/Sources/OpenAPIRuntime/Conversion/Configuration.swift @@ -152,6 +152,20 @@ public struct Configuration: Sendable { /// Custom XML coder for encoding and decoding xml bodies. public var xmlCoder: (any CustomCoder)? + /// The handler for client-side errors. + /// + /// This handler is invoked after a client error has been wrapped in a ``ClientError``. + /// Use this to add logging, monitoring, or analytics for client-side errors. + /// If `nil`, errors are thrown without additional handling. + public var clientErrorHandler: (any ClientErrorHandler)? + + /// The handler for server-side errors. + /// + /// This handler is invoked after a server error has been wrapped in a ``ServerError``. + /// Use this to add logging, monitoring, or analytics for server-side errors. + /// If `nil`, errors are thrown without additional handling. + public var serverErrorHandler: (any ServerErrorHandler)? + /// Creates a new configuration with the specified values. /// /// - Parameters: @@ -160,15 +174,21 @@ public struct Configuration: Sendable { /// - jsonEncodingOptions: The options for the underlying JSON encoder. /// - multipartBoundaryGenerator: The generator to use when creating mutlipart bodies. /// - xmlCoder: Custom XML coder for encoding and decoding xml bodies. Only required when using XML body payloads. + /// - clientErrorHandler: Optional handler for observing client-side errors. Defaults to `nil`. + /// - serverErrorHandler: Optional handler for observing server-side errors. Defaults to `nil`. public init( dateTranscoder: any DateTranscoder = .iso8601, jsonEncodingOptions: JSONEncodingOptions = [.sortedKeys, .prettyPrinted], multipartBoundaryGenerator: any MultipartBoundaryGenerator = .random, - xmlCoder: (any CustomCoder)? = nil + xmlCoder: (any CustomCoder)? = nil, + clientErrorHandler: (any ClientErrorHandler)? = nil, + serverErrorHandler: (any ServerErrorHandler)? = nil ) { self.dateTranscoder = dateTranscoder self.jsonEncodingOptions = jsonEncodingOptions self.multipartBoundaryGenerator = multipartBoundaryGenerator self.xmlCoder = xmlCoder + self.clientErrorHandler = clientErrorHandler + self.serverErrorHandler = serverErrorHandler } } diff --git a/Sources/OpenAPIRuntime/Errors/ErrorHandler.swift b/Sources/OpenAPIRuntime/Errors/ErrorHandler.swift new file mode 100644 index 00000000..37e6df90 --- /dev/null +++ b/Sources/OpenAPIRuntime/Errors/ErrorHandler.swift @@ -0,0 +1,54 @@ +//===----------------------------------------------------------------------===// +// +// This source file is part of the SwiftOpenAPIGenerator open source project +// +// Copyright (c) 2023 Apple Inc. and the SwiftOpenAPIGenerator project authors +// Licensed under Apache License v2.0 +// +// See LICENSE.txt for license information +// See CONTRIBUTORS.txt for the list of SwiftOpenAPIGenerator project authors +// +// SPDX-License-Identifier: Apache-2.0 +// +//===----------------------------------------------------------------------===// +import Foundation +import HTTPTypes + +// MARK: - Client Error Handler + +/// A protocol for observing and logging errors that occur during client operations. +/// +/// Implement this protocol to add logging, monitoring, or analytics for client-side errors. +/// This handler is called after the error has been wrapped in a ``ClientError``, providing +/// full context about the operation and the error. +/// +/// - Note: This handler should not throw or modify the error. Its purpose is observation only. +public protocol ClientErrorHandler: Sendable { + /// Called when a client error occurs, after it has been wrapped in a ``ClientError``. + /// + /// Use this method to log, monitor, or send analytics about the error. The error + /// will be thrown to the caller after this method returns. + /// + /// - Parameter error: The ``ClientError`` that will be thrown to the caller. + func handleClientError(_ error: ClientError) +} + + +// MARK: - Server Error Handler + +/// A protocol for observing and logging errors that occur during server operations. +/// +/// Implement this protocol to add logging, monitoring, or analytics for server-side errors. +/// This handler is called after the error has been wrapped in a ``ServerError``, providing +/// full context about the operation and the HTTP response that will be sent. +/// +/// - Note: This handler should not throw or modify the error. Its purpose is observation only. +public protocol ServerErrorHandler: Sendable { + /// Called when a server error occurs, after it has been wrapped in a ``ServerError``. + /// + /// Use this method to log, monitor, or send analytics about the error. The error + /// will be thrown to the error handling middleware after this method returns. + /// + /// - Parameter error: The ``ServerError`` that will be thrown to the middleware. + func handleServerError(_ error: ServerError) +} diff --git a/Sources/OpenAPIRuntime/Interface/UniversalClient.swift b/Sources/OpenAPIRuntime/Interface/UniversalClient.swift index 5afff2b1..24f94d54 100644 --- a/Sources/OpenAPIRuntime/Interface/UniversalClient.swift +++ b/Sources/OpenAPIRuntime/Interface/UniversalClient.swift @@ -98,6 +98,7 @@ import struct Foundation.URL } } let baseURL = serverURL + let errorHandler = converter.configuration.clientErrorHandler @Sendable func makeError( request: HTTPRequest? = nil, requestBody: HTTPBody? = nil, @@ -112,6 +113,7 @@ import struct Foundation.URL error.baseURL = error.baseURL ?? baseURL error.response = error.response ?? response error.responseBody = error.responseBody ?? responseBody + errorHandler?.handleClientError(error) return error } let causeDescription: String @@ -123,7 +125,7 @@ import struct Foundation.URL causeDescription = "Unknown" underlyingError = error } - return ClientError( + let clientError = ClientError( operationID: operationID, operationInput: input, request: request, @@ -134,6 +136,8 @@ import struct Foundation.URL causeDescription: causeDescription, underlyingError: underlyingError ) + errorHandler?.handleClientError(clientError) + return clientError } let (request, requestBody): (HTTPRequest, HTTPBody?) = try await wrappingErrors { try serializer(input) diff --git a/Sources/OpenAPIRuntime/Interface/UniversalServer.swift b/Sources/OpenAPIRuntime/Interface/UniversalServer.swift index 2153ccea..c5b976de 100644 --- a/Sources/OpenAPIRuntime/Interface/UniversalServer.swift +++ b/Sources/OpenAPIRuntime/Interface/UniversalServer.swift @@ -102,12 +102,14 @@ import struct Foundation.URLComponents throw mapError(error) } } + let errorHandler = converter.configuration.serverErrorHandler @Sendable func makeError(input: OperationInput? = nil, output: OperationOutput? = nil, error: any Error) -> any Error { if var error = error as? ServerError { error.operationInput = error.operationInput ?? input error.operationOutput = error.operationOutput ?? output + errorHandler?.handleServerError(error) return error } let causeDescription: String @@ -136,7 +138,7 @@ import struct Foundation.URLComponents httpHeaderFields = [:] httpBody = nil } - return ServerError( + let serverError = ServerError( operationID: operationID, request: request, requestBody: requestBody, @@ -149,6 +151,8 @@ import struct Foundation.URLComponents httpHeaderFields: httpHeaderFields, httpBody: httpBody ) + errorHandler?.handleServerError(serverError) + return serverError } var next: @Sendable (HTTPRequest, HTTPBody?, ServerRequestMetadata) async throws -> (HTTPResponse, HTTPBody?) = { _request, _requestBody, _metadata in diff --git a/Tests/OpenAPIRuntimeTests/Errors/Test_ErrorHandler.swift b/Tests/OpenAPIRuntimeTests/Errors/Test_ErrorHandler.swift new file mode 100644 index 00000000..b02a2e68 --- /dev/null +++ b/Tests/OpenAPIRuntimeTests/Errors/Test_ErrorHandler.swift @@ -0,0 +1,134 @@ +//===----------------------------------------------------------------------===// +// +// This source file is part of the SwiftOpenAPIGenerator open source project +// +// Copyright (c) 2025 Apple Inc. and the SwiftOpenAPIGenerator project authors +// Licensed under Apache License v2.0 +// +// See LICENSE.txt for license information +// See CONTRIBUTORS.txt for the list of SwiftOpenAPIGenerator project authors +// +// SPDX-License-Identifier: Apache-2.0 +// +//===----------------------------------------------------------------------===// + +import HTTPTypes +@_spi(Generated) @testable import OpenAPIRuntime +import XCTest + +// MARK: - Test Helpers + +/// A custom client error handler that logs all errors for testing +final class LoggingClientErrorHandler: ClientErrorHandler { + var handledErrors: [ClientError] = [] + private let lock = NSLock() + + func handleClientError(_ error: ClientError) { + lock.lock() + handledErrors.append(error) + lock.unlock() + } +} + +/// A custom server error handler that logs all errors for testing +final class LoggingServerErrorHandler: ServerErrorHandler { + var handledErrors: [ServerError] = [] + private let lock = NSLock() + + func handleServerError(_ error: ServerError) { + lock.lock() + handledErrors.append(error) + lock.unlock() + } +} + +// MARK: - ErrorHandler Tests + +final class Test_ErrorHandler: XCTestCase { + + func testClientErrorHandler_IsCalledWithClientError() throws { + let handler = LoggingClientErrorHandler() + let clientError = ClientError( + operationID: "testOp", + operationInput: "test-input", + request: .init(soar_path: "/test", method: .get), + requestBody: nil, + baseURL: URL(string: "https://example.com"), + response: nil, + responseBody: nil, + causeDescription: "Test error", + underlyingError: NSError(domain: "test", code: 1) + ) + + handler.handleClientError(clientError) + + XCTAssertEqual(handler.handledErrors.count, 1) + XCTAssertEqual(handler.handledErrors[0].operationID, "testOp") + XCTAssertEqual(handler.handledErrors[0].causeDescription, "Test error") + } + + func testServerErrorHandler_IsCalledWithServerError() throws { + let handler = LoggingServerErrorHandler() + let serverError = ServerError( + operationID: "testOp", + request: .init(soar_path: "/test", method: .post), + requestBody: nil, + requestMetadata: .init(), + operationInput: "test-input", + operationOutput: nil, + causeDescription: "Test error", + underlyingError: NSError(domain: "test", code: 1), + httpStatus: .badRequest, + httpHeaderFields: [:], + httpBody: nil + ) + + handler.handleServerError(serverError) + + XCTAssertEqual(handler.handledErrors.count, 1) + XCTAssertEqual(handler.handledErrors[0].operationID, "testOp") + XCTAssertEqual(handler.handledErrors[0].httpStatus, .badRequest) + } + + func testMultipleErrors_AreAllLogged() throws { + let clientHandler = LoggingClientErrorHandler() + let serverHandler = LoggingServerErrorHandler() + + // Log multiple client errors + for i in 1...3 { + let error = ClientError( + operationID: "op\(i)", + operationInput: nil as String?, + request: nil, + requestBody: nil, + baseURL: nil, + response: nil, + responseBody: nil, + causeDescription: "Error \(i)", + underlyingError: NSError(domain: "test", code: i) + ) + clientHandler.handleClientError(error) + } + + // Log multiple server errors + for i in 1...3 { + let error = ServerError( + operationID: "op\(i)", + request: .init(soar_path: "/test", method: .get), + requestBody: nil, + requestMetadata: .init(), + operationInput: nil as String?, + operationOutput: nil as String?, + causeDescription: "Error \(i)", + underlyingError: NSError(domain: "test", code: i), + httpStatus: .internalServerError, + httpHeaderFields: [:], + httpBody: nil + ) + serverHandler.handleServerError(error) + } + + XCTAssertEqual(clientHandler.handledErrors.count, 3) + XCTAssertEqual(serverHandler.handledErrors.count, 3) + } +} diff --git a/Tests/OpenAPIRuntimeTests/Integration/Test_ErrorHandlerIntegration.swift b/Tests/OpenAPIRuntimeTests/Integration/Test_ErrorHandlerIntegration.swift new file mode 100644 index 00000000..ec87b2f5 --- /dev/null +++ b/Tests/OpenAPIRuntimeTests/Integration/Test_ErrorHandlerIntegration.swift @@ -0,0 +1,332 @@ +//===----------------------------------------------------------------------===// +// +// This source file is part of the SwiftOpenAPIGenerator open source project +// +// Copyright (c) 2025 Apple Inc. and the SwiftOpenAPIGenerator project authors +// Licensed under Apache License v2.0 +// +// See LICENSE.txt for license information +// See CONTRIBUTORS.txt for the list of SwiftOpenAPIGenerator project authors +// +// SPDX-License-Identifier: Apache-2.0 +// +//===----------------------------------------------------------------------===// + +import HTTPTypes +import Foundation +@_spi(Generated) @testable import OpenAPIRuntime +import XCTest + +// MARK: - Test Helpers + +/// Tracks all client errors that pass through it +final class TrackingClientErrorHandler: ClientErrorHandler { + private let lock = NSLock() + private var _handledErrors: [ClientError] = [] + + var handledErrors: [ClientError] { + lock.lock() + defer { lock.unlock() } + return _handledErrors + } + + func handleClientError(_ error: ClientError) { + lock.lock() + _handledErrors.append(error) + lock.unlock() + } +} + +/// Tracks all server errors that pass through it +final class TrackingServerErrorHandler: ServerErrorHandler { + private let lock = NSLock() + private var _handledErrors: [ServerError] = [] + + var handledErrors: [ServerError] { + lock.lock() + defer { lock.unlock() } + return _handledErrors + } + + func handleServerError(_ error: ServerError) { + lock.lock() + _handledErrors.append(error) + lock.unlock() + } +} + +/// Mock client transport for testing +struct TestClientTransport: ClientTransport { + var sendBlock: @Sendable (HTTPRequest, HTTPBody?, URL, String) async throws -> (HTTPResponse, HTTPBody?) + + func send(_ request: HTTPRequest, body: HTTPBody?, baseURL: URL, operationID: String) async throws -> ( + HTTPResponse, HTTPBody? + ) { try await sendBlock(request, body, baseURL, operationID) } + + static var successful: Self { + TestClientTransport { _, _, _, _ in (HTTPResponse(status: .ok), HTTPBody("success")) } + } + + static var failing: Self { TestClientTransport { _, _, _, _ in throw NSError(domain: "Transport", code: -1) } } +} + +/// Mock API handler for testing +struct TestAPIHandler: Sendable { + var handleBlock: @Sendable (String) async throws -> String + + func handleRequest(_ input: String) async throws -> String { try await handleBlock(input) } + + static var successful: Self { TestAPIHandler { input in "Response: \(input)" } } + + static var failing: Self { + TestAPIHandler { _ in throw NSError(domain: "Handler", code: -1, userInfo: [NSLocalizedDescriptionKey: "Handler failed"]) } + } +} + +// MARK: - Integration Tests + +final class Test_ErrorHandlerIntegration: XCTestCase { + + // MARK: Client Integration Tests + + func testUniversalClient_CallsErrorHandler_OnSerializationError() async throws { + let trackingHandler = TrackingClientErrorHandler() + let configuration = Configuration(clientErrorHandler: trackingHandler) + let client = UniversalClient(configuration: configuration, transport: TestClientTransport.successful) + + do { + _ = try await client.send( + input: "test", + forOperation: "testOp", + serializer: { _ in throw NSError(domain: "Serialization", code: 1) }, + deserializer: { _, _ in "" } + ) + XCTFail("Expected error to be thrown") + } catch { + // Verify the error handler was called + XCTAssertEqual(trackingHandler.handledErrors.count, 1) + XCTAssertEqual(trackingHandler.handledErrors[0].operationID, "testOp") + + // Verify the error is a ClientError + XCTAssertTrue(error is ClientError) + } + } + + func testUniversalClient_CallsErrorHandler_OnTransportError() async throws { + let trackingHandler = TrackingClientErrorHandler() + let configuration = Configuration(clientErrorHandler: trackingHandler) + let client = UniversalClient(configuration: configuration, transport: TestClientTransport.failing) + + do { + _ = try await client.send( + input: "test", + forOperation: "testOp", + serializer: { _ in (HTTPRequest(soar_path: "/test", method: .get), nil) }, + deserializer: { _, _ in "" } + ) + XCTFail("Expected error to be thrown") + } catch { + // Verify the error handler was called + XCTAssertEqual(trackingHandler.handledErrors.count, 1) + XCTAssertEqual(trackingHandler.handledErrors[0].operationID, "testOp") + + // Verify the error is a ClientError + guard let clientError = error as? ClientError else { + XCTFail("Expected ClientError") + return + } + + // Should be wrapped in RuntimeError.transportFailed + XCTAssertTrue(clientError.causeDescription.contains("Transport")) + } + } + + func testUniversalClient_CallsErrorHandler_OnDeserializationError() async throws { + let trackingHandler = TrackingClientErrorHandler() + let configuration = Configuration(clientErrorHandler: trackingHandler) + let client = UniversalClient(configuration: configuration, transport: TestClientTransport.successful) + + do { + _ = try await client.send( + input: "test", + forOperation: "testOp", + serializer: { _ in (HTTPRequest(soar_path: "/test", method: .get), nil) }, + deserializer: { _, _ in throw NSError(domain: "Deserialization", code: 1) } + ) + XCTFail("Expected error to be thrown") + } catch { + // Verify the error handler was called + XCTAssertEqual(trackingHandler.handledErrors.count, 1) + XCTAssertEqual(trackingHandler.handledErrors[0].operationID, "testOp") + + // Verify the error is a ClientError + XCTAssertTrue(error is ClientError) + } + } + + func testUniversalClient_DoesNotCallHandler_WhenNotConfigured() async throws { + // No custom handler in configuration + let client = UniversalClient(transport: TestClientTransport.failing) + + do { + _ = try await client.send( + input: "test", + forOperation: "testOp", + serializer: { _ in (HTTPRequest(soar_path: "/test", method: .get), nil) }, + deserializer: { _, _ in "" } + ) + XCTFail("Expected error to be thrown") + } catch { + // Should still produce a ClientError + guard let clientError = error as? ClientError else { + XCTFail("Expected ClientError") + return + } + + XCTAssertEqual(clientError.operationID, "testOp") + } + } + + // MARK: Server Integration Tests + + func testUniversalServer_CallsErrorHandler_OnDeserializationError() async throws { + let trackingHandler = TrackingServerErrorHandler() + let configuration = Configuration(serverErrorHandler: trackingHandler) + let server = UniversalServer(handler: TestAPIHandler.successful, configuration: configuration) + + do { + _ = try await server.handle( + request: HTTPRequest(soar_path: "/test", method: .post), + requestBody: nil, + metadata: ServerRequestMetadata(), + forOperation: "serverOp", + using: { handler in handler.handleRequest }, + deserializer: { _, _, _ in throw NSError(domain: "Deserialization", code: 1) }, + serializer: { output, _ in (HTTPResponse(status: .ok), HTTPBody(output)) } + ) + XCTFail("Expected error to be thrown") + } catch { + // Verify the error handler was called + XCTAssertEqual(trackingHandler.handledErrors.count, 1) + XCTAssertEqual(trackingHandler.handledErrors[0].operationID, "serverOp") + + // Verify the error is a ServerError + XCTAssertTrue(error is ServerError) + } + } + + func testUniversalServer_CallsErrorHandler_OnHandlerError() async throws { + let trackingHandler = TrackingServerErrorHandler() + let configuration = Configuration(serverErrorHandler: trackingHandler) + let server = UniversalServer(handler: TestAPIHandler.failing, configuration: configuration) + + do { + _ = try await server.handle( + request: HTTPRequest(soar_path: "/test", method: .post), + requestBody: nil, + metadata: ServerRequestMetadata(), + forOperation: "serverOp", + using: { handler in handler.handleRequest }, + deserializer: { _, _, _ in "test-input" }, + serializer: { output, _ in (HTTPResponse(status: .ok), HTTPBody(output)) } + ) + XCTFail("Expected error to be thrown") + } catch { + // Verify the error handler was called + XCTAssertEqual(trackingHandler.handledErrors.count, 1) + XCTAssertEqual(trackingHandler.handledErrors[0].operationID, "serverOp") + + // Verify the error is a ServerError + guard let serverError = error as? ServerError else { + XCTFail("Expected ServerError") + return + } + + // Should be wrapped in RuntimeError.handlerFailed + XCTAssertTrue(serverError.causeDescription.contains("handler")) + XCTAssertEqual(serverError.httpStatus, .internalServerError) + } + } + + func testUniversalServer_CallsErrorHandler_OnSerializationError() async throws { + let trackingHandler = TrackingServerErrorHandler() + let configuration = Configuration(serverErrorHandler: trackingHandler) + let server = UniversalServer(handler: TestAPIHandler.successful, configuration: configuration) + + do { + _ = try await server.handle( + request: HTTPRequest(soar_path: "/test", method: .post), + requestBody: nil, + metadata: ServerRequestMetadata(), + forOperation: "serverOp", + using: { handler in handler.handleRequest }, + deserializer: { _, _, _ in "test-input" }, + serializer: { _, _ in throw NSError(domain: "Serialization", code: 1) } + ) + XCTFail("Expected error to be thrown") + } catch { + // Verify the error handler was called + XCTAssertEqual(trackingHandler.handledErrors.count, 1) + XCTAssertEqual(trackingHandler.handledErrors[0].operationID, "serverOp") + + // Verify the error is a ServerError + XCTAssertTrue(error is ServerError) + } + } + + func testUniversalServer_DoesNotCallHandler_WhenNotConfigured() async throws { + // No custom handler in configuration + let server = UniversalServer(handler: TestAPIHandler.failing) + + do { + _ = try await server.handle( + request: HTTPRequest(soar_path: "/test", method: .post), + requestBody: nil, + metadata: ServerRequestMetadata(), + forOperation: "serverOp", + using: { handler in handler.handleRequest }, + deserializer: { _, _, _ in "test-input" }, + serializer: { output, _ in (HTTPResponse(status: .ok), HTTPBody(output)) } + ) + XCTFail("Expected error to be thrown") + } catch { + // Should still produce a ServerError + guard let serverError = error as? ServerError else { + XCTFail("Expected ServerError") + return + } + + XCTAssertEqual(serverError.operationID, "serverOp") + XCTAssertEqual(serverError.httpStatus, .internalServerError) + } + } + + // MARK: Multiple Error Handler Tests + + func testMultipleErrors_EachPassesThroughHandler() async throws { + let trackingHandler = TrackingClientErrorHandler() + let configuration = Configuration(clientErrorHandler: trackingHandler) + let client = UniversalClient(configuration: configuration, transport: TestClientTransport.failing) + + // Trigger multiple errors + for i in 1...3 { + do { + _ = try await client.send( + input: "test-\(i)", + forOperation: "testOp\(i)", + serializer: { _ in (HTTPRequest(soar_path: "/test", method: .get), nil) }, + deserializer: { _, _ in "" } + ) + XCTFail("Expected error to be thrown") + } catch { + // Expected + } + } + + // Verify all errors were tracked + XCTAssertEqual(trackingHandler.handledErrors.count, 3) + XCTAssertEqual(trackingHandler.handledErrors[0].operationID, "testOp1") + XCTAssertEqual(trackingHandler.handledErrors[1].operationID, "testOp2") + XCTAssertEqual(trackingHandler.handledErrors[2].operationID, "testOp3") + } +}