mirror of
https://github.com/openclaw/openclaw.git
synced 2026-08-25 11:55:47 -06:00
refactor(swift): consolidate gateway problem mapping (#118107)
This commit is contained in:
committed by
GitHub
parent
aae0342f7c
commit
54c4ff3c06
@@ -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 {
|
||||
|
||||
Reference in New Issue
Block a user