Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
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
22 changes: 0 additions & 22 deletions Source/Turbo/Errors/HTTPError.swift
Original file line number Diff line number Diff line change
Expand Up @@ -24,14 +24,6 @@ public enum HTTPError: LocalizedError, Equatable, Sendable {
}
}

/// 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) {
Expand Down Expand Up @@ -106,13 +98,6 @@ extension HTTPError {
}
}

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

public init(statusCode: Int) {
switch statusCode {
case 400: self = .badRequest
Expand Down Expand Up @@ -172,13 +157,6 @@ extension HTTPError {
}
}

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

public init(statusCode: Int) {
switch statusCode {
case 500: self = .internalServerError
Expand Down
14 changes: 0 additions & 14 deletions Source/Turbo/Errors/HotwireNativeError.swift
Original file line number Diff line number Diff line change
Expand Up @@ -38,20 +38,6 @@ public enum HotwireNativeError: LocalizedError, Equatable, Sendable {
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
Expand Down
36 changes: 30 additions & 6 deletions Source/Turbo/Navigator/Navigator.swift
Original file line number Diff line number Diff line change
Expand Up @@ -54,6 +54,7 @@ public class Navigator {
return
}

logger.info("Navigator starting at \(configuration.startLocation.absoluteString)")
route(configuration.startLocation)
}

Expand All @@ -78,6 +79,7 @@ public class Navigator {
}

guard let controller = controller(for: proposal) else { return }
logger.info("Routing \(proposal.url.absoluteString)")
hierarchyController.route(controller: controller, proposal: proposal)
}

Expand Down Expand Up @@ -204,7 +206,14 @@ extension Navigator: SessionDelegate {
}

public func session(_ session: Session, didFailRequestForVisitable visitable: Visitable, error: HotwireNativeError) {
let retryHandler: (() -> Void)? = error.isRetryable ? { session.reload() } : nil
let retryHandler: (() -> Void)? = {
if session.topmostVisitable == nil {
// Preserve reload semantics (force cold boot) when there is no topmost visitable yet.
session.visit(visitable, reload: true)
} else {
session.reload()
}
}
delegate?.visitableDidFailRequest(visitable, error: error, retryHandler: retryHandler)
}

Expand Down Expand Up @@ -269,6 +278,7 @@ extension Navigator: WKUIControllerDelegate {

extension Navigator {
private func inspectAllSessions() {
logger.info("Inspecting sessions")
[session, modalSession].forEach { inspect($0) }
}

Expand All @@ -282,9 +292,13 @@ extension Navigator {
/// side-effects for the next visit (like showing the wrong bridge components). We can't just
/// check if the view controller is visible, since it may be further back in the stack of a navigation controller.
/// Seeing if there is a parent was the best solution I could find.
guard let viewController = session.activeVisitable?.visitableViewController,
viewController.parent != nil
else {
guard let viewController = session.activeVisitable?.visitableViewController else {
logger.info("Skipping session reload: no visitableViewController found")
return
}

guard viewController.parent != nil else {
logger.info("Skipping session reload: visitableViewController has no parent")
return
}

Expand All @@ -293,6 +307,7 @@ extension Navigator {
// and reload it when the app is back in foreground.
if appLifecycleObserver.appState == .background {
if !backgroundTerminatedWebViewSessions.contains(where: { $0 === session }) {
logger.info("Skipping session reload: app in background")
backgroundTerminatedWebViewSessions.append(session)
}
return
Expand All @@ -316,16 +331,21 @@ extension Navigator {
private func inspect(_ session: Session) {
if let index = backgroundTerminatedWebViewSessions.firstIndex(where: { $0 === session }) {
backgroundTerminatedWebViewSessions.remove(at: index)
logger.debug("Reloading background terminated web view")
reload(session)
return
}

guard let _ = session.topmostVisitable?.initialVisitableURL else {
logger.debug("Skipping inspection: no topmostVisitable found")
return
}

session.webView.queryWebContentProcessState { [weak self] state in
guard case .terminated = state else { return }
guard case .terminated = state else {
logger.debug("Skipping web view recreation: process not terminated")
return
}
self?.recreateWebView(for: session)
}
}
Expand All @@ -335,8 +355,12 @@ extension Navigator {
/// - Parameter session: The session to recreate.
private func recreateWebView(for session: Session) {
guard let _ = session.activeVisitable?.visitableViewController,
let url = session.activeVisitable?.initialVisitableURL else { return }
let url = session.activeVisitable?.initialVisitableURL else {
logger.debug("Skipping web view recreation: no initialVisitableURL found")
return
}

logger.debug("Recreating web view for \(url.absoluteString)")
let newSession = Session(webView: Hotwire.config.makeWebView())
newSession.pathConfiguration = session.pathConfiguration
newSession.delegate = self
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,7 @@ public final class AppNavigationRouteDecisionHandler: RouteDecisionHandler {
public func handle(location: URL,
configuration: Navigator.Configuration,
navigator: Navigator) -> Router.Decision {
logger.info("Routing \(location.absoluteString)")
return .navigate
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,7 @@ public struct ReloadWebViewPolicyDecisionHandler: WebViewPolicyDecisionHandler {
public func handle(navigationAction: WKNavigationAction,
configuration: Navigator.Configuration,
navigator: Navigator) -> WebViewPolicyManager.Decision {
logger.info("ReloadWebViewPolicyDecisionHandler: Reloading at \(navigationAction.request.url?.absoluteString ?? "<web view has no url>")")
navigator.reload()

return .cancel
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -22,7 +22,7 @@ public final class WebViewPolicyManager {
}
}

logger.warning("[WebViewPolicyManager] no handler for navigation action: \(navigationAction)")
logger.info("[WebViewPolicyManager] no handler for navigation action: \(navigationAction)")
return .allow
}
}
Expand Down
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
import Foundation

enum RedirectHandlerError: LocalizedError {
enum JSFetchRecoveryError: LocalizedError {
case requestFailed(Error)
case responseValidationFailed(reason: ResponseValidationFailureReason)

Expand All @@ -13,19 +13,19 @@ enum RedirectHandlerError: LocalizedError {
var errorDescription: String? {
switch self {
case .requestFailed(let error):
return "Redirect resolution failed: \(error.localizedDescription)"
return "Failed to recover js fetch: \(error.localizedDescription)"
case .responseValidationFailed(let reason):
switch reason {
case .missingURL:
return "Redirect resolution failed: missing URL"
return "Failed to validate js fetch resolution response: missing URL"
case .invalidResponse:
return "Redirect resolution response invalid"
return "Failed to validate js fetch resolution response: response invalid"
}
}
}
}

struct RedirectHandler {
struct JSFetchRecoveryHandler {
enum Result {
case noRedirect
case sameOriginRedirect(URL)
Expand All @@ -36,38 +36,42 @@ struct RedirectHandler {
location: URL,
timeout: TimeInterval = Hotwire.config.redirectResolutionTimeout
) async throws -> Result {
logger.debug("[JSFetchRecoveryHandler] resolve: \(location.absoluteString)")
do {
var request = URLRequest(url: location)
request.timeoutInterval = timeout
let (_, response) = try await URLSession.shared.data(for: request)
let httpResponse = try validateResponse(response)

guard let responseUrl = httpResponse.url else {
throw RedirectHandlerError.responseValidationFailed(reason: .missingURL)
throw JSFetchRecoveryError.responseValidationFailed(reason: .missingURL)
}

let isRedirect = location != responseUrl
let redirectIsCrossOrigin = isRedirect && location.host != responseUrl.host

guard isRedirect else {
logger.debug("[JSFetchRecoveryHandler] no redirect, status: \(httpResponse.statusCode)")
return .noRedirect
}

if redirectIsCrossOrigin {
logger.debug("[JSFetchRecoveryHandler] cross-origin redirect to \(responseUrl.absoluteString)")
return .crossOriginRedirect(responseUrl)
}

logger.debug("[JSFetchRecoveryHandler] same-origin redirect to \(responseUrl.absoluteString)")
return .sameOriginRedirect(responseUrl)
} catch let error as RedirectHandlerError {
} catch let error as JSFetchRecoveryError {
throw error
} catch {
throw RedirectHandlerError.requestFailed(error)
throw JSFetchRecoveryError.requestFailed(error)
}
}

private func validateResponse(_ response: URLResponse) throws -> HTTPURLResponse {
guard let httpResponse = response as? HTTPURLResponse else {
throw RedirectHandlerError.responseValidationFailed(reason: .invalidResponse)
throw JSFetchRecoveryError.responseValidationFailed(reason: .invalidResponse)
}

return httpResponse
Expand Down
22 changes: 18 additions & 4 deletions Source/Turbo/Session/Session.swift
Original file line number Diff line number Diff line change
Expand Up @@ -81,8 +81,12 @@ public class Session: NSObject {
}

public func reload() {
guard let visitable = topmostVisitable else { return }
guard let visitable = topmostVisitable else {
log("Skipping session reload: no visitable found")
return
}

log("Reloading session with visitable: \(visitable)")
initialized = false
visit(visitable)
topmostVisit = currentVisit
Expand Down Expand Up @@ -396,10 +400,10 @@ extension Session: WebViewDelegate {

private func resolveRedirect(to location: URL, identifier: String, statusCode: Int) async {
do {
let result = try await RedirectHandler().resolve(location: location)
let result = try await JSFetchRecoveryHandler().resolve(location: location)
switch result {
case .noRedirect:
// The server is reachable (RedirectHandler confirmed an HTTP response
// The server is reachable (JSFetchRecoveryHandler confirmed an HTTP response
// with no redirect). The Turbo.js fetch failure was transient —
// retry with a cold boot visit before showing an error.
log("resolveRedirect: no redirect, retrying",
Expand Down Expand Up @@ -429,7 +433,12 @@ extension Session: WebViewDelegate {
visitIdentifier: identifier
)
}
} catch let error as RedirectHandlerError {
} catch let error as JSFetchRecoveryError {
log("resolveRedirect: recovery failed",
["location": location,
"visitIdentifier": identifier,
"error": error.localizedDescription])

let visitError: HotwireNativeError
switch error {
case .responseValidationFailed(reason: .missingURL),
Expand All @@ -440,6 +449,11 @@ extension Session: WebViewDelegate {
}
await retryOrFailCurrentVisit(with: visitError, visitIdentifier: identifier)
} catch {
log("resolveRedirect: unexpected error",
["location": location,
"visitIdentifier": identifier,
"error": "\(error)"])

await retryOrFailCurrentVisit(
with: .web(WebError(error)),
visitIdentifier: identifier
Expand Down
1 change: 1 addition & 0 deletions Source/Turbo/Visit/ColdBootVisit.swift
Original file line number Diff line number Diff line change
Expand Up @@ -105,6 +105,7 @@ extension ColdBootVisit: WKNavigationDelegate {
decisionHandler(.allow)
} else {
decisionHandler(.cancel)
logger.warning("[ColdBootVisit] request failed with status \(httpResponse.statusCode) for \(self.location.absoluteString)")
if let httpError = HTTPError(statusCode: httpResponse.statusCode) {
fail(with: .http(httpError))
} else {
Expand Down
6 changes: 6 additions & 0 deletions Source/Turbo/Visitable/VisitableViewController.swift
Original file line number Diff line number Diff line change
Expand Up @@ -72,6 +72,9 @@ open class VisitableViewController: UIViewController, Visitable {
}

open func visitableWillDeactivateWebView() {
let webViewURL = visitableView.webView?.url
let locationStateDescription = webViewURL != nil ? "webView's url: \(webViewURL!.absoluteString)" : "initialVisitableURL: \(initialVisitableURL.absoluteString)"
logger.info("visitableWillDeactivateWebView: setting location state to \(locationStateDescription)")
visitableLocationState = .deactivated(visitableView.webView?.url ?? initialVisitableURL)
}

Expand Down Expand Up @@ -109,6 +112,9 @@ open class VisitableViewController: UIViewController, Visitable {
private func resolveVisitableLocation() -> URL {
switch visitableLocationState {
case .resolved:
if visitableView.webView?.url == nil {
logger.info("Resolving visitable location to initialVisitableURL: \(initialVisitableURL.absoluteString)")
}
return visitableView.webView?.url ?? initialVisitableURL
case .initialized(let url):
return url
Expand Down
Loading
Loading