Skip to content

Feat: Add HTTPResponseError for HTTP failures, including streaming - #490

Closed
luis-silva-ms wants to merge 2 commits into
MacPaw:mainfrom
luis-silva-ms:bug/expose-http-response-errors
Closed

luis-silva-ms wants to merge 2 commits into
MacPaw:mainfrom
luis-silva-ms:bug/expose-http-response-errors

Conversation

@luis-silva-ms

@luis-silva-ms luis-silva-ms commented Nov 11, 2026 •

Copy link
Copy Markdown

TL;DR: HTTP failures (status >= 400) from JSON, raw-data and streaming requests now throw a single HTTPResponseError. It carries the status code and the raw body, so callers can read error JSON that the SDK doesn't decode, such as a gateway's own error format. It also carries the response and the decoded API error when there is one. Streaming failures now also go through response middleware.
There is no source break, but behaviour changes at runtime: code that catches APIErrorResponse, GeminiAPIErrorResponse or OpenAIError.statusError for HTTP failures no longer matches them.

What

HTTP status failures (status >= 400) now throw a single public error type, HTTPResponseError, for JSON, raw-data and streaming requests.

HTTPResponseError exposes:

  • statusCode and response (HTTPURLResponse)
  • body: the raw error body (Data), kept even when the SDK can't decode it: for example a gateway's custom error JSON, or a non-JSON body
  • isBodyTruncated: streaming error bodies are capped at 256 KiB
  • apiError: the decoded APIErrorResponse / GeminiAPIErrorResponse when the body is one
  • underlyingError: a transport error that arrived with the failing response

Streaming (StreamingSession) changes:

  • Response middleware now runs for HTTP failures, matching non-streaming requests.
  • SSE/stream middleware is skipped for error bodies, which aren't SSE.
  • The collected error body is capped at 256 KiB.
  • Cancellation (CancellationError / URLError.cancelled) is rethrown as is, not wrapped.

Non-streaming (URLResponseHandler): keeps #471's flow (middleware first, status read from the middleware-returned response). It now throws HTTPResponseError instead of APIErrorResponse / GeminiAPIErrorResponse / OpenAIError.statusError. Cancellation is rethrown first.

Also updated: README ("HTTP failures" section), CHANGELOG (Unreleased), and the Demo's 400/401/429 hints in ResponsesStore.

Why

When a request fails at the HTTP level, callers often can't read the error:

  • Custom error JSON is lost. Gateways and OpenAI-compatible servers often return their own error JSON, for example {"error":{"code":"…","message":"…"}} without type, or a flat {"code":"…","message":"…"}. APIError requires type, so decoding fails. The caller then gets OpenAIError.statusError with only the status code, and the code and message are lost. Non-JSON bodies (HTML, plain text) are lost the same way.
  • Middleware can't work around it for streams. Streaming failures skip response middleware, so a middleware can't read the status code and body of a failed stream.
  • The status code is hard to find. Callers must match several error types to get it, and the streaming and non-streaming paths differ.

One error type with the status code and the raw body lets callers decode their own error JSON, on streaming and non-streaming requests alike. We need this in a production iOS app that calls OpenAI through an API gateway. The app reads:

  • the status code, to refresh auth on 401 and decide whether to retry;
  • the gateway's JSON code and message, to detect quota errors and show a message.

Related: #287 (streaming status errors), #469 / #471 (non-streaming status check), #484 (single Gemini error object, not addressed here).

⚠️ Behaviour change

There is no source break: swift package diagnose-api-breaking-changes 0.6.0 reports no breaking changes. Only a public type is added.

It is a runtime behaviour change. Code that catches APIErrorResponse, GeminiAPIErrorResponse or OpenAIError.statusError for HTTP failures will silently stop matching. It should catch HTTPResponseError and read apiError / statusCode instead. Errors sent inside a successful (2xx) stream keep their existing types.

I'm opening this as a draft to check the direction first. If you'd rather avoid the behaviour change, an additive variant is possible. For example, keep the current thrown types and attach the raw body and response to them. I'm happy to rework it.

Affected Areas

  • Sources/OpenAI/Public/Errors/HTTPResponseError.swift: new public type
  • Sources/OpenAI/Private/URLResponseHandler.swift: non-streaming error path
  • Sources/OpenAI/Private/Streaming/StreamingSession.swift: streaming error path
  • Tests:
  • README, CHANGELOG, Demo (ResponsesStore status hints)

Validation

  • swift build: ✅ passed
  • swift test: ✅ passed (277 XCTest, 0 failures; 35 Swift Testing tests passed)
  • xcodebuild -project Demo/Demo.xcodeproj -scheme Demo -destination "generic/platform=iOS Simulator" build: ✅ BUILD SUCCEEDED
  • swift package diagnose-api-breaking-changes 0.6.0: ✅ No breaking changes detected in OpenAI
  • Linux: not run locally. New and changed files that use HTTPURLResponse / URLRequest import FoundationNetworking under #if canImport(FoundationNetworking).

HTTP status failures (>= 400) from JSON, raw-data and streaming
requests now throw a single HTTPResponseError carrying the status
code, the HTTP response, the raw body (truncated for streams), the
decoded API error when available and any underlying transport error.

Streaming failures now run response middleware, skip SSE middleware
for error bodies, cap the collected error body, and rethrow
cancellation unchanged.
@luis-silva-ms luis-silva-ms changed the title Bug: Expose HTTP failures as HTTPResponseError, including streaming Feat: Add HTTPResponseError for HTTP failures, including streaming Nov 11, 2026
@luis-silva-ms

Copy link
Copy Markdown
Author

Superseded by #491, which uses a feat/ branch to match the Feat: category. The changes are the same.

@luis-silva-ms
luis-silva-ms deleted the bug/expose-http-response-errors branch November 11, 2026 16:32
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant