refactor(swift): consolidate gateway problem mapping (#118107)

This commit is contained in:
Peter Steinberger
2026-08-02 13:17:22 -07:00
committed by GitHub
parent aae0342f7c
commit 54c4ff3c06
2 changed files with 209 additions and 231 deletions
@@ -796,201 +796,109 @@ extension GatewayConnectionProblemMapper {
private static func mapTransportError(_ error: Error) -> GatewayConnectionProblem? {
let nsError = error as NSError
let rawMessage = nsError.userInfo[NSLocalizedDescriptionKey] as? String ?? nsError.localizedDescription
let lower = rawMessage.trimmingCharacters(in: .whitespacesAndNewlines).lowercased()
if lower.isEmpty {
return nil
}
let message = rawMessage.trimmingCharacters(in: .whitespacesAndNewlines).lowercased()
guard !message.isEmpty else { return nil }
let urlErrorCode = URLError.Code(rawValue: nsError.code)
if nsError.domain == URLError.errorDomain {
switch urlErrorCode {
case .timedOut:
return GatewayConnectionProblem(
kind: .timeout,
owner: .network,
title: "Connection timed out",
message: "The gateway did not respond before the connection timed out.",
actionLabel: "Retry",
actionCommand: nil,
docsURL: URL(string: "https://docs.openclaw.ai/gateway/troubleshooting"),
retryable: true,
pauseReconnect: false,
technicalDetails: rawMessage)
case .cannotConnectToHost:
return GatewayConnectionProblem(
kind: .connectionRefused,
owner: .network,
title: "Gateway refused the connection",
message: "The gateway host was reachable, but it refused the connection.",
actionLabel: "Retry",
actionCommand: nil,
docsURL: URL(string: "https://docs.openclaw.ai/gateway/troubleshooting"),
retryable: true,
pauseReconnect: false,
technicalDetails: rawMessage)
case .cannotFindHost, .dnsLookupFailed, .notConnectedToInternet, .networkConnectionLost,
.internationalRoamingOff, .callIsActive, .dataNotAllowed:
return GatewayConnectionProblem(
kind: .reachabilityFailed,
owner: .network,
title: "Gateway is not reachable",
message: "OpenClaw could not reach the gateway over the current network.",
actionLabel: "Check network",
actionCommand: nil,
docsURL: URL(string: "https://docs.openclaw.ai/gateway/troubleshooting"),
retryable: true,
pauseReconnect: false,
technicalDetails: rawMessage)
case .cancelled:
return GatewayConnectionProblem(
kind: .websocketCancelled,
owner: .network,
title: "Connection interrupted",
message: "The connection to the gateway was interrupted before setup completed.",
actionLabel: "Retry",
actionCommand: nil,
docsURL: URL(string: "https://docs.openclaw.ai/gateway/troubleshooting"),
retryable: true,
pauseReconnect: false,
technicalDetails: rawMessage)
default:
break
}
}
let typedKind = nsError.domain == URLError.errorDomain
? self.transportKind(for: URLError.Code(rawValue: nsError.code))
: nil
guard let kind = typedKind ?? self.transportKind(for: message) else { return nil }
return self.transportProblem(kind: kind, technicalDetails: rawMessage)
}
if lower.contains("timed out") {
return GatewayConnectionProblem(
kind: .timeout,
owner: .network,
title: "Connection timed out",
message: "The gateway did not respond before the connection timed out.",
actionLabel: "Retry",
actionCommand: nil,
docsURL: URL(string: "https://docs.openclaw.ai/gateway/troubleshooting"),
retryable: true,
pauseReconnect: false,
technicalDetails: rawMessage)
}
if lower.contains("connection refused") || lower.contains("refused") {
return GatewayConnectionProblem(
kind: .connectionRefused,
owner: .network,
title: "Gateway refused the connection",
message: "The gateway host was reachable, but it refused the connection.",
actionLabel: "Retry",
actionCommand: nil,
docsURL: URL(string: "https://docs.openclaw.ai/gateway/troubleshooting"),
retryable: true,
pauseReconnect: false,
technicalDetails: rawMessage)
}
if lower.contains("cannot find host") || lower.contains("could not connect") || lower
.contains("network is unreachable")
{
return GatewayConnectionProblem(
kind: .reachabilityFailed,
owner: .network,
title: "Gateway is not reachable",
message: "OpenClaw could not reach the gateway over the current network.",
actionLabel: "Check network",
actionCommand: nil,
docsURL: URL(string: "https://docs.openclaw.ai/gateway/troubleshooting"),
retryable: true,
pauseReconnect: false,
technicalDetails: rawMessage)
}
if lower.contains("cancelled") || lower.contains("canceled") {
return GatewayConnectionProblem(
kind: .websocketCancelled,
owner: .network,
title: "Connection interrupted",
message: "The connection to the gateway was interrupted before setup completed.",
actionLabel: "Retry",
actionCommand: nil,
docsURL: URL(string: "https://docs.openclaw.ai/gateway/troubleshooting"),
retryable: true,
pauseReconnect: false,
technicalDetails: rawMessage)
private static func transportKind(for code: URLError.Code) -> GatewayConnectionProblem.Kind? {
switch code {
case .timedOut: .timeout
case .cannotConnectToHost: .connectionRefused
case .cannotFindHost, .dnsLookupFailed, .notConnectedToInternet, .networkConnectionLost,
.internationalRoamingOff, .callIsActive, .dataNotAllowed: .reachabilityFailed
case .cancelled: .websocketCancelled
default: nil
}
}
private static func transportKind(for message: String) -> GatewayConnectionProblem.Kind? {
if message.contains("timed out") { return .timeout }
if message.contains("refused") { return .connectionRefused }
let unreachable = ["cannot find host", "could not connect", "network is unreachable"]
if unreachable.contains(where: message.contains) { return .reachabilityFailed }
if message.contains("cancelled") || message.contains("canceled") { return .websocketCancelled }
return nil
}
private static func pairingProblem(for authError: GatewayConnectAuthError) -> GatewayConnectionProblem {
let requestId = authError.requestId
let pairingCommand = self.approvalCommand(requestId: requestId)
private static func transportProblem(
kind: GatewayConnectionProblem.Kind,
technicalDetails: String) -> GatewayConnectionProblem
{
let facts: (title: String, message: String, actionLabel: String) = switch kind {
case .timeout:
("Connection timed out", "The gateway did not respond before the connection timed out.", "Retry")
case .connectionRefused:
(
"Gateway refused the connection",
"The gateway host was reachable, but it refused the connection.",
"Retry")
case .reachabilityFailed:
(
"Gateway is not reachable",
"OpenClaw could not reach the gateway over the current network.",
"Check network")
case .websocketCancelled:
("Connection interrupted", "The connection to the gateway was interrupted before setup completed.", "Retry")
default:
preconditionFailure("Unexpected transport problem kind")
}
return GatewayConnectionProblem(
kind: kind,
owner: .network,
title: facts.title,
message: facts.message,
actionLabel: facts.actionLabel,
docsURL: URL(string: "https://docs.openclaw.ai/gateway/troubleshooting"),
retryable: true,
pauseReconnect: false,
technicalDetails: technicalDetails)
}
private static func pairingProblem(for authError: GatewayConnectAuthError) -> GatewayConnectionProblem {
let kind: GatewayConnectionProblem.Kind
let title: String
let message: String
switch authError.detailsReason {
case "role-upgrade":
return self.problem(
.init(
kind: .pairingRoleUpgradeRequired,
owner: .gateway,
title: authError.titleOverride ?? "Additional approval required",
message: authError.userMessageOverride
?? "This device is already paired, but it is requesting a new role "
+ "that was not previously approved.",
actionLabel: authError.actionLabel ?? "Approve on gateway",
actionCommand: authError.actionCommand ?? pairingCommand,
docsURL: self.docsURL(
authError.docsURLString,
fallback: "https://docs.openclaw.ai/gateway/pairing"),
requestId: requestId,
retryable: false,
pauseReconnect: true),
authError: authError)
kind = .pairingRoleUpgradeRequired
title = "Additional approval required"
message = "This device is already paired, but it is requesting a new role "
+ "that was not previously approved."
case "scope-upgrade":
return self.problem(
.init(
kind: .pairingScopeUpgradeRequired,
owner: .gateway,
title: authError.titleOverride ?? "Additional permissions required",
message: authError.userMessageOverride
?? "This device is already paired, but it is requesting new permissions that require approval.",
actionLabel: authError.actionLabel ?? "Approve on gateway",
actionCommand: authError.actionCommand ?? pairingCommand,
docsURL: self.docsURL(
authError.docsURLString,
fallback: "https://docs.openclaw.ai/gateway/pairing"),
requestId: requestId,
retryable: false,
pauseReconnect: true),
authError: authError)
kind = .pairingScopeUpgradeRequired
title = "Additional permissions required"
message = "This device is already paired, but it is requesting new permissions that require approval."
case "metadata-upgrade":
return self.problem(
.init(
kind: .pairingMetadataUpgradeRequired,
owner: .gateway,
title: authError.titleOverride ?? "Device approval needs refresh",
message: authError.userMessageOverride
?? "The gateway detected a change in this device's approved identity metadata "
+ "and requires re-approval.",
actionLabel: authError.actionLabel ?? "Approve on gateway",
actionCommand: authError.actionCommand ?? pairingCommand,
docsURL: self.docsURL(
authError.docsURLString,
fallback: "https://docs.openclaw.ai/gateway/pairing"),
requestId: requestId,
retryable: false,
pauseReconnect: true),
authError: authError)
kind = .pairingMetadataUpgradeRequired
title = "Device approval needs refresh"
message = "The gateway detected a change in this device's approved identity metadata "
+ "and requires re-approval."
default:
return self.problem(
.init(
kind: .pairingRequired,
owner: .gateway,
title: authError.titleOverride ?? "This device is not approved yet",
message: authError.userMessageOverride
?? "The gateway received the connection request, but this device must be approved first.",
actionLabel: authError.actionLabel ?? "Approve on gateway",
actionCommand: authError.actionCommand ?? pairingCommand,
docsURL: self.docsURL(
authError.docsURLString,
fallback: "https://docs.openclaw.ai/gateway/pairing"),
requestId: requestId,
retryable: false,
pauseReconnect: true),
authError: authError)
kind = .pairingRequired
title = "This device is not approved yet"
message = "The gateway received the connection request, but this device must be approved first."
}
return self.problem(
.init(
kind: kind,
owner: .gateway,
title: authError.titleOverride ?? title,
message: authError.userMessageOverride ?? message,
actionLabel: authError.actionLabel ?? "Approve on gateway",
actionCommand: authError.actionCommand ?? self.approvalCommand(requestId: authError.requestId),
docsURL: self.docsURL(
authError.docsURLString,
fallback: "https://docs.openclaw.ai/gateway/pairing"),
requestId: authError.requestId,
retryable: false,
pauseReconnect: true),
authError: authError)
}
private static func protocolMismatchProblem(for authError: GatewayConnectAuthError) -> GatewayConnectionProblem {
@@ -1074,39 +982,24 @@ extension GatewayConnectionProblemMapper {
}
private static func approvalCommand(requestId: String?) -> String {
if let requestId = self.nonEmpty(requestId) {
return "openclaw devices approve \(requestId)"
}
return "openclaw devices list"
self.nonEmpty(requestId).map { "openclaw devices approve \($0)" }
?? "openclaw devices list"
}
private static func technicalDetails(for authError: GatewayConnectAuthError) -> String? {
var parts: [String] = []
if let detail = self.nonEmpty(authError.detailCodeRaw) {
parts.append(detail)
}
if let reason = self.nonEmpty(authError.detailsReason) {
parts.append("reason=\(reason)")
}
if let requestId = self.nonEmpty(authError.requestId) {
parts.append("requestId=\(requestId)")
}
if let nextStep = self.nonEmpty(authError.recommendedNextStepRaw) {
parts.append("next=\(nextStep)")
}
if authError.canRetryWithDeviceToken {
parts.append("deviceTokenRetry=true")
}
if let clientRange = self.protocolRange(min: authError.clientMinProtocol, max: authError.clientMaxProtocol) {
parts.append("clientProtocol=\(clientRange)")
}
if let expected = authError.expectedProtocol {
parts.append("gatewayProtocol=\(expected)")
}
if let minimumProbe = authError.minimumProbeProtocol {
parts.append("probeMin=\(minimumProbe)")
}
return parts.isEmpty ? nil : parts.joined(separator: " · ")
var parts: [String?] = [
self.nonEmpty(authError.detailCodeRaw),
self.nonEmpty(authError.detailsReason).map { "reason=\($0)" },
self.nonEmpty(authError.requestId).map { "requestId=\($0)" },
self.nonEmpty(authError.recommendedNextStepRaw).map { "next=\($0)" },
self.protocolRange(min: authError.clientMinProtocol, max: authError.clientMaxProtocol)
.map { "clientProtocol=\($0)" },
authError.expectedProtocol.map { "gatewayProtocol=\($0)" },
authError.minimumProbeProtocol.map { "probeMin=\($0)" },
]
if authError.canRetryWithDeviceToken { parts.insert("deviceTokenRetry=true", at: 4) }
let details = parts.compactMap(\.self)
return details.isEmpty ? nil : details.joined(separator: " · ")
}
private static func protocolRange(min: Int?, max: Int?) -> String? {
@@ -1125,29 +1018,16 @@ extension GatewayConnectionProblemMapper {
}
private static func docsURL(_ preferred: String?, fallback: String?) -> URL? {
if let preferred = self.nonEmpty(preferred), let url = URL(string: preferred) {
return url
}
if let fallback = self.nonEmpty(fallback), let url = URL(string: fallback) {
return url
}
return nil
self.nonEmpty(preferred).flatMap { URL(string: $0) }
?? self.nonEmpty(fallback).flatMap { URL(string: $0) }
}
private static func owner(from raw: String) -> GatewayConnectionProblem.Owner? {
switch raw.trimmingCharacters(in: .whitespacesAndNewlines).lowercased() {
case "gateway":
.gateway
case "iphone", "ios", "device":
.iphone
case "both":
.both
case "network":
.network
case "unknown", "":
.unknown
default:
nil
case "ios", "device": .iphone
case "": .unknown
case let normalized:
GatewayConnectionProblem.Owner(rawValue: normalized)
}
}
@@ -113,6 +113,66 @@ struct GatewayErrorsTests {
#expect(problem.actionLabelPresentation == .verbatim("Custom gateway action"))
}
@Test func `typed and textual transport errors share exact problem facts`() throws {
let typedCases: [(URLError.Code, GatewayConnectionProblem.Kind)] = [
(.timedOut, .timeout),
(.cannotConnectToHost, .connectionRefused),
(.cannotFindHost, .reachabilityFailed),
(.dnsLookupFailed, .reachabilityFailed),
(.notConnectedToInternet, .reachabilityFailed),
(.networkConnectionLost, .reachabilityFailed),
(.internationalRoamingOff, .reachabilityFailed),
(.callIsActive, .reachabilityFailed),
(.dataNotAllowed, .reachabilityFailed),
(.cancelled, .websocketCancelled),
]
for (code, kind) in typedCases {
let rawMessage = "typed \(code.rawValue)"
let error = NSError(
domain: URLError.errorDomain,
code: code.rawValue,
userInfo: [NSLocalizedDescriptionKey: rawMessage])
let problem = try #require(GatewayConnectionProblemMapper.map(error: error))
#expect(problem == Self.transportProblem(kind: kind, technicalDetails: rawMessage))
}
let textCases: [(String, GatewayConnectionProblem.Kind)] = [
("gateway timed out", .timeout),
("connection refused", .connectionRefused),
("request refused", .connectionRefused),
("cannot find host", .reachabilityFailed),
("could not connect", .reachabilityFailed),
("network is unreachable", .reachabilityFailed),
("operation cancelled", .websocketCancelled),
("operation canceled", .websocketCancelled),
]
for (rawMessage, kind) in textCases {
let error = NSError(
domain: "GatewayTransport",
code: 1,
userInfo: [NSLocalizedDescriptionKey: rawMessage])
let problem = try #require(GatewayConnectionProblemMapper.map(error: error))
#expect(problem == Self.transportProblem(kind: kind, technicalDetails: rawMessage))
}
}
@Test func `URL error codes remain domain gated before text fallback`() throws {
let wrongDomain = NSError(
domain: "GatewayTransport",
code: URLError.timedOut.rawValue,
userInfo: [NSLocalizedDescriptionKey: "neutral failure"])
#expect(GatewayConnectionProblemMapper.map(error: wrongDomain) == nil)
let textualFallback = NSError(
domain: "GatewayTransport",
code: URLError.timedOut.rawValue,
userInfo: [NSLocalizedDescriptionKey: "connection refused"])
let problem = try #require(GatewayConnectionProblemMapper.map(error: textualFallback))
#expect(problem == Self.transportProblem(
kind: .connectionRefused,
technicalDetails: "connection refused"))
}
@Test func `protocol mismatch maps older app to update problem`() {
let error = GatewayConnectAuthError(
message: "protocol mismatch",
@@ -362,6 +422,44 @@ struct GatewayErrorsTests {
#expect(problem?.kind == .tlsPinMismatch)
#expect(problem?.canTrustRotatedCertificate == false)
}
private static let troubleshootingDocs = "https://docs.openclaw.ai/gateway/troubleshooting"
private static func transportProblem(
kind: GatewayConnectionProblem.Kind,
technicalDetails: String) -> GatewayConnectionProblem
{
let facts: (title: String, message: String, actionLabel: String)
switch kind {
case .timeout:
facts = ("Connection timed out", "The gateway did not respond before the connection timed out.", "Retry")
case .connectionRefused:
facts = (
"Gateway refused the connection",
"The gateway host was reachable, but it refused the connection.",
"Retry")
case .reachabilityFailed:
facts = (
"Gateway is not reachable", "OpenClaw could not reach the gateway over the current network.",
"Check network")
case .websocketCancelled:
facts = (
"Connection interrupted", "The connection to the gateway was interrupted before setup completed.",
"Retry")
default:
preconditionFailure("Unexpected transport problem kind")
}
return GatewayConnectionProblem(
kind: kind,
owner: .network,
title: facts.title,
message: facts.message,
actionLabel: facts.actionLabel,
docsURL: URL(string: Self.troubleshootingDocs),
retryable: true,
pauseReconnect: false,
technicalDetails: technicalDetails)
}
}
extension GatewayConnectionProblem.PresentationText {