Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
32 commits
Select commit Hold shift + click to select a range
47ccbb4
Add LocalizedError conformance to RedirectHandlerError and mark public
zoejessica Jul 12, 2025
b58c92b
Pass back correct status code in redirect handling and use to log pre…
zoejessica Jul 12, 2025
0ad9dbf
Throw contentTypeMismatch error if navigation response is neither htt…
zoejessica Jul 12, 2025
a9978ce
Replace TurboError with HotwireNativeError enum for exhaustive error …
alpkeser Dec 31, 2025
588d8f0
Consolidate Turbo.js status code to error mapping in HotwireNativeError
alpkeser Jan 14, 2026
531f86d
Make WebError.from(turboStatusCode:) internal
alpkeser Jan 16, 2026
8967ce4
Simplify WebError.isTimeout to check errorCode directly
alpkeser Jan 16, 2026
e4ac16e
Return error description instead of hardcoded network description
alpkeser Jan 23, 2026
21208fc
Make RedirectHandlerError internal
zoejessica Feb 12, 2026
6360d29
Remove unnecessary factory method on WebError
zoejessica Feb 12, 2026
3301bff
Rename to isSSLError
zoejessica Feb 12, 2026
90a343c
Rename to HTTPError
zoejessica Feb 12, 2026
1eb615f
Add LoadError tests
olivaresf Feb 12, 2026
201b159
Add WebError tests
olivaresf Feb 12, 2026
070bec7
Add HttpError tests
olivaresf Feb 12, 2026
7bcdb1f
Add HotwireNativeError tests
olivaresf Feb 12, 2026
5ca32a2
Improve test quality: individual methods, final classes, stronger ass…
olivaresf Feb 12, 2026
6f79387
Update with name changes
olivaresf Feb 12, 2026
588801b
Cancel leaked WKWebView navigations in test simulator
olivaresf Feb 12, 2026
42a07b7
Retry with cold boot when server is reachable but JS fetch failed
olivaresf Feb 13, 2026
468fbbe
Granular isRetryable for HTTP errors and turboIsReady message
olivaresf Feb 13, 2026
16bc789
Set 30s timeout on RedirectHandler reachability check
olivaresf Feb 13, 2026
b7c6c37
Accept any HTTP response in redirect check and retry on failure
olivaresf Feb 13, 2026
b7c2cc4
Fix WebError.isTimeout false positive for URLError(.unknown)
olivaresf Feb 13, 2026
f18062a
Make HTTP 503/504 retryable and fix isRetryable doc comment
olivaresf Feb 13, 2026
71a0024
Rename from(...) factory methods to init per Swift API guidelines
olivaresf Feb 13, 2026
cdb68b1
Differentiate LoadError.notPresent and .notReady descriptions
olivaresf Feb 13, 2026
5f40029
Guard statusCode force-unwrap in WebViewBridge
olivaresf Feb 13, 2026
e6058fc
Add explicit Sendable conformance to ClientError and ServerError
olivaresf Feb 13, 2026
ea32fbc
Use [weak self] in Task capturing Session
olivaresf Feb 13, 2026
e5e226f
Revert stray whitespace and method reordering in Test.swift
olivaresf Feb 13, 2026
252f742
Clean up retriedVisitIdentifiers on retry exhaustion
olivaresf Feb 13, 2026
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
21 changes: 12 additions & 9 deletions Demo/SceneController.swift
Original file line number Diff line number Diff line change
Expand Up @@ -44,17 +44,20 @@ extension SceneController: NavigatorDelegate {
}
}

