diff --git a/Sources/Shared/API/Webhook/Networking/WebhookManager.swift b/Sources/Shared/API/Webhook/Networking/WebhookManager.swift index d627bb90b8..e023eba405 100644 --- a/Sources/Shared/API/Webhook/Networking/WebhookManager.swift +++ b/Sources/Shared/API/Webhook/Networking/WebhookManager.swift @@ -350,16 +350,21 @@ public class WebhookManager: NSObject { return held } - let (promise, seal) = Promise.pending() - - dataQueue.async { [dataQueue] in - let sendRegular: () -> Promise = { [self] in + return firstly { + Promise { seal in + Task { + await seal.fulfill(server.webhookURL()) + } + } + }.then(on: dataQueue) { [self, dataQueue] webhookURL -> Promise in + let sendRegular: (URL?) -> Promise = { [self] url in send( on: currentRegularSessionInfo, server: server, identifier: identifier, request: request, - waitForResponse: true + waitForResponse: true, + webhookURL: url ) } @@ -373,27 +378,90 @@ public class WebhookManager: NSObject { ) } - let promise: Promise - if Current.isBackgroundRequestsImmediate() { - promise = sendBackground() - } else { - Current.Log.info("in background, choosing to not use background session") - promise = sendRegular().recover(on: dataQueue) { error -> Promise in - Current.Log.error("in-background non-background failed: \(error)") - if error is HomeAssistantAPI.APIError { - // not worth retrying, since we got a real response that we didn't like - throw error - } else { - return sendBackground() + return sendBackground() + } + + Current.Log.info("in background, choosing to not use background session") + return sendRegular(webhookURL).recover(on: dataQueue) { error -> Promise in + Current.Log.error("in-background non-background failed: \(error)") + if error is HomeAssistantAPI.APIError { + // not worth retrying, since we got a real response that we didn't like + throw error + } + + guard let webhookURL, let remoteURL = Self.remoteWebhookURL( + for: server, + unreachableInternalURL: webhookURL, + error: error + ) else { + return sendBackground() + } + + Current.Log.info("internal URL unreachable, retrying \(request.type) with the remote URL") + Current.clientEventStore.addEvent(ClientEvent( + text: "Internal URL unreachable, retrying with remote URL", + type: .networkRequest, + payload: ["error": LogRedaction.redact(String(describing: error))] + )) + + return sendRegular(remoteURL).get { + Current.connectivity.updateLastKnownNetworkState(NetworkState()) + }.recover(on: dataQueue) { remoteError -> Promise in + Current.Log.error("remote URL retry failed: \(remoteError)") + if remoteError is HomeAssistantAPI.APIError { + throw remoteError } + return sendBackground() } } + } + } + + static let regularSessionTimeout: TimeInterval = 10 - promise.pipe(to: { seal.resolve($0) }) + static func remoteWebhookURL(for server: Server, unreachableInternalURL: URL, error: Error) -> URL? { + guard isUnreachable(error) else { return nil } + + let connection = server.info.connection + guard let internalURL = connection.address(for: .internal), + unreachableInternalURL.scheme?.lowercased() == internalURL.scheme?.lowercased(), + unreachableInternalURL.host?.lowercased() == internalURL.host?.lowercased(), + unreachableInternalURL.port == internalURL.port else { + return nil } - return promise + if let cloudhookURL = connection.cloudhookURL { + return cloudhookURL + } + + let remoteBase: URL? + if connection.useCloud, let remoteUIURL = connection.address(for: .remoteUI) { + remoteBase = remoteUIURL + } else { + remoteBase = connection.address(for: .external) + } + + guard let remoteURL = remoteBase?.appendingPathComponent(connection.webhookPath, isDirectory: false), + remoteURL != unreachableInternalURL else { + return nil + } + return remoteURL + } + + static func isUnreachable(_ error: Error) -> Bool { + guard let urlError = error as? URLError else { return false } + switch urlError.code { + case .cannotConnectToHost, + .cannotFindHost, + .dnsLookupFailed, + .networkConnectionLost, + .notConnectedToInternet, + .timedOut: + return true + default: + return false + } } public func sendPassive( @@ -434,7 +502,8 @@ public class WebhookManager: NSObject { server: Server, identifier: WebhookResponseIdentifier, request: WebhookRequest, - waitForResponse: Bool + waitForResponse: Bool, + webhookURL: URL? = nil ) -> Promise { guard let handlerType = responseHandlers[identifier] else { Current.Log.error("no existing handler for \(identifier), not sending request") @@ -452,7 +521,7 @@ public class WebhookManager: NSObject { Current.backgroundTask(withName: BackgroundTask.webhookSend.rawValue) { _ in promise }.cauterize() firstly { - Self.urlRequest(for: request, server: server) + Self.urlRequest(for: request, server: server, webhookURL: webhookURL) }.done(on: dataQueue) { urlRequest, data in let task: URLSessionUploadTask let filesToRemove: [URL] @@ -468,6 +537,8 @@ public class WebhookManager: NSObject { filesToRemove = [temporaryFile] } else { + var urlRequest = urlRequest + urlRequest.timeoutInterval = Self.regularSessionTimeout // not writing to disk so we don't have to deal with the cleanup logic across sessions task = sessionInfo.session.uploadTask(with: urlRequest, from: data) filesToRemove = [] @@ -614,14 +685,17 @@ public class WebhookManager: NSObject { private static func urlRequest( for request: WebhookRequest, server: Server, - baseURL: URL? = nil + baseURL: URL? = nil, + webhookURL resolvedWebhookURL: URL? = nil ) -> Promise<(URLRequest, Data)> { Promise { seal in Task { do { let webhookURL: URL - if let baseURL { + if let resolvedWebhookURL { + webhookURL = resolvedWebhookURL + } else if let baseURL { webhookURL = baseURL.appendingPathComponent( server.info.connection.webhookPath, isDirectory: false @@ -790,7 +864,10 @@ extension WebhookManager: URLSessionDataDelegate, URLSessionTaskDelegate { Current.backgroundTask(withName: BackgroundTask.webhookInvoke.rawValue) { _ -> Promise in guard let api = Current.api(for: server) else { - return .init(error: HomeAssistantAPI.APIError.noAPIAvailable) + let error = HomeAssistantAPI.APIError.noAPIAvailable + resolver?.reject(error) + sessionInfo.eventGroup.leave() + return .init(error: error) } let handler = handlerType.init(api: api) let handlerPromise = firstly { diff --git a/Tests/Shared/Webhook/WebhookManager.test.swift b/Tests/Shared/Webhook/WebhookManager.test.swift index c035d7f7ac..a3d6684785 100644 --- a/Tests/Shared/Webhook/WebhookManager.test.swift +++ b/Tests/Shared/Webhook/WebhookManager.test.swift @@ -1024,6 +1024,315 @@ class WebhookManagerTests: XCTestCase { } } + func testSendPersistentInBackgroundFailsOverBeforeBackgroundTimeRunsOut() { + Current.isBackgroundRequestsImmediate = { false } + + let expectedRequest = WebhookRequest(type: "webhook_name", data: ["json": true]) + + var timeouts = [TimeInterval]() + stub(condition: { [webhookURL1] req in req.url == webhookURL1 }, response: { request in + timeouts.append(request.timeoutInterval) + if timeouts.count == 1 { + return HTTPStubsResponse(error: URLError(.timedOut)) + } else { + return HTTPStubsResponse(jsonObject: ["hello": "goodbye"], statusCode: 200, headers: nil) + } + }) + + XCTAssertNoThrow(try hang(manager.send(server: api1.server, request: expectedRequest))) + XCTAssertEqual(timeouts.count, 2) + XCTAssertEqual(timeouts.first, WebhookManager.regularSessionTimeout) + XCTAssertNotEqual(timeouts.last, WebhookManager.regularSessionTimeout) + } + + func testSendPersistentInForegroundKeepsDefaultTimeout() { + Current.isBackgroundRequestsImmediate = { true } + + let expectedRequest = WebhookRequest(type: "webhook_name", data: ["json": true]) + + var timeouts = [TimeInterval]() + stub(condition: { [webhookURL1] req in req.url == webhookURL1 }, response: { request in + timeouts.append(request.timeoutInterval) + return HTTPStubsResponse(jsonObject: ["hello": "goodbye"], statusCode: 200, headers: nil) + }) + + XCTAssertNoThrow(try hang(manager.send(server: api1.server, request: expectedRequest))) + XCTAssertEqual(timeouts.count, 1) + XCTAssertNotEqual(timeouts.first, WebhookManager.regularSessionTimeout) + } + + func testSendPersistentRetriesRemoteURLWhenInternalURLIsUnreachable() throws { + Current.isBackgroundRequestsImmediate = { false } + let urls = try useInternalURLAtHome() + let expectedRequest = WebhookRequest(type: "update_location", data: ["json": true]) + + var internalRequests = 0 + stub(condition: { req in req.url == urls.internal }, response: { _ in + internalRequests += 1 + return HTTPStubsResponse(error: URLError(.cannotConnectToHost)) + }) + + var externalRequests = 0 + stub(condition: { req in req.url == urls.external }, response: { [api1] request in + externalRequests += 1 + XCTAssertEqualWebhookRequest(request.ohhttpStubs_httpBody, expectedRequest, server: api1!.server) + return HTTPStubsResponse(jsonObject: ["hello": "goodbye"], statusCode: 200, headers: nil) + }) + + XCTAssertNoThrow(try hang(manager.send(server: api1.server, request: expectedRequest))) + XCTAssertEqual(internalRequests, 1) + XCTAssertEqual(externalRequests, 1) + XCTAssertNil(Current.connectivity.lastKnownNetworkState().ssid) + } + + func testSendPersistentRetriesCloudhookWhenInternalURLIsUnreachable() throws { + Current.isBackgroundRequestsImmediate = { false } + let urls = try useInternalURLAtHome() + let cloudhookURL = try XCTUnwrap(URL(string: "https://hooks.nabu.casa/cloudhook-id")) + api1.server.info.connection.cloudhookURL = cloudhookURL + let expectedRequest = WebhookRequest(type: "update_location", data: ["json": true]) + + stub(condition: { req in req.url == urls.internal }, response: { _ in + HTTPStubsResponse(error: URLError(.notConnectedToInternet)) + }) + + var externalRequests = 0 + stub(condition: { req in req.url == urls.external }, response: { _ in + externalRequests += 1 + return HTTPStubsResponse(jsonObject: ["hello": "goodbye"], statusCode: 200, headers: nil) + }) + + var cloudhookRequests = 0 + stub(condition: { req in req.url == cloudhookURL }, response: { [api1] request in + cloudhookRequests += 1 + XCTAssertEqualWebhookRequest(request.ohhttpStubs_httpBody, expectedRequest, server: api1!.server) + return HTTPStubsResponse(jsonObject: ["hello": "goodbye"], statusCode: 200, headers: nil) + }) + + XCTAssertNoThrow(try hang(manager.send(server: api1.server, request: expectedRequest))) + XCTAssertEqual(cloudhookRequests, 1) + XCTAssertEqual(externalRequests, 0) + } + + func testSendPersistentUsesBackgroundSessionWhenRemoteURLFailsToo() throws { + Current.isBackgroundRequestsImmediate = { false } + let urls = try useInternalURLAtHome() + let expectedRequest = WebhookRequest(type: "update_location", data: ["json": true]) + + var internalRequests = 0 + stub(condition: { req in req.url == urls.internal }, response: { _ in + internalRequests += 1 + if internalRequests == 1 { + return HTTPStubsResponse(error: URLError(.cannotConnectToHost)) + } else { + return HTTPStubsResponse(jsonObject: ["hello": "goodbye"], statusCode: 200, headers: nil) + } + }) + + var externalRequests = 0 + stub(condition: { req in req.url == urls.external }, response: { _ in + externalRequests += 1 + return HTTPStubsResponse(error: URLError(.timedOut)) + }) + + XCTAssertNoThrow(try hang(manager.send(server: api1.server, request: expectedRequest))) + XCTAssertEqual(internalRequests, 2) + XCTAssertEqual(externalRequests, 1) + XCTAssertEqual(Current.connectivity.lastKnownNetworkState().ssid, "MyWifi") + } + + func testSendPersistentBackgroundFallbackUsesNetworkJoinedDuringAttempts() throws { + Current.isBackgroundRequestsImmediate = { false } + let urls = try useInternalURLAtHome() + let expectedRequest = WebhookRequest(type: "update_location", data: ["json": true]) + + var internalRequests = 0 + stub(condition: { req in req.url == urls.internal }, response: { _ in + internalRequests += 1 + return HTTPStubsResponse(error: URLError(.cannotConnectToHost)) + }) + + var externalRequests = 0 + stub(condition: { req in req.url == urls.external }, response: { _ in + externalRequests += 1 + if externalRequests == 1 { + Current.connectivity.updateLastKnownNetworkState(NetworkState(ssid: "Elsewhere")) + return HTTPStubsResponse(error: URLError(.timedOut)) + } else { + return HTTPStubsResponse(jsonObject: ["hello": "goodbye"], statusCode: 200, headers: nil) + } + }) + + XCTAssertNoThrow(try hang(manager.send(server: api1.server, request: expectedRequest))) + XCTAssertEqual(internalRequests, 1) + XCTAssertEqual(externalRequests, 2) + } + + func testSendPersistentDoesNotRetryRemoteURLWhenInternalURLAnswers() throws { + Current.isBackgroundRequestsImmediate = { false } + let urls = try useInternalURLAtHome() + let expectedRequest = WebhookRequest(type: "update_location", data: ["json": true]) + + stub(condition: { req in req.url == urls.internal }, response: { _ in + HTTPStubsResponse(jsonObject: [:], statusCode: 500, headers: nil) + }) + + var externalRequests = 0 + stub(condition: { req in req.url == urls.external }, response: { _ in + externalRequests += 1 + return HTTPStubsResponse(jsonObject: ["hello": "goodbye"], statusCode: 200, headers: nil) + }) + + XCTAssertThrowsError(try hang(manager.send(server: api1.server, request: expectedRequest))) { error in + XCTAssertEqual(error as? WebhookError, .unacceptableStatusCode(500)) + } + XCTAssertEqual(externalRequests, 0) + XCTAssertEqual(Current.connectivity.lastKnownNetworkState().ssid, "MyWifi") + } + + func testSendPersistentDoesNotRetryRemoteURLWhenInternalURLWasNotUsed() throws { + Current.isBackgroundRequestsImmediate = { false } + let urls = try useInternalURLAtHome() + useNetworkAwayFromHome() + let expectedRequest = WebhookRequest(type: "update_location", data: ["json": true]) + + var internalRequests = 0 + stub(condition: { req in req.url == urls.internal }, response: { _ in + internalRequests += 1 + return HTTPStubsResponse(jsonObject: ["hello": "goodbye"], statusCode: 200, headers: nil) + }) + + var externalRequests = 0 + stub(condition: { req in req.url == urls.external }, response: { _ in + externalRequests += 1 + return HTTPStubsResponse(error: URLError(.cannotConnectToHost)) + }) + + XCTAssertThrowsError(try hang(manager.send(server: api1.server, request: expectedRequest))) { error in + XCTAssertEqual((error as? URLError)?.code, .cannotConnectToHost) + } + XCTAssertEqual(externalRequests, 2) + XCTAssertEqual(internalRequests, 0) + } + + func testSendPersistentInBackgroundDoesNotRetryAfterAPIError() { + Current.isBackgroundRequestsImmediate = { false } + let expectedRequest = WebhookRequest(type: "webhook_name", data: ["json": true]) + + var requests = 0 + stub(condition: { [webhookURL1] req in req.url == webhookURL1 }, response: { [api1] _ in + requests += 1 + api1!.server.info.connection.set(address: nil, for: .external) + return HTTPStubsResponse(jsonObject: ["hello": "goodbye"], statusCode: 200, headers: nil) + }) + + XCTAssertThrowsError(try hang(manager.send(server: api1.server, request: expectedRequest))) { error in + guard case HomeAssistantAPI.APIError.noAPIAvailable = error else { + return XCTFail("unexpected error \(error)") + } + } + XCTAssertEqual(requests, 1) + } + + func testSendPersistentDoesNotFallBackAfterAPIErrorFromRemoteURL() throws { + Current.isBackgroundRequestsImmediate = { false } + let urls = try useInternalURLAtHome() + let expectedRequest = WebhookRequest(type: "update_location", data: ["json": true]) + + var internalRequests = 0 + stub(condition: { req in req.url == urls.internal }, response: { _ in + internalRequests += 1 + return HTTPStubsResponse(error: URLError(.cannotConnectToHost)) + }) + + var externalRequests = 0 + stub(condition: { req in req.url == urls.external }, response: { [api1] _ in + externalRequests += 1 + api1!.server.info.connection.set(address: nil, for: .internal) + api1!.server.info.connection.set(address: nil, for: .external) + return HTTPStubsResponse(jsonObject: ["hello": "goodbye"], statusCode: 200, headers: nil) + }) + + XCTAssertThrowsError(try hang(manager.send(server: api1.server, request: expectedRequest))) { error in + guard case HomeAssistantAPI.APIError.noAPIAvailable = error else { + return XCTFail("unexpected error \(error)") + } + } + XCTAssertEqual(internalRequests, 1) + XCTAssertEqual(externalRequests, 1) + } + + func testRemoteWebhookURLUsesRemoteUIWhenCloudIsEnabled() throws { + let urls = try useInternalURLAtHome() + let remoteUIURL = try XCTUnwrap(URL(string: "https://example.ui.nabu.casa")) + api1.server.info.connection.set(address: remoteUIURL, for: .remoteUI) + api1.server.info.connection.useCloud = true + + XCTAssertEqual(WebhookManager.remoteWebhookURL( + for: api1.server, + unreachableInternalURL: urls.internal, + error: URLError(.cannotConnectToHost) + ), remoteUIURL.appendingPathComponent(api1.server.info.connection.webhookPath, isDirectory: false)) + } + + func testRemoteWebhookURLIsNilWhenRemoteURLIsTheInternalURL() throws { + let urls = try useInternalURLAtHome() + let internalBaseURL = try XCTUnwrap(api1.server.info.connection.address(for: .internal)) + api1.server.info.connection.set(address: internalBaseURL, for: .external) + + XCTAssertNil(WebhookManager.remoteWebhookURL( + for: api1.server, + unreachableInternalURL: urls.internal, + error: URLError(.cannotConnectToHost) + )) + } + + func testRemoteWebhookURLIgnoresErrorsThatAreNotTransportFailures() throws { + let urls = try useInternalURLAtHome() + + XCTAssertNil(WebhookManager.remoteWebhookURL( + for: api1.server, + unreachableInternalURL: urls.internal, + error: WebhookError.unacceptableStatusCode(500) + )) + XCTAssertNil(WebhookManager.remoteWebhookURL( + for: api1.server, + unreachableInternalURL: urls.internal, + error: URLError(.cancelled) + )) + XCTAssertEqual(WebhookManager.remoteWebhookURL( + for: api1.server, + unreachableInternalURL: urls.internal, + error: URLError(.dnsLookupFailed) + ), urls.external) + } + + private func useInternalURLAtHome() throws -> (internal: URL, external: URL) { + let internalBaseURL = try XCTUnwrap(URL(string: "http://192.168.1.2:8123")) + api1.server.info.connection.set(address: internalBaseURL, for: .internal) + + let connectivity = Current.connectivity + let previousCurrentNetworkState = connectivity.currentNetworkState + let previousRefreshNetworkInformation = connectivity.refreshNetworkInformation + + connectivity.currentNetworkState = { NetworkState(ssid: "MyWifi") } + connectivity.refreshNetworkInformation = {} + connectivity.updateLastKnownNetworkState(NetworkState(ssid: "MyWifi")) + + addTeardownBlock { + connectivity.currentNetworkState = previousCurrentNetworkState + connectivity.refreshNetworkInformation = previousRefreshNetworkInformation + connectivity.updateLastKnownNetworkState(NetworkState()) + } + + let webhookPath = api1.server.info.connection.webhookPath + let externalBaseURL = try XCTUnwrap(api1.server.info.connection.address(for: .external)) + return ( + internal: internalBaseURL.appendingPathComponent(webhookPath, isDirectory: false), + external: externalBaseURL.appendingPathComponent(webhookPath, isDirectory: false) + ) + } + func testSendPersistentPassively() throws { let request = WebhookRequest(type: "webhook_name", data: ["json": true])