Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Add a global error handler focused on non-route errors #436

Merged
merged 6 commits into from
Dec 10, 2024
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
26 changes: 26 additions & 0 deletions Source/SwiftyDropbox/Shared/Handwritten/Errors.swift
Original file line number Diff line number Diff line change
Expand Up @@ -106,6 +106,32 @@ public enum CallError<EType>: Error, CustomStringConvertible {
return "\(err)"
}
}

// Used for global error handlers which cannot know the type of the boxed route error
public var typeErased: CallError<Any> {
switch self {
case .internalServerError(let code, let msg, let requestId):
return .internalServerError(code, msg, requestId)
case .badInputError(let msg, let requestId):
return .badInputError(msg, requestId)
case .rateLimitError(let error, let locMsg, let msg, let requestId):
return .rateLimitError(error, locMsg, msg, requestId)
case .httpError(let code, let msg, let requestId):
return .httpError(code, msg, requestId)
case .authError(let error, let locMsg, let msg, let requestId):
return .authError(error, locMsg, msg, requestId)
case .accessError(let error, let locMsg, let msg, let requestId):
return .accessError(error, locMsg, msg, requestId)
case .routeError(let boxedError, let locMsg, let msg, let requestId):
return .routeError(Box(boxedError.unboxed as Any), locMsg, msg, requestId)
case .serializationError(let error):
return .serializationError(error)
case .reconnectionError(let error):
return .reconnectionError(error)
case .clientError(let error):
return .clientError(error)
}
}

