Skip to content

Commit

Permalink
Add request retries implementation (#34)
Browse files Browse the repository at this point in the history
  • Loading branch information
fsufyan authored Oct 2, 2023
1 parent c154cfc commit 9b63c9b
Show file tree
Hide file tree
Showing 12 changed files with 717 additions and 38 deletions.
28 changes: 3 additions & 25 deletions Sources/Nakama/Client.swift
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,8 @@ public protocol Client {
var host: String { get }
var port: Int { get }
var ssl: Bool { get }
var transientErrorAdapter: TransientErrorAdapter? { get }
var globalRetryConfiguration: RetryConfiguration { get set }

/**
Disconnects the client. This function kills all outgoing exchanges immediately without waiting.
Expand Down Expand Up @@ -72,30 +74,6 @@ public protocol Client {
*/
func addGroupUsers(session: Session, groupId: String, ids: String...) async throws -> Void

/**
Authenticate a user with a custom id.
- Parameter id: A custom identifier usually obtained from an external authentication service.
- Returns: A future to resolve a session object.
*/
func authenticateCustom(id: String) async throws -> Session

/**
Authenticate a user with a custom id.
- Parameter id: A custom identifier usually obtained from an external authentication service.
- Parameter create: True if the user should be created when authenticated.
- Returns: A future to resolve a session object.
*/
func authenticateCustom(id: String, create: Bool?) async throws -> Session

/**
Authenticate a user with a custom id.
- Parameter id: A custom identifier usually obtained from an external authentication service.
- Parameter create: True if the user should be created when authenticated.
- Parameter username: A username used to create the user.
- Returns: A future to resolve a session object.
*/
func authenticateCustom(id: String, create: Bool?, username: String?) async throws -> Session

/**
Authenticate a user with a custom id.
- Parameter id: A custom identifier usually obtained from an external authentication service.
Expand All @@ -104,7 +82,7 @@ public protocol Client {
- Parameter vars: Extra information that will be bundled in the session token.
- Returns: A future to resolve a session object.
*/
func authenticateCustom(id: String, create: Bool?, username: String?, vars: [String:String]?) async throws -> Session
func authenticateCustom(id: String, create: Bool?, username: String?, vars: [String:String]?, retryConfig: RetryConfiguration?) async throws -> Session

/**
Authenticate a user with a device id.
Expand Down
29 changes: 16 additions & 13 deletions Sources/Nakama/GrpcClient.swift
Original file line number Diff line number Diff line change
Expand Up @@ -20,14 +20,19 @@ import NIO
import Logging
import SwiftProtobuf

public class GrpcClient : Client {
public final class GrpcClient : Client {

public var eventLoopGroup = MultiThreadedEventLoopGroup(numberOfThreads: 1)
public var retriesLimit = 5
public var globalRetryConfiguration: RetryConfiguration

public let host: String
public let port: Int
public let ssl: Bool
public let transientErrorAdapter: TransientErrorAdapter?

private let retryInvoker: RetryInvoker

let serverKey: String
let grpcConnection: ClientConnection
let nakamaGrpcClient: Nakama_Api_NakamaClientProtocol
Expand All @@ -52,7 +57,7 @@ public class GrpcClient : Client {
small value might be increased. Defaults to 20 seconds.
- Parameter trace: Trace all actions performed by the client. Defaults to false.
*/
public init(serverKey: String, host: String = "127.0.0.1", port: Int = 7349, ssl: Bool = false, deadlineAfter: TimeInterval = 20.0, keepAliveTimeout: TimeAmount = .seconds(20), trace: Bool = false) {
public init(serverKey: String, host: String = "127.0.0.1", port: Int = 7349, ssl: Bool = false, deadlineAfter: TimeInterval = 20.0, keepAliveTimeout: TimeAmount = .seconds(20), trace: Bool = false, transientErrorAdapter: TransientErrorAdapter? = nil) {

let base64Auth = "\(serverKey):".data(using: String.Encoding.utf8)!.base64EncodedString()
let basicAuth = "Basic \(base64Auth)"
Expand Down Expand Up @@ -85,6 +90,11 @@ public class GrpcClient : Client {
self.host = host
self.port = port
self.ssl = ssl
self.transientErrorAdapter = transientErrorAdapter ?? TransientErrorAdapter()

retryInvoker = RetryInvoker(transientErrorAdapter: self.transientErrorAdapter!)
globalRetryConfiguration = RetryConfiguration(baseDelayMs: 500, maxRetries: 4)

self.nakamaGrpcClient = Nakama_Api_NakamaClient(channel: grpcConnection, defaultCallOptions: callOptions)
}

Expand Down Expand Up @@ -124,16 +134,7 @@ public class GrpcClient : Client {
_ = try await self.nakamaGrpcClient.addGroupUsers(req, callOptions: sessionCallOption(session: session)).response.get()
}

public func authenticateCustom(id: String) async throws -> Session {
return try await self.authenticateCustom(id: id, create: nil, username: nil, vars: nil)
}
public func authenticateCustom(id: String, create: Bool?) async throws -> Session {
return try await self.authenticateCustom(id: id, create: nil, username: nil, vars: nil)
}
public func authenticateCustom(id: String, create: Bool?, username: String?) async throws -> Session {
return try await self.authenticateCustom(id: id, create: nil, username: username, vars: nil)
}
public func authenticateCustom(id: String, create: Bool?, username: String?, vars: [String : String]?) async throws -> Session {
public func authenticateCustom(id: String, create: Bool? = nil, username: String? = nil, vars: [String : String]? = nil, retryConfig: RetryConfiguration? = nil) async throws -> Session {
var req = Nakama_Api_AuthenticateCustomRequest()
req.account = Nakama_Api_AccountCustom()
req.account.id = id
Expand All @@ -145,7 +146,9 @@ public class GrpcClient : Client {
if vars != nil {
req.account.vars = vars!
}
return try await self.nakamaGrpcClient.authenticateCustom(req).response.get().toSession()
return try await retryInvoker.invokeWithRetry(request: {
return try await self.nakamaGrpcClient.authenticateCustom(req).response.get()
}, history: RetryHistory(token: id, configuration: retryConfig ?? globalRetryConfiguration)).toSession()
}

public func authenticateDevice(id: String) async throws -> Session {
Expand Down
58 changes: 58 additions & 0 deletions Sources/Nakama/NakamaRandom.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,58 @@
/*
* Copyright © 2023 Heroic Labs
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/

import Foundation

/// A protocol defining a random number generator.
protocol Random {
/// Generates a random double in the range `(0.0, 1.0]`
/// - Returns; A random double between 0.0 (inclusive) and 1.0 (exclusive).
func next() -> Double

/**
Generates a random double within the specified range.
- Parameter range: The half-open range within which the random number is generated. Lower bound is inclusive, and upper bound is exclusive.
- Returns: A random double within the specified range.
*/
func next(in range: Range<Double>) -> Double
}

/// A custom implementation of the `Random` protocol providing a random number generator with a seed.
public final class NakamaRandomGenerator: Random {
private var seed: UInt64

/**
Initialize the instance with the given `seed`.
- Parameter seed: A string used to seed the random number generator.
*/
public init(seed: String) {
var hasher = Hasher()
seed.hash(into: &hasher)
let truncated = UInt32(truncatingIfNeeded: hasher.finalize())

srand48(Int(truncated))
self.seed = UInt64(truncated)
}

public func next() -> Double {
return drand48()
}

public func next(in range: Range<Double>) -> Double {
return range.lowerBound + ((range.upperBound - range.lowerBound) * drand48())
}

}
32 changes: 32 additions & 0 deletions Sources/Nakama/RequestRetry/Retry.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,32 @@
/*
* Copyright © 2023 Heroic Labs
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/

import Foundation

/// Represents a single retry attempt.
public final class Retry
{
/// The delay (milliseconds) in the request retry attributable to the exponential backoff algorithm.
let exponentialBackoff: Int

/// The delay (milliseconds) in the request retry attributable to the jitter algorithm.
let jitterBackoff: Int

public init(exponentialBackoff: Int, jitterBackoff: Int) {
self.exponentialBackoff = exponentialBackoff
self.jitterBackoff = jitterBackoff
}
}
54 changes: 54 additions & 0 deletions Sources/Nakama/RequestRetry/RetryConfiguration.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,54 @@
/*
* Copyright © 2023 Heroic Labs
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/

import Foundation

/**
A configuration for controlling retriable requests.

Configurations can be assigned to the `Client` on a request-by-request basis via `RequestConfiguration`.

It can also be assigned on a global basis using `GlobalRetryConfiguration`.

Configurations passed via the `RequestConfiguration` parameter take precedence over the global configuration.
*/
public final class RetryConfiguration {
/**
The base delay (milliseconds) used to calculate the time before making another request attempt.
This base will be raised to N, where N is the number of retry attempts.
*/
var baseDelay: Int

/// The maximum number of attempts to make before cancelling the request task.
var maxRetries: Int

/// The jitter algorithm used to apply randomness to the retry delay. Defaults to `FullJitter`
var jitter: Jitter

/// A closure that is invoked before a new retry attempt is made.
var retryListener: RetryListener

public init(baseDelayMs: Int, maxRetries: Int, jitter: Jitter? = nil, retryListener: RetryListener? = nil) {
self.baseDelay = baseDelayMs
self.maxRetries = maxRetries
self.jitter = jitter ?? { retries, delayMs, random in
return RetryJitter.fullJitter(retries: retries, retryDelay: delayMs, random: random)
}
self.retryListener = retryListener ?? { retriesCount, retry in

}
}
}
54 changes: 54 additions & 0 deletions Sources/Nakama/RequestRetry/RetryHistory.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,54 @@
/*
* Copyright © 2023 Heroic Labs
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/

import Foundation

/// Represents a history of retry attempts.
public final class RetryHistory
{
/// The configuration for retry behavior.
var configuration: RetryConfiguration?

/// An array containing individual retry attempts.
var retries: [Retry]

/// A seeded random number generator.
var random: NakamaRandomGenerator

/// Initializes a `RetryHistory` instance with a given token and retry configuration.
///
/// Typically called with the Nakama authentication methods using the id or token as a seed for the `RNG`.
/// - Parameters:
/// - token: The id or authentication token used to seed the random number generator.
/// - configuration: The configuration specifying retry behavior.
public init(token: String, configuration: RetryConfiguration) {
self.configuration = configuration
self.retries = []
self.random = NakamaRandomGenerator(seed: token)
}

/// A convenience initializer that creates a `RetryHistory` instance using an existing session.
///
/// Typically called with other Nakama methods after obtaining an authentication token.
/// The auth token will be used as a seed for the random generator.
///
/// - Parameters:
/// - session: The authenticated session providing the token.
/// - configuration: The configuration specifying retry behavior.
public convenience init(session: Session, configuration: RetryConfiguration) {
self.init(token: session.token, configuration: configuration)
}
}
Loading

0 comments on commit 9b63c9b

Please sign in to comment.