From a63293151ad836bfaac6a434d2b756bba62b9cf1 Mon Sep 17 00:00:00 2001 From: Matt Van Horn <455140+mvanhorn@users.noreply.github.com> Date: Wed, 14 Oct 2026 04:04:12 -0700 Subject: [PATCH 1/5] fix: check the HTTP status code for JSON responses All four URLResponseHandler entry points, JSON and raw, with and without an error argument, now take the body from interceptedData. Middleware runs first, a transport error is rethrown unchanged, and throwIfErrorStatus then treats a status of 400 or above as a decoded APIErrorResponse or GeminiAPIErrorResponse, or as OpenAIError.statusError when the body is neither. The body is returned only after that check, and a missing body throws OpenAIError.emptyData; JSON decoding still happens next, so a 2xx payload that is an API or Gemini error document still surfaces that error. unwrap is removed, the changelog records the new JSON error behavior, and tests cover completion-handler, async, and Combine calls for an API error body, an HTML body, relaxed partial payloads, a 200 error document, a transport failure, and Gemini error shapes. Fixes #469 --- CHANGELOG.md | 1 + .../OpenAI/Private/URLResponseHandler.swift | 55 ++-- .../OpenAITests/JSONResponseStatusTests.swift | 234 ++++++++++++++++++ 3 files changed, 260 insertions(+), 30 deletions(-) create mode 100644 Tests/OpenAITests/JSONResponseStatusTests.swift diff --git a/CHANGELOG.md b/CHANGELOG.md index 03d441a4..e37c1cca 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -20,6 +20,7 @@ Compatibility promise: the public API is additive-only. Anything `public` is dep ### Fixed - `audioCreateSpeech` and `files.content(id:)` returned an API error response's JSON body as if it were the audio or file content, because responses read as raw data never had their HTTP status checked. They now throw for a status of 400 or above: the decoded `APIErrorResponse`, or `OpenAIError.statusError` when the body isn't an API error, as streaming requests already did ([#401](https://github.com/MacPaw/OpenAI/pull/401)). +- JSON requests never checked the HTTP status, so an HTML gateway body surfaced as a `DecodingError`, and with `ParsingOptions.relaxed` a status of 400 or above whose body was missing required fields could decode as a successful result. Completion-handler, async, and Combine JSON calls now throw for a status of 400 or above, as raw and streaming calls already did: the decoded `APIErrorResponse` or `GeminiAPIErrorResponse`, or `OpenAIError.statusError` when the body isn't an API error. ## [0.5.3] - 2026-10-08 diff --git a/Sources/OpenAI/Private/URLResponseHandler.swift b/Sources/OpenAI/Private/URLResponseHandler.swift index 07240f6f..d391c956 100644 --- a/Sources/OpenAI/Private/URLResponseHandler.swift +++ b/Sources/OpenAI/Private/URLResponseHandler.swift @@ -20,30 +20,19 @@ struct URLResponseHandler { } func interceptAndDecode(response: URLResponse, urlRequest: URLRequest, responseData data: Data) throws -> ResultType { - let interceptedData = intercept(response: response, data: data, request: urlRequest) - return try decodeJson(data: interceptedData ?? data) + try decodeJson(data: try interceptedData(response: response, urlRequest: urlRequest, error: nil, responseData: data)) } func interceptAndDecode(response: URLResponse?, urlRequest: URLRequest, error: Error?, responseData data: Data?) throws -> ResultType { - let interceptedData = intercept(response: response, data: data, request: urlRequest) - let unwrappedData = try self.unwrap(error: error, originalData: data, interceptedData: interceptedData) - return try decodeJson(data: unwrappedData) + try decodeJson(data: try interceptedData(response: response, urlRequest: urlRequest, error: error, responseData: data)) } func interceptAndDecodeRaw(response: URLResponse, urlRequest: URLRequest, responseData data: Data) throws -> Data { - let interceptedData = intercept(response: response, data: data, request: urlRequest) - let finalData = interceptedData ?? data - try throwIfErrorStatus(response: response, data: finalData) - return finalData + try interceptedData(response: response, urlRequest: urlRequest, error: nil, responseData: data) } func interceptAndDecodeRaw(response: URLResponse?, urlRequest: URLRequest, error: Error?, responseData data: Data?) throws -> Data { - let interceptedData = intercept(response: response, data: data, request: urlRequest) - if let error { - throw error - } - try throwIfErrorStatus(response: response, data: interceptedData ?? data) - return try unwrap(error: nil, originalData: data, interceptedData: interceptedData) + try interceptedData(response: response, urlRequest: urlRequest, error: error, responseData: data) } private func decodeJson(data: Data) throws -> ResultType { @@ -51,8 +40,27 @@ struct URLResponseHandler { return try jsonDecoder.decodeResponseData(data) } - /// Raw responses aren't decoded as JSON, so unlike JSON requests they can't fall back to decoding an API error - /// when the expected type fails to decode; check the status code instead, as `StreamingSession` does. + /// Response middleware runs first, then a transport error is thrown unchanged. An HTTP status of 400 or above + /// is an error for every JSON and raw response: the decoded API or Gemini error, or `OpenAIError.statusError`. + /// The body is returned only after that check, or `OpenAIError.emptyData` when there is none. JSON callers + /// decode that body next, so a 2xx that isn't the result type still falls back to an API or Gemini error. + private func interceptedData(response: URLResponse?, urlRequest: URLRequest, error: Error?, responseData data: Data?) throws -> Data { + let middlewareData = intercept(response: response, data: data, request: urlRequest) + if let error { + throw error + } + + let body = middlewareData ?? data + try throwIfErrorStatus(response: response, data: body) + guard let body else { + throw OpenAIError.emptyData + } + + return body + } + + /// Status >= 400, matching `StreamingSession`: throw a decoded API or Gemini error when the body has one, + /// otherwise `OpenAIError.statusError`. Statuses below 400 are not an error here. private func throwIfErrorStatus(response: URLResponse?, data: Data?) throws { guard let httpResponse = response as? HTTPURLResponse, httpResponse.statusCode >= 400 else { return @@ -71,17 +79,4 @@ struct URLResponseHandler { return interceptedData } - - private func unwrap(error: Error?, originalData: Data?, interceptedData: Data?) throws -> Data { - if let error { - throw error - } - - let finalData = interceptedData ?? originalData - guard let finalData else { - throw OpenAIError.emptyData - } - - return finalData - } } diff --git a/Tests/OpenAITests/JSONResponseStatusTests.swift b/Tests/OpenAITests/JSONResponseStatusTests.swift new file mode 100644 index 00000000..5a92960e --- /dev/null +++ b/Tests/OpenAITests/JSONResponseStatusTests.swift @@ -0,0 +1,234 @@ +// +// JSONResponseStatusTests.swift +// OpenAI +// + +import XCTest +#if canImport(FoundationNetworking) +import FoundationNetworking +#endif +@testable import OpenAI + +final class JSONResponseStatusTests: XCTestCase { + + private var openAI: OpenAIProtocol! + private let urlSession = URLSessionMock() + + override func setUp() async throws { + let configuration = OpenAI.Configuration(token: "foo", organizationIdentifier: "bar", timeoutInterval: 14) + self.openAI = OpenAI(configuration: configuration, session: self.urlSession, streamingSessionFactory: MockStreamingSessionFactory()) + } + + func testAsyncChatsThrowsAPIErrorForErrorStatus() async throws { + urlSession.dataTask = .errorResponse(statusCode: 400, data: Self.apiErrorBody) + + do { + _ = try await openAI.chats(query: chatQuery()) + XCTFail("Expected an error") + } catch let error as APIErrorResponse { + XCTAssertEqual(error.error.message, "Invalid request") + XCTAssertEqual(error.error.type, "invalid_request_error") + } + } + + func testCompletionChatsThrowsAPIErrorForErrorStatus() throws { + urlSession.dataTask = .errorResponse(statusCode: 400, data: Self.apiErrorBody) + + let expectation = expectation(description: "chats completes") + let result = UncheckedBox>() + openAI.chats(query: chatQuery()) { + result.value = $0 + expectation.fulfill() + } + wait(for: [expectation], timeout: 1) + + XCTAssertThrowsError(try result.value?.get()) { error in + guard let apiError = error as? APIErrorResponse else { + return XCTFail("Expected APIErrorResponse, got \(error)") + } + XCTAssertEqual(apiError.error.message, "Invalid request") + XCTAssertEqual(apiError.error.type, "invalid_request_error") + } + } + + func testAsyncChatsThrowsStatusErrorForHTMLBody() async throws { + urlSession.dataTask = .errorResponse(statusCode: 502, data: Data("Bad Gateway".utf8)) + + do { + _ = try await openAI.chats(query: chatQuery()) + XCTFail("Expected an error") + } catch OpenAIError.statusError(_, let statusCode) { + XCTAssertEqual(statusCode, 502) + } + } + + func testCompletionChatsThrowsStatusErrorForHTMLBody() throws { + urlSession.dataTask = .errorResponse(statusCode: 502, data: Data("Bad Gateway".utf8)) + + let expectation = expectation(description: "chats completes") + let result = UncheckedBox>() + openAI.chats(query: chatQuery()) { + result.value = $0 + expectation.fulfill() + } + wait(for: [expectation], timeout: 1) + + XCTAssertThrowsError(try result.value?.get()) { error in + guard case let .statusError(_, statusCode)? = error as? OpenAIError else { + return XCTFail("Expected OpenAIError.statusError, got \(error)") + } + XCTAssertEqual(statusCode, 502) + } + } + + /// `.relaxed` fills the missing `id`, `object`, and `model` strings, so this body decodes as `ChatResult`. + /// A status of 400 must still be an error. `created` and `choices` are not filled by `.relaxed`. + func testRelaxedAsyncChatsThrowsStatusErrorForPartialBody() async throws { + let openAI = makeOpenAI(parsingOptions: .relaxed) + urlSession.dataTask = .errorResponse(statusCode: 400, data: Data(#"{"created":1,"choices":[]}"#.utf8)) + + do { + let result = try await openAI.chats(query: chatQuery()) + XCTFail("Expected an error, got \(result)") + } catch OpenAIError.statusError(_, let statusCode) { + XCTAssertEqual(statusCode, 400) + } + } + + /// A status below 400 still decodes. `ModelResult` fills missing `created`, `object`, and `owned_by` only under `.relaxed`. + func testRelaxedAsyncModelDecodesPartialBodyOnSuccessStatus() async throws { + let openAI = makeOpenAI(parsingOptions: .relaxed) + urlSession.dataTask = .errorResponse(statusCode: 200, data: Data(#"{"id":"gemini-2.0-flash"}"#.utf8)) + + let result = try await openAI.model(query: ModelQuery(model: .gpt3_5Turbo)) + XCTAssertEqual(result, ModelResult(id: "gemini-2.0-flash", created: 0, object: "model", ownedBy: "")) + } + + /// The status guard applies only at 400 and above. A 200 error document still throws through `JSONResponseDecoder`. + func testAsyncChatsThrowsAPIErrorForErrorDocumentOnSuccessStatus() async throws { + urlSession.dataTask = .errorResponse(statusCode: 200, data: Self.apiErrorBody) + + do { + _ = try await openAI.chats(query: chatQuery()) + XCTFail("Expected an error") + } catch let error as APIErrorResponse { + XCTAssertEqual(error.error.message, "Invalid request") + XCTAssertEqual(error.error.type, "invalid_request_error") + } + } + + /// `DataTaskMock.failed` sets a transport error and status 503 with nil data. The transport error is thrown. + func testCompletionChatsThrowsTransportError() throws { + let transportError = URLError(.cannotConnectToHost) + urlSession.dataTask = .failed(with: transportError) + + let expectation = expectation(description: "chats completes") + let result = UncheckedBox>() + openAI.chats(query: chatQuery()) { + result.value = $0 + expectation.fulfill() + } + wait(for: [expectation], timeout: 1) + + XCTAssertThrowsError(try result.value?.get()) { error in + XCTAssertEqual(error as? URLError, transportError) + } + } + + func testAsyncChatsThrowsGeminiAPIErrorForErrorStatus() async throws { + let body = Data(#"[{"error":{"code":400,"message":"Request contains an invalid argument.","status":"INVALID_ARGUMENT"}}]"#.utf8) + urlSession.dataTask = .errorResponse(statusCode: 400, data: body) + + do { + _ = try await openAI.chats(query: chatQuery()) + XCTFail("Expected an error") + } catch let error as GeminiAPIErrorResponse { + XCTAssertEqual(error.error.code, 400) + XCTAssertEqual(error.error.message, "Request contains an invalid argument.") + XCTAssertEqual(error.error.status, "INVALID_ARGUMENT") + } + } + + /// A single Gemini error object is not the array `JSONResponseErrorDecoder` accepts, and it is not an + /// `APIErrorResponse` (`type` is required), so a 400 falls through to `OpenAIError.statusError`. + func testAsyncChatsThrowsStatusErrorForSingleGeminiErrorObject() async throws { + let body = Data(#"{"error":{"code":400,"message":"Request contains an invalid argument.","status":"INVALID_ARGUMENT"}}"#.utf8) + urlSession.dataTask = .errorResponse(statusCode: 400, data: body) + + do { + _ = try await openAI.chats(query: chatQuery()) + XCTFail("Expected an error") + } catch OpenAIError.statusError(_, let statusCode) { + XCTAssertEqual(statusCode, 400) + } + } + +#if canImport(Combine) + @MainActor + func testCombineChatsThrowsAPIErrorForErrorStatus() throws { + let urlSession = URLSessionMockCombine() + let openAI = OpenAI( + configuration: .init(token: "foo", organizationIdentifier: "bar", timeoutInterval: 14), + session: urlSession, + streamingSessionFactory: MockStreamingSessionFactory() + ) + urlSession.dataTask = .errorResponse(statusCode: 400, data: Self.apiErrorBody) + + XCTAssertThrowsError(try awaitPublisher(openAI.chats(query: chatQuery()))) { error in + guard let apiError = error as? APIErrorResponse else { + return XCTFail("Expected APIErrorResponse, got \(error)") + } + XCTAssertEqual(apiError.error.message, "Invalid request") + XCTAssertEqual(apiError.error.type, "invalid_request_error") + } + } + + @MainActor + func testCombineChatsThrowsStatusErrorForHTMLBody() throws { + let urlSession = URLSessionMockCombine() + let openAI = OpenAI( + configuration: .init(token: "foo", organizationIdentifier: "bar", timeoutInterval: 14), + session: urlSession, + streamingSessionFactory: MockStreamingSessionFactory() + ) + urlSession.dataTask = .errorResponse(statusCode: 502, data: Data("Bad Gateway".utf8)) + + XCTAssertThrowsError(try awaitPublisher(openAI.chats(query: chatQuery()))) { error in + guard case let .statusError(_, statusCode)? = error as? OpenAIError else { + return XCTFail("Expected OpenAIError.statusError, got \(error)") + } + XCTAssertEqual(statusCode, 502) + } + } +#endif + + private func makeOpenAI(parsingOptions: ParsingOptions) -> OpenAI { + let configuration = OpenAI.Configuration( + token: "foo", + organizationIdentifier: "bar", + timeoutInterval: 14, + parsingOptions: parsingOptions + ) + return OpenAI(configuration: configuration, session: urlSession, streamingSessionFactory: MockStreamingSessionFactory()) + } + + private func chatQuery() -> ChatQuery { + ChatQuery(messages: [.user(.init(content: .string("Hi")))], model: .gpt3_5Turbo) + } + + /// An OpenAI error document: `error.message` and `error.type`. + private static let apiErrorBody = Data(""" + { + "error": { + "message": "Invalid request", + "type": "invalid_request_error", + "param": null, + "code": null + } + } + """.utf8) +} + +private final class UncheckedBox: @unchecked Sendable { + var value: Value? +} From 2d25632751ccfb982546d1b9b91c95d7f72efd28 Mon Sep 17 00:00:00 2001 From: Matt Van Horn <455140+mvanhorn@users.noreply.github.com> Date: Tue, 3 Nov 2026 22:30:56 -0800 Subject: [PATCH 2/5] fix: check the status of the response middleware returns URLResponseHandler.intercept now returns both the response and the data the middlewares return, and the status check reads that response. Before, a middleware's response was dropped and the original status was checked. Adds tests for a middleware that sets an error status and one that clears it. --- .../OpenAI/Private/URLResponseHandler.swift | 18 ++++---- .../OpenAITests/JSONResponseStatusTests.swift | 42 ++++++++++++++++++- 2 files changed, 48 insertions(+), 12 deletions(-) diff --git a/Sources/OpenAI/Private/URLResponseHandler.swift b/Sources/OpenAI/Private/URLResponseHandler.swift index d391c956..374a065f 100644 --- a/Sources/OpenAI/Private/URLResponseHandler.swift +++ b/Sources/OpenAI/Private/URLResponseHandler.swift @@ -40,18 +40,18 @@ struct URLResponseHandler { return try jsonDecoder.decodeResponseData(data) } - /// Response middleware runs first, then a transport error is thrown unchanged. An HTTP status of 400 or above - /// is an error for every JSON and raw response: the decoded API or Gemini error, or `OpenAIError.statusError`. - /// The body is returned only after that check, or `OpenAIError.emptyData` when there is none. JSON callers + /// Response middleware runs first, then a transport error is thrown unchanged. The status check reads the + /// response and body the middleware returns. An HTTP status of 400 or above is an error for every JSON and raw + /// response: the decoded API or Gemini error, or `OpenAIError.statusError`. The body is returned only after that check, or `OpenAIError.emptyData` when there is none. JSON callers /// decode that body next, so a 2xx that isn't the result type still falls back to an API or Gemini error. private func interceptedData(response: URLResponse?, urlRequest: URLRequest, error: Error?, responseData data: Data?) throws -> Data { - let middlewareData = intercept(response: response, data: data, request: urlRequest) + let intercepted = intercept(response: response, data: data, request: urlRequest) if let error { throw error } - let body = middlewareData ?? data - try throwIfErrorStatus(response: response, data: body) + let body = intercepted.data ?? data + try throwIfErrorStatus(response: intercepted.response ?? response, data: body) guard let body else { throw OpenAIError.emptyData } @@ -72,11 +72,9 @@ struct URLResponseHandler { throw OpenAIError.statusError(response: httpResponse, statusCode: httpResponse.statusCode) } - private func intercept(response: URLResponse?, data: Data?, request: URLRequest) -> Data? { - let (_, interceptedData) = self.middlewares.reduce((response, data)) { current, middleware in + private func intercept(response: URLResponse?, data: Data?, request: URLRequest) -> (response: URLResponse?, data: Data?) { + self.middlewares.reduce((response, data)) { current, middleware in middleware.intercept(response: current.response, request: request, data: current.data) } - - return interceptedData } } diff --git a/Tests/OpenAITests/JSONResponseStatusTests.swift b/Tests/OpenAITests/JSONResponseStatusTests.swift index 5a92960e..395e6e88 100644 --- a/Tests/OpenAITests/JSONResponseStatusTests.swift +++ b/Tests/OpenAITests/JSONResponseStatusTests.swift @@ -163,6 +163,29 @@ final class JSONResponseStatusTests: XCTestCase { } } + /// The status check reads the response a middleware returns, not the original one. + func testAsyncChatsThrowsStatusErrorForMiddlewareErrorStatus() async throws { + let openAI = makeOpenAI(middlewares: [StatusOverrideMiddleware(statusCode: 503)]) + urlSession.dataTask = .errorResponse(statusCode: 200, data: Data("Unavailable".utf8)) + + do { + let result = try await openAI.chats(query: chatQuery()) + XCTFail("Expected an error, got \(result)") + } catch OpenAIError.statusError(_, let statusCode) { + XCTAssertEqual(statusCode, 503) + } + } + + /// A middleware that turns an error status into a success lets the body decode. + func testAsyncModelDecodesWhenMiddlewareReturnsSuccessStatus() async throws { + let openAI = makeOpenAI(middlewares: [StatusOverrideMiddleware(statusCode: 200)]) + let body = Data(#"{"id":"gpt-3.5-turbo","created":1,"object":"model","owned_by":"openai"}"#.utf8) + urlSession.dataTask = .errorResponse(statusCode: 500, data: body) + + let result = try await openAI.model(query: ModelQuery(model: .gpt3_5Turbo)) + XCTAssertEqual(result, ModelResult(id: "gpt-3.5-turbo", created: 1, object: "model", ownedBy: "openai")) + } + #if canImport(Combine) @MainActor func testCombineChatsThrowsAPIErrorForErrorStatus() throws { @@ -202,14 +225,19 @@ final class JSONResponseStatusTests: XCTestCase { } #endif - private func makeOpenAI(parsingOptions: ParsingOptions) -> OpenAI { + private func makeOpenAI(parsingOptions: ParsingOptions = [], middlewares: [OpenAIMiddleware] = []) -> OpenAI { let configuration = OpenAI.Configuration( token: "foo", organizationIdentifier: "bar", timeoutInterval: 14, parsingOptions: parsingOptions ) - return OpenAI(configuration: configuration, session: urlSession, streamingSessionFactory: MockStreamingSessionFactory()) + return OpenAI( + configuration: configuration, + session: urlSession, + streamingSessionFactory: MockStreamingSessionFactory(), + middlewares: middlewares + ) } private func chatQuery() -> ChatQuery { @@ -229,6 +257,16 @@ final class JSONResponseStatusTests: XCTestCase { """.utf8) } +/// Replaces the HTTP status of every response it sees. +private struct StatusOverrideMiddleware: OpenAIMiddleware { + let statusCode: Int + + func intercept(response: URLResponse?, request: URLRequest, data: Data?) -> (response: URLResponse?, data: Data?) { + let url = response?.url ?? URL(fileURLWithPath: "") + return (HTTPURLResponse(url: url, statusCode: statusCode, httpVersion: nil, headerFields: nil), data) + } +} + private final class UncheckedBox: @unchecked Sendable { var value: Value? } From 9524d95da609ccb404c92c80477404feb03cfc26 Mon Sep 17 00:00:00 2001 From: Matt Van Horn <455140+mvanhorn@users.noreply.github.com> Date: Tue, 3 Nov 2026 22:36:17 -0800 Subject: [PATCH 3/5] test: share UncheckedBox between test files Moves the UncheckedBox copies in FilesEndpointTests and JSONResponseStatusTests into one helper in Tests/OpenAITests/Mocks. --- Tests/OpenAITests/FilesEndpointTests.swift | 4 ---- Tests/OpenAITests/JSONResponseStatusTests.swift | 4 ---- Tests/OpenAITests/Mocks/UncheckedBox.swift | 9 +++++++++ 3 files changed, 9 insertions(+), 8 deletions(-) create mode 100644 Tests/OpenAITests/Mocks/UncheckedBox.swift diff --git a/Tests/OpenAITests/FilesEndpointTests.swift b/Tests/OpenAITests/FilesEndpointTests.swift index e0666e23..ca11d083 100644 --- a/Tests/OpenAITests/FilesEndpointTests.swift +++ b/Tests/OpenAITests/FilesEndpointTests.swift @@ -305,10 +305,6 @@ extension FilesEndpointTests { """.utf8) } -private final class UncheckedBox: @unchecked Sendable { - var value: Value? -} - extension OpenAIFile { static func mock(id: String = "file-abc123") -> Self { .init( diff --git a/Tests/OpenAITests/JSONResponseStatusTests.swift b/Tests/OpenAITests/JSONResponseStatusTests.swift index 395e6e88..a3c43352 100644 --- a/Tests/OpenAITests/JSONResponseStatusTests.swift +++ b/Tests/OpenAITests/JSONResponseStatusTests.swift @@ -266,7 +266,3 @@ private struct StatusOverrideMiddleware: OpenAIMiddleware { return (HTTPURLResponse(url: url, statusCode: statusCode, httpVersion: nil, headerFields: nil), data) } } - -private final class UncheckedBox: @unchecked Sendable { - var value: Value? -} diff --git a/Tests/OpenAITests/Mocks/UncheckedBox.swift b/Tests/OpenAITests/Mocks/UncheckedBox.swift new file mode 100644 index 00000000..c4975b5a --- /dev/null +++ b/Tests/OpenAITests/Mocks/UncheckedBox.swift @@ -0,0 +1,9 @@ +// +// UncheckedBox.swift +// OpenAI +// + +/// A mutable box that test callbacks can write a result into. +final class UncheckedBox: @unchecked Sendable { + var value: Value? +} From 8985ec7f09f99e4f8681eb7af335302b79f89951 Mon Sep 17 00:00:00 2001 From: Matt Van Horn <455140+mvanhorn@users.noreply.github.com> Date: Tue, 3 Nov 2026 22:41:10 -0800 Subject: [PATCH 4/5] refactor: rename interceptedData to interceptAndValidate The helper runs the response middlewares and checks the status, so the name now says both. Also drop the redundant inner try in the two JSON interceptAndDecode overloads. --- Sources/OpenAI/Private/URLResponseHandler.swift | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) diff --git a/Sources/OpenAI/Private/URLResponseHandler.swift b/Sources/OpenAI/Private/URLResponseHandler.swift index 374a065f..0830f636 100644 --- a/Sources/OpenAI/Private/URLResponseHandler.swift +++ b/Sources/OpenAI/Private/URLResponseHandler.swift @@ -20,19 +20,19 @@ struct URLResponseHandler { } func interceptAndDecode(response: URLResponse, urlRequest: URLRequest, responseData data: Data) throws -> ResultType { - try decodeJson(data: try interceptedData(response: response, urlRequest: urlRequest, error: nil, responseData: data)) + try decodeJson(data: interceptAndValidate(response: response, urlRequest: urlRequest, error: nil, responseData: data)) } func interceptAndDecode(response: URLResponse?, urlRequest: URLRequest, error: Error?, responseData data: Data?) throws -> ResultType { - try decodeJson(data: try interceptedData(response: response, urlRequest: urlRequest, error: error, responseData: data)) + try decodeJson(data: interceptAndValidate(response: response, urlRequest: urlRequest, error: error, responseData: data)) } func interceptAndDecodeRaw(response: URLResponse, urlRequest: URLRequest, responseData data: Data) throws -> Data { - try interceptedData(response: response, urlRequest: urlRequest, error: nil, responseData: data) + try interceptAndValidate(response: response, urlRequest: urlRequest, error: nil, responseData: data) } func interceptAndDecodeRaw(response: URLResponse?, urlRequest: URLRequest, error: Error?, responseData data: Data?) throws -> Data { - try interceptedData(response: response, urlRequest: urlRequest, error: error, responseData: data) + try interceptAndValidate(response: response, urlRequest: urlRequest, error: error, responseData: data) } private func decodeJson(data: Data) throws -> ResultType { @@ -44,7 +44,7 @@ struct URLResponseHandler { /// response and body the middleware returns. An HTTP status of 400 or above is an error for every JSON and raw /// response: the decoded API or Gemini error, or `OpenAIError.statusError`. The body is returned only after that check, or `OpenAIError.emptyData` when there is none. JSON callers /// decode that body next, so a 2xx that isn't the result type still falls back to an API or Gemini error. - private func interceptedData(response: URLResponse?, urlRequest: URLRequest, error: Error?, responseData data: Data?) throws -> Data { + private func interceptAndValidate(response: URLResponse?, urlRequest: URLRequest, error: Error?, responseData data: Data?) throws -> Data { let intercepted = intercept(response: response, data: data, request: urlRequest) if let error { throw error From 64d43ac20b02d87c0395d601e7937ab8f88dc33f Mon Sep 17 00:00:00 2001 From: Matt Van Horn <455140+mvanhorn@users.noreply.github.com> Date: Tue, 3 Nov 2026 22:46:07 -0800 Subject: [PATCH 5/5] test: use .successful(with:) for 200 responses The JSONResponseStatusTests cases that stub a 200 status now use .successful(with:) instead of .errorResponse(statusCode: 200, ...), so the stub no longer reads as an error response. Cases with a real error status are unchanged. --- Tests/OpenAITests/JSONResponseStatusTests.swift | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/Tests/OpenAITests/JSONResponseStatusTests.swift b/Tests/OpenAITests/JSONResponseStatusTests.swift index a3c43352..cfed4b16 100644 --- a/Tests/OpenAITests/JSONResponseStatusTests.swift +++ b/Tests/OpenAITests/JSONResponseStatusTests.swift @@ -98,7 +98,7 @@ final class JSONResponseStatusTests: XCTestCase { /// A status below 400 still decodes. `ModelResult` fills missing `created`, `object`, and `owned_by` only under `.relaxed`. func testRelaxedAsyncModelDecodesPartialBodyOnSuccessStatus() async throws { let openAI = makeOpenAI(parsingOptions: .relaxed) - urlSession.dataTask = .errorResponse(statusCode: 200, data: Data(#"{"id":"gemini-2.0-flash"}"#.utf8)) + urlSession.dataTask = .successful(with: Data(#"{"id":"gemini-2.0-flash"}"#.utf8)) let result = try await openAI.model(query: ModelQuery(model: .gpt3_5Turbo)) XCTAssertEqual(result, ModelResult(id: "gemini-2.0-flash", created: 0, object: "model", ownedBy: "")) @@ -106,7 +106,7 @@ final class JSONResponseStatusTests: XCTestCase { /// The status guard applies only at 400 and above. A 200 error document still throws through `JSONResponseDecoder`. func testAsyncChatsThrowsAPIErrorForErrorDocumentOnSuccessStatus() async throws { - urlSession.dataTask = .errorResponse(statusCode: 200, data: Self.apiErrorBody) + urlSession.dataTask = .successful(with: Self.apiErrorBody) do { _ = try await openAI.chats(query: chatQuery()) @@ -166,7 +166,7 @@ final class JSONResponseStatusTests: XCTestCase { /// The status check reads the response a middleware returns, not the original one. func testAsyncChatsThrowsStatusErrorForMiddlewareErrorStatus() async throws { let openAI = makeOpenAI(middlewares: [StatusOverrideMiddleware(statusCode: 503)]) - urlSession.dataTask = .errorResponse(statusCode: 200, data: Data("Unavailable".utf8)) + urlSession.dataTask = .successful(with: Data("Unavailable".utf8)) do { let result = try await openAI.chats(query: chatQuery())