static func error<ESerial: JSONSerializer>(response: HTTPURLResponse, data: Data?, errorSerializer: ESerial) throws -> CallError<ESerial.ValueType> {
let requestId = requestId(from: response)
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,69 @@
import Foundation

/// Similar to `DBGlobalErrorResponseHandler` in the Objc SDK
/// It does not have special handling for route errors, which end up type-erased right now
/// It also does not allow you to easily retry requests yet, like Objc's does.
/// Call `registerGlobalErrorHandler` to register a global error handler callback
/// Call `deregisterGlobalErrorHandler` to deregister it
/// Call `deregisterAllGlobalErrorHandlers` to deregister all global error handler callbacks
public class GlobalErrorResponseHandler {
/// Singleton instance of `GlobalErrorResponseHandler`
public static var shared = { GlobalErrorResponseHandler() }()

private struct Handler {
let callback: (CallError<Any>) -> Void
let queue: OperationQueue
}

// Locked state
private struct State {
var handlers: [String:Handler] = [:]
}

private var state = UnfairLock<State>(value: State())

internal init() { }

internal func reportGlobalError(_ error: CallError<Any>) {
state.read { lockedState in
lockedState.handlers.forEach { _, handler in
handler.queue.addOperation {
handler.callback(error)
}
}
}
}


/// Register a callback to be called in addition to the normal completion handler for a request.
/// You can use the callback to accomplish global error handling, such as logging errors or logging a user out after an `AuthError`.
/// - Parameters:
/// - callback: The function you'd like called when an error occurs, it is provided a type-erased `CallError` that you can switch on. If you know the specific route error type you want to look for, you can unbox and cast the contained route error value to that type, in the case of a route error.
/// - queue: The queue on which the callback should be called. Defaults to the main queue via `OperationQueue.main`.
/// - Returns: A key you can use to deregister the callback later. It's just a UUID string.
@discardableResult
public func registerGlobalErrorHandler(_ callback: @escaping (CallError<Any>) -> Void, queue: OperationQueue = .main) -> String {
let key = UUID().uuidString
state.mutate { lockedState in
lockedState.handlers[key] = Handler(callback: callback, queue: queue)
}
return key
}


/// Remove a global error handler callback by its key
/// - Parameter key: The key returned when you registered the callback
public func deregisterGlobalErrorHandler(key: String) {
state.mutate { lockedState in
lockedState.handlers[key] = nil
}
}


/// Remove all global error handler callbacks, regardless of key.
public func deregisterAllGlobalErrorHandlers() {
state.mutate { lockedState in
lockedState.handlers = [:]
}
}
}
10 changes: 10 additions & 0 deletions Source/SwiftyDropbox/Shared/Handwritten/Request.swift
Original file line number Diff line number Diff line change
Expand Up @@ -72,6 +72,16 @@ public class Request<RSerial: JSONSerializer, ESerial: JSONSerializer> {
}

func handleResponseError(networkTaskFailure: NetworkTaskFailure) -> CallError<ESerial.ValueType> {
let callError = parseCallError(from: networkTaskFailure)

// We call the global error response handler to alert it to an error
// But unlike in the objc SDK we do not stop the SDK from calling the per-route completion handler as well.
GlobalErrorResponseHandler.shared.reportGlobalError(callError.typeErased)

return callError
}

private func parseCallError(from networkTaskFailure: NetworkTaskFailure) -> CallError<ESerial.ValueType> {
switch networkTaskFailure {
case .badStatusCode(let data, _, let response):
return CallError(response, data: data, errorSerializer: errorSerializer)
Expand Down
43 changes: 43 additions & 0 deletions Source/SwiftyDropbox/Shared/Handwritten/Utilities.swift
Original file line number Diff line number Diff line change
Expand Up @@ -124,3 +124,46 @@ public enum LogHelper {
log(backgroundSessionLogLevel, "bg session - \(message)")
}
}

// MARK: Locking
/// Wrapper around a value with a lock
///
/// Copied from https://github.com/home-assistant/HAKit/blob/main/Source/Internal/HAProtected.swift
public class UnfairLock<ValueType> {
private var value: ValueType
private let lock: os_unfair_lock_t = {
let value = os_unfair_lock_t.allocate(capacity: 1)
value.initialize(to: os_unfair_lock())
return value
}()

/// Create a new protected value
/// - Parameter value: The initial value
public init(value: ValueType) {
self.value = value
}

deinit {
lock.deinitialize(count: 1)
lock.deallocate()
}

/// Get and optionally change the value
/// - Parameter handler: Will be invoked immediately with the current value as an inout parameter.
/// - Returns: The value returned by the handler block
@discardableResult
public func mutate<HandlerType>(using handler: (inout ValueType) -> HandlerType) -> HandlerType {
os_unfair_lock_lock(lock)
defer { os_unfair_lock_unlock(lock) }
return handler(&value)
}

/// Read the value and get a result out of it
/// - Parameter handler: Will be invoked immediately with the current value.
/// - Returns: The value returned by the handler block
public func read<T>(_ handler: (ValueType) -> T) -> T {
os_unfair_lock_lock(lock)
defer { os_unfair_lock_unlock(lock) }
return handler(value)
}
}
203 changes: 203 additions & 0 deletions Source/SwiftyDropboxUnitTests/GlobalErrorResponseHandlerTests.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,203 @@
import XCTest
@testable import SwiftyDropbox

class GlobalErrorResponseHandlerTests: XCTestCase {
jboulter11 marked this conversation as resolved.
Show resolved Hide resolved
private var handler: GlobalErrorResponseHandler!
override func setUp() {
handler = GlobalErrorResponseHandler()
super.setUp()
}

func testGlobalHandlerReportsNonRouteError() {
let error = CallError<String>.authError(Auth.AuthError.expiredAccessToken, LocalizedUserMessage(text: "ábc", locale: "EN-US"), "abc", "def")
let expectation = XCTestExpectation(description: "Callback is called")
handler.registerGlobalErrorHandler { error in
guard case .authError(let authError, let locMessage, let message, let requestId) = error else {
return XCTFail("Expected error")
}
do {
let authErrorJson = try authError.json()
let expectedJson = try Auth.AuthError.expiredAccessToken.json()
XCTAssertEqual(authErrorJson, expectedJson)
} catch {
XCTFail("Error serializing auth error")
}
XCTAssertEqual(locMessage?.text, "ábc")
XCTAssertEqual(locMessage?.locale, "EN-US")
XCTAssertEqual(message, "abc")
XCTAssertEqual(requestId, "def")
expectation.fulfill()
}
handler.reportGlobalError(error.typeErased)
}

func testGlobalHandlerReportsRouteError() {
let error = CallError<String>.routeError(Box("value"), LocalizedUserMessage(text: "ábc", locale: "EN-US"), "abc", "def")
let expectation = XCTestExpectation(description: "Callback is called")
handler.registerGlobalErrorHandler { error in
guard case .routeError(let boxedValue, let locMessage, let message, let requestId) = error else {
return XCTFail("Expected error")
}
XCTAssertEqual(boxedValue.unboxed as? String, "value")
XCTAssertEqual(locMessage?.text, "ábc")
XCTAssertEqual(locMessage?.locale, "EN-US")
XCTAssertEqual(message, "abc")
XCTAssertEqual(requestId, "def")
expectation.fulfill()
}
handler.reportGlobalError(error.typeErased)
}

func testDeregisterGlobalHandler() {
let key = handler.registerGlobalErrorHandler { error in
XCTFail("Should not be called")
}
handler.deregisterGlobalErrorHandler(key: key)
let error = CallError<String>.authError(Auth.AuthError.expiredAccessToken, LocalizedUserMessage(text: "ábc", locale: "EN-US"), "abc", "def")
handler.reportGlobalError(error.typeErased)
}

func testDeregisterAllGlobalHandlers() {
_ = handler.registerGlobalErrorHandler { error in
XCTFail("Should not be called")
}
_ = handler.registerGlobalErrorHandler { error in
XCTFail("Should not be called")
}
handler.deregisterAllGlobalErrorHandlers()
let error = CallError<String>.authError(Auth.AuthError.expiredAccessToken, LocalizedUserMessage(text: "ábc", locale: "EN-US"), "abc", "def")
handler.reportGlobalError(error.typeErased)
}
}

class CallErrorTypeErasureTests: XCTestCase {
let cases: [CallError<String>] = [
.internalServerError(567, "abc", "def"),
.badInputError("abc", "def"),
.rateLimitError(.init(reason: .tooManyRequests), LocalizedUserMessage(text: "ábc", locale: "EN-US"), "abc", "def"),
.httpError(456, "abc", "def"),
.authError(Auth.AuthError.userSuspended, LocalizedUserMessage(text: "ábc", locale: "EN-US"), "abc", "def"),
.accessError(Auth.AccessError.other, LocalizedUserMessage(text: "ábc", locale: "EN-US"), "abc", "def"),
.routeError(Box("value"), LocalizedUserMessage(text: "ábc", locale: "EN-US"), "abc", "def"),
.serializationError(SerializationError.missingResultData),
.reconnectionError(ReconnectionError(reconnectionErrorKind: .badPersistedStringFormat, taskDescription: "bad")),
.clientError(.unexpectedState),
]
func testErrorTypeErasureProvidesSameDescription() {
for error in cases {
let typeErased = error.typeErased
XCTAssertEqual(typeErased.description, error.description)
}
}
}

class RequestGlobalErrorHandlerIntegrationTests: XCTestCase {

var client: DropboxClient!
var mockTransportClient: MockDropboxTransportClient!

override func setUp() {
mockTransportClient = MockDropboxTransportClient()
client = DropboxClient(transportClient: mockTransportClient)
super.setUp()
}

override func tearDown() {
// GlobalErrorResponseHandler.shared.removeAllHandlers()
super.tearDown()
}

func testRpcRequestGlobalErrorHandler() {
let handler = GlobalErrorResponseHandler.shared
let globalExpectation = XCTestExpectation(description: "Callback is called")
let key = handler.registerGlobalErrorHandler { error in
guard case .authError(let authError, _, _, _) = error else {
return XCTFail("Expected error")
}
do {
let authErrorJson = try authError.json()
let expectedJson = try Auth.AuthError.expiredAccessToken.json()
XCTAssertEqual(authErrorJson, expectedJson)
} catch {
XCTFail("Error serializing auth error")
}
globalExpectation.fulfill()
}

mockTransportClient.mockRequestHandler = { request in
try? request.handleMockInput(.requestError(model: Auth.AuthError.expiredAccessToken, code: 401))
}

let completionHandlerExpectation = XCTestExpectation(description: "Callback is called")
client.check.user().response { _, error in
XCTAssertNotNil(error)
completionHandlerExpectation.fulfill()
}

handler.deregisterGlobalErrorHandler(key: key)
}

func testDownloadRequestGlobalErrorHandler() {
let handler = GlobalErrorResponseHandler.shared
let globalExpectation = XCTestExpectation(description: "Callback is called")
let key = handler.registerGlobalErrorHandler { error in
guard case .authError(let authError, _, _, _) = error else {
return XCTFail("Expected error")
}
do {
let authErrorJson = try authError.json()
let expectedJson = try Auth.AuthError.expiredAccessToken.json()
XCTAssertEqual(authErrorJson, expectedJson)
} catch {
XCTFail("Error serializing auth error")
}
globalExpectation.fulfill()
}

mockTransportClient.mockRequestHandler = { request in
try? request.handleMockInput(.requestError(model: Auth.AuthError.expiredAccessToken, code: 401))
}

let completionHandlerExpectation = XCTestExpectation(description: "Callback is called")
client.files.download(path: "/test/path.pdf").response { _, error in
XCTAssertNotNil(error)
completionHandlerExpectation.fulfill()
}

handler.deregisterGlobalErrorHandler(key: key)
}

func testUploadRequestGlobalErrorHandler() {
let handler = GlobalErrorResponseHandler.shared
let globalExpectation = XCTestExpectation(description: "Callback is called")
let key = handler.registerGlobalErrorHandler { error in
guard case .authError(let authError, _, _, _) = error else {
return XCTFail("Expected error")
}
do {
let authErrorJson = try authError.json()
let expectedJson = try Auth.AuthError.expiredAccessToken.json()
XCTAssertEqual(authErrorJson, expectedJson)
} catch {
XCTFail("Error serializing auth error")
}
globalExpectation.fulfill()
}

mockTransportClient.mockRequestHandler = { request in
try? request.handleMockInput(.requestError(model: Auth.AuthError.expiredAccessToken, code: 401))
}

let completionHandlerExpectation = XCTestExpectation(description: "Callback is called")
guard let testData = "test".data(using: .utf8) else {
XCTFail("Failed to create test data")
return
}
client.files.upload(path: "/test/path.pdf", input: testData).response { _, error in
XCTAssertNotNil(error)
completionHandlerExpectation.fulfill()
}

handler.deregisterGlobalErrorHandler(key: key)
}
}
12 changes: 6 additions & 6 deletions TestSwiftyDropbox/Podfile.lock
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
PODS:
- SwiftyDropbox (10.0.0)
- SwiftyDropboxObjC (10.0.0):
- SwiftyDropbox (~> 10.0.0)
- SwiftyDropbox (10.1.0)
Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Unclear if we want this?

Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

i think it's probably safe to keep

- SwiftyDropboxObjC (10.1.0):
- SwiftyDropbox (~> 10.1.0)

DEPENDENCIES:
- SwiftyDropbox (from `../`)
Expand All @@ -14,9 +14,9 @@ EXTERNAL SOURCES:
:path: "../"

SPEC CHECKSUMS:
SwiftyDropbox: ef7961c2d64e7ac48d4f88d39e6db5554375f774
SwiftyDropboxObjC: b99c342560286cdfca8eeff54ac15515d002deaf
SwiftyDropbox: a023f55cb1bd8a81bb48c33eea529cd71faf8e29
SwiftyDropboxObjC: c7f2189b65386016ea29942d222863dcd03159ac

PODFILE CHECKSUM: e0f6bd11b29f3d698a8cf15c435947aa1ac4eee1

COCOAPODS: 1.14.3
COCOAPODS: 1.16.2