func visitableDidFailRequest(_ visitable: any Visitable, error: any Error, retryHandler: RetryBlock?) {
if let turboError = error as? TurboError, case let .http(statusCode) = turboError, statusCode == 401 {
func visitableDidFailRequest(_ visitable: any Visitable, error: HotwireNativeError, retryHandler: RetryBlock?) {
switch error {
case .http(.client(.unauthorized)):
promptForAuthentication()
} else if let errorPresenter = visitable as? ErrorPresenter {
errorPresenter.presentError(error) {
retryHandler?()
default:
if let errorPresenter = visitable as? ErrorPresenter {
errorPresenter.presentError(error) {
retryHandler?()
}
} else {
let alert = UIAlertController(title: "Visit failed!", message: error.localizedDescription, preferredStyle: .alert)
alert.addAction(UIAlertAction(title: "OK", style: .default, handler: nil))
tabBarController.activeNavigator.present(alert, animated: true)
}
} else {
let alert = UIAlertController(title: "Visit failed!", message: error.localizedDescription, preferredStyle: .alert)
alert.addAction(UIAlertAction(title: "OK", style: .default, handler: nil))
tabBarController.activeNavigator.present(alert, animated: true)
}
}
}
194 changes: 194 additions & 0 deletions Source/Turbo/Errors/HTTPError.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,194 @@
import Foundation

/// Errors representing HTTP status codes received from the server.
public enum HTTPError: LocalizedError, Equatable, Sendable {
case client(ClientError)
case server(ServerError)

/// The HTTP status code for this error.
public var statusCode: Int {
switch self {
case .client(let error):
return error.statusCode
case .server(let error):
return error.statusCode
}
}

public var errorDescription: String? {
switch self {
case .client(let error):
return error.errorDescription
case .server(let error):
return error.errorDescription
}
}

/// Whether the HTTP error is transient and worth retrying.
public var isRetryable: Bool {
switch self {
case .client(let error): return error.isRetryable
case .server(let error): return error.isRetryable
}
}

/// Creates an HTTPError from an HTTP status code.
/// Returns `nil` for status codes outside the 400-599 error range.
public init?(statusCode: Int) {
if (400...499).contains(statusCode) {
self = .client(ClientError(statusCode: statusCode))
} else if (500...599).contains(statusCode) {
self = .server(ServerError(statusCode: statusCode))
} else {
return nil
}
}
}

// MARK: - Client Errors (4xx)

extension HTTPError {
/// Errors representing HTTP client errors in the 400-499 range.
public enum ClientError: LocalizedError, Equatable, Sendable {
case badRequest
case unauthorized
case paymentRequired
case forbidden
case notFound
case methodNotAllowed
case notAcceptable
case proxyAuthenticationRequired
case requestTimeout
case conflict
case misdirectedRequest
case unprocessableEntity
case preconditionRequired
case tooManyRequests
case other(statusCode: Int)

public var statusCode: Int {
switch self {
case .badRequest: return 400
case .unauthorized: return 401
case .paymentRequired: return 402
case .forbidden: return 403
case .notFound: return 404
case .methodNotAllowed: return 405
case .notAcceptable: return 406
case .proxyAuthenticationRequired: return 407
case .requestTimeout: return 408
case .conflict: return 409
case .misdirectedRequest: return 421
case .unprocessableEntity: return 422
case .preconditionRequired: return 428
case .tooManyRequests: return 429
case .other(let code): return code
}
}

public var errorDescription: String? {
switch self {
case .badRequest: return "Bad Request"
case .unauthorized: return "Unauthorized"
case .paymentRequired: return "Payment Required"
case .forbidden: return "Forbidden"
case .notFound: return "Not Found"
case .methodNotAllowed: return "Method Not Allowed"
case .notAcceptable: return "Not Acceptable"
case .proxyAuthenticationRequired: return "Proxy Authentication Required"
case .requestTimeout: return "Request Timeout"
case .conflict: return "Conflict"
case .misdirectedRequest: return "Misdirected Request"
case .unprocessableEntity: return "Unprocessable Entity"
case .preconditionRequired: return "Precondition Required"
case .tooManyRequests: return "Too Many Requests"
case .other(let code): return "Client Error (\(code))"
}
}

public var isRetryable: Bool {
switch self {
case .requestTimeout, .tooManyRequests: return true
default: return false
}
}

public init(statusCode: Int) {
switch statusCode {
case 400: self = .badRequest
case 401: self = .unauthorized
case 402: self = .paymentRequired
case 403: self = .forbidden
case 404: self = .notFound
case 405: self = .methodNotAllowed
case 406: self = .notAcceptable
case 407: self = .proxyAuthenticationRequired
case 408: self = .requestTimeout
case 409: self = .conflict
case 421: self = .misdirectedRequest
case 422: self = .unprocessableEntity
case 428: self = .preconditionRequired
case 429: self = .tooManyRequests
default: self = .other(statusCode: statusCode)
}
}
}
}

// MARK: - Server Errors (5xx)

extension HTTPError {
/// Errors representing HTTP server errors in the 500-599 range.
public enum ServerError: LocalizedError, Equatable, Sendable {
case internalServerError
case notImplemented
case badGateway
case serviceUnavailable
case gatewayTimeout
case httpVersionNotSupported
case other(statusCode: Int)

public var statusCode: Int {
switch self {
case .internalServerError: return 500
case .notImplemented: return 501
case .badGateway: return 502
case .serviceUnavailable: return 503
case .gatewayTimeout: return 504
case .httpVersionNotSupported: return 505
case .other(let code): return code
}
}

public var errorDescription: String? {
switch self {
case .internalServerError: return "Internal Server Error"
case .notImplemented: return "Not Implemented"
case .badGateway: return "Bad Gateway"
case .serviceUnavailable: return "Service Unavailable"
case .gatewayTimeout: return "Gateway Timeout"
case .httpVersionNotSupported: return "HTTP Version Not Supported"
case .other(let code): return "Server Error (\(code))"
}
}

public var isRetryable: Bool {
switch self {
case .serviceUnavailable, .gatewayTimeout: return true
default: return false
}
}

public init(statusCode: Int) {
switch statusCode {
case 500: self = .internalServerError
case 501: self = .notImplemented
case 502: self = .badGateway
case 503: self = .serviceUnavailable
case 504: self = .gatewayTimeout
case 505: self = .httpVersionNotSupported
default: self = .other(statusCode: statusCode)
}
}
}
}
72 changes: 72 additions & 0 deletions Source/Turbo/Errors/HotwireNativeError.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,72 @@
import Foundation

/// A unified error type for all Hotwire Native errors.
public enum HotwireNativeError: LocalizedError, Equatable, Sendable {
/// HTTP status code errors (4xx, 5xx)
case http(HTTPError)

/// Network/connection errors
case web(WebError)

/// Turbo.js loading errors
case load(LoadError)

public var errorDescription: String? {
switch self {
case .http(let error):
return error.errorDescription
case .web(let error):
return error.errorDescription
case .load(let error):
return error.errorDescription
}
}

/// The HTTP status code, if this is an HTTP error.
public var statusCode: Int? {
if case .http(let error) = self {
return error.statusCode
}
return nil
}

/// The underlying URLError, if this is a web error with one.
public var urlError: URLError? {
if case .web(let error) = self {
return error.urlError
}
return nil
}

/// Whether the error is recoverable by retrying the request.
/// HTTP 408/429 and 503/504 are retryable. Network-level timeouts,
/// offline errors, and configuration errors are not.
public var isRetryable: Bool {
switch self {
case .http(let error):
return error.isRetryable
case .web(let error):
return !error.isOffline && !error.isTimeout
case .load:
return false
}
}

/// Creates an error from a Turbo.js status code.
/// - Positive status codes are HTTP errors
/// - 0 = network failure, -1 = timeout, -2 = content type mismatch
init(turboJSStatusCode statusCode: Int) {
switch statusCode {
case -2:
self = .load(.contentTypeMismatch)
case ...0:
self = .web(WebError(turboStatusCode: statusCode))
default:
if let httpError = HTTPError(statusCode: statusCode) {
self = .http(httpError)
} else {
self = .web(WebError(errorCode: statusCode, message: "Unexpected status code"))
}
}
}
}
38 changes: 38 additions & 0 deletions Source/Turbo/Errors/LoadError.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,38 @@
import Foundation

/// Errors representing when turbo.js or the native adapter fails to load on a page.
public enum LoadError: LocalizedError, Equatable, Sendable {
/// Turbo.js is not present on the page.
case notPresent

/// Turbo.js is present but not ready/initialized.
case notReady

/// The server returned an invalid content type (non-HTML response).
case contentTypeMismatch

/// The server returned a malformed or unexpected response.
case invalidResponse

public var title: String {
switch self {
case .notPresent: return "Turbo Not Present"
case .notReady: return "Turbo Not Ready"
case .contentTypeMismatch: return "Content Type Mismatch"
case .invalidResponse: return "Invalid Response"
}
}

public var errorDescription: String? {
switch self {
case .notPresent:
return "The page could not be loaded because Turbo is not present."
case .notReady:
return "The page could not be loaded because Turbo is not ready."
case .contentTypeMismatch:
return "The server returned an invalid content type."
case .invalidResponse:
return "The server returned an invalid response."
}
}
}
Loading