Skip to content

Commit 9ecbce2

Browse files
Merge pull request #665 from algolia/feat/unreachable-hosts-exception
Fix retry strategy behaviour if all the hosts are unreachable
2 parents d37eb48 + 0c94c1b commit 9ecbce2

File tree

8 files changed

+113
-53
lines changed

8 files changed

+113
-53
lines changed

Sources/AlgoliaSearchClient/Transport/HTTP/HTTPRequest.swift

Lines changed: 7 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -14,12 +14,13 @@ class HTTPRequest<ResponseType: Decodable, Output>: AsyncOperation, ResultContai
1414
typealias Result = Swift.Result<Output, Swift.Error>
1515

1616
let requester: HTTPRequester
17-
let hostIterator: HostIterator
1817
let retryStrategy: RetryStrategy
1918
let timeout: TimeInterval
2019
let request: URLRequest
20+
let callType: CallType
2121
let transform: (ResponseType) -> Output
2222
let completion: (Result) -> Void
23+
let hostIterator: HostIterator
2324
var didUpdateProgress: ((ProgressReporting) -> Void)?
2425

2526
var underlyingTask: (TransportTask)? {
@@ -42,19 +43,20 @@ class HTTPRequest<ResponseType: Decodable, Output>: AsyncOperation, ResultContai
4243

4344
init(requester: HTTPRequester,
4445
retryStrategy: RetryStrategy,
45-
hostIterator: HostIterator,
4646
request: URLRequest,
47+
callType: CallType,
4748
timeout: TimeInterval,
4849
transform: @escaping Transform,
4950
completion: @escaping (Result) -> Void) {
5051
self.requester = requester
5152
self.retryStrategy = retryStrategy
52-
self.hostIterator = hostIterator
5353
self.request = request
54+
self.callType = callType
5455
self.timeout = timeout
5556
self.transform = transform
5657
self.completion = completion
5758
self.underlyingTask = nil
59+
self.hostIterator = retryStrategy.retryableHosts(for: callType)
5860
}
5961

6062
override func main() {
@@ -151,12 +153,13 @@ extension HTTPRequest where ResponseType == Output {
151153
retryStrategy: RetryStrategy,
152154
hostIterator: HostIterator,
153155
request: URLRequest,
156+
callType: CallType,
154157
timeout: TimeInterval,
155158
completion: @escaping (Result) -> Void) {
156159
self.init(requester: requester,
157160
retryStrategy: retryStrategy,
158-
hostIterator: hostIterator,
159161
request: request,
162+
callType: callType,
160163
timeout: timeout,
161164
transform: { $0 },
162165
completion: completion)

Sources/AlgoliaSearchClient/Transport/HTTP/HTTPRequestBuilder.swift

Lines changed: 10 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -27,15 +27,22 @@ class HTTPRequestBuilder {
2727
func build<Response: Decodable, Output>(for command: AlgoliaCommand, transform: @escaping (Response) -> Output, with completion: @escaping (HTTPRequest<Response, Output>.Result) -> Void) -> HTTPRequest<Response, Output> {
2828

2929
let timeout = command.requestOptions?.timeout(for: command.callType) ?? configuration.timeout(for: command.callType)
30-
let hostIterator = HostIterator(retryStrategy: retryStrategy, callType: command.callType)
3130
var request = command.urlRequest.setIfNotNil(\.credentials, to: credentials)
3231
let userAgentsValue = UserAgentController.userAgents.map(\.description).joined(separator: "; ")
3332
request.addValue(userAgentsValue, forHTTPHeaderField: HTTPHeaderKey.userAgent.rawValue)
34-
return HTTPRequest(requester: requester, retryStrategy: retryStrategy, hostIterator: hostIterator, request: request, timeout: timeout, transform: transform, completion: completion)
33+
return HTTPRequest(requester: requester,
34+
retryStrategy: retryStrategy,
35+
request: request,
36+
callType: command.callType,
37+
timeout: timeout,
38+
transform: transform,
39+
completion: completion)
3540
}
3641

3742
func build<Response: Decodable, Output>(for command: AlgoliaCommand, transform: @escaping (Response) -> Output, responseType: Output.Type) -> HTTPRequest<Response, Output> {
38-
return build(for: command, transform: transform, with: { (_:HTTPRequest<Response, Output>.Result) in })
43+
return build(for: command,
44+
transform: transform,
45+
with: { (_:HTTPRequest<Response, Output>.Result) in })
3946
}
4047

4148
}

Sources/AlgoliaSearchClient/Transport/HTTP/HTTPTransport+Error.swift

Lines changed: 13 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -9,9 +9,21 @@ import Foundation
99

1010
extension HTTPTransport {
1111

12-
enum Error: Swift.Error {
12+
enum Error: Swift.Error, LocalizedError {
1313
case noReachableHosts
1414
case missingData
15+
case decodingFailure(Swift.Error)
16+
17+
var localizedDescription: String {
18+
switch self {
19+
case .noReachableHosts:
20+
return "All hosts are unreachable"
21+
case .missingData:
22+
return "Missing response data"
23+
case .decodingFailure:
24+
return "Response decoding failed"
25+
}
26+
}
1527
}
1628

1729
}

Sources/AlgoliaSearchClient/Transport/HTTP/HTTPTransport+Result.swift

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -32,7 +32,7 @@ extension Result where Success: Decodable, Failure == Error {
3232
let object = try jsonDecoder.decode(Success.self, from: data)
3333
self = .success(object)
3434
} catch let error {
35-
self = .failure(error)
35+
self = .failure(HTTPTransport.Error.decodingFailure(error))
3636
}
3737

3838
}

Sources/AlgoliaSearchClient/Transport/RetryStrategy/AlgoliaRetryStrategy.swift

Lines changed: 10 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -25,23 +25,21 @@ class AlgoliaRetryStrategy: RetryStrategy {
2525
convenience init(configuration: Configuration) {
2626
self.init(hosts: configuration.hosts)
2727
}
28-
29-
func host(for callType: CallType) -> RetryableHost? {
28+
29+
func retryableHosts(for callType: CallType) -> HostIterator {
3030
return queue.sync {
31+
// Reset expired hosts
3132
hosts.resetExpired(expirationDelay: hostsExpirationDelay)
32-
33-
func firstUpHostForCallType() -> RetryableHost? {
34-
return hosts.first { $0.supports(callType) && $0.isUp }
35-
}
36-
37-
guard let host = firstUpHostForCallType() else {
33+
34+
// If all hosts of the required type are down, reset them all
35+
if !hosts.contains(where: { $0.supports(callType) && $0.isUp }) {
3836
hosts.resetAll(for: callType)
39-
return firstUpHostForCallType()
4037
}
41-
42-
return host
38+
39+
return HostIterator { [weak self] in
40+
self?.hosts.first { $0.supports(callType) && $0.isUp }
41+
}
4342
}
44-
4543
}
4644

4745
func notify<T>(host: RetryableHost, result: Result<T, Swift.Error>) -> RetryOutcome<T> {

Sources/AlgoliaSearchClient/Transport/RetryStrategy/HostIterator.swift

Lines changed: 5 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -9,16 +9,14 @@ import Foundation
99

1010
class HostIterator: IteratorProtocol {
1111

12-
var retryStrategy: RetryStrategy
13-
let callType: CallType
14-
15-
init(retryStrategy: RetryStrategy, callType: CallType) {
16-
self.retryStrategy = retryStrategy
17-
self.callType = callType
12+
var getHost: () -> RetryableHost?
13+
14+
init(getHost: @escaping () -> RetryableHost?) {
15+
self.getHost = getHost
1816
}
1917

2018
func next() -> RetryableHost? {
21-
return retryStrategy.host(for: callType)
19+
return getHost()
2220
}
2321

2422
}

Sources/AlgoliaSearchClient/Transport/RetryStrategy/RetryStrategy.swift

Lines changed: 1 addition & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -7,13 +7,9 @@
77

88
import Foundation
99

10-
protocol HostResultNotifiable {
11-
12-
}
13-
1410
protocol RetryStrategy: class {
1511

16-
func host(for callType: CallType) -> RetryableHost?
12+
func retryableHosts(for callType: CallType) -> HostIterator
1713
func notify<T>(host: RetryableHost, result: Result<T, Swift.Error>) -> RetryOutcome<T>
1814

1915
}

Tests/AlgoliaSearchClientTests/Unit/AlgoliaRetryStrategyTests.swift

Lines changed: 66 additions & 20 deletions
Original file line numberDiff line numberDiff line change
@@ -24,49 +24,92 @@ class AlgoliaRetryStrategyTests: XCTestCase {
2424
func testHostsRotation() {
2525

2626
let strategy = AlgoliaRetryStrategy(hosts: [host1, host2, host3, host4, host5])
27-
28-
XCTAssertEqual(strategy.host(for: .write)?.url.absoluteString, "algolia1.com")
29-
XCTAssertEqual(strategy.host(for: .read)?.url.absoluteString, "algolia3.com")
30-
27+
28+
let writeHosts = strategy.retryableHosts(for: .write)
29+
let readHosts = strategy.retryableHosts(for: .read)
30+
31+
// Fresh state, first read and write hosts are available
32+
XCTAssertEqual(writeHosts.next()?.url.absoluteString, "algolia1.com")
33+
XCTAssertEqual(readHosts.next()?.url.absoluteString, "algolia3.com")
34+
35+
// First write host failed
3136
XCTAssertNoThrow(strategy.notify(host: host1, result: retryableErrorResult))
32-
XCTAssertEqual(strategy.host(for: .write)?.url.absoluteString, "algolia2.com")
33-
XCTAssertEqual(strategy.host(for: .read)?.url.absoluteString, "algolia3.com")
37+
// Second write host is available now
38+
XCTAssertEqual(writeHosts.next()?.url.absoluteString, "algolia2.com")
39+
// First read host is still available
40+
XCTAssertEqual(readHosts.next()?.url.absoluteString, "algolia3.com")
3441

42+
// Second write host failed
3543
_ = strategy.notify(host: host2, result: retryableErrorResult)
36-
XCTAssertEqual(strategy.host(for: .write)?.url.absoluteString, "algolia1.com")
37-
XCTAssertEqual(strategy.host(for: .read)?.url.absoluteString, "algolia3.com")
44+
// No more writable hosts available
45+
XCTAssertEqual(writeHosts.next()?.url.absoluteString, nil)
46+
// First read host is still available
47+
XCTAssertEqual(readHosts.next()?.url.absoluteString, "algolia3.com")
3848

49+
// First read host failed
3950
_ = strategy.notify(host: host3, result: retryableErrorResult)
40-
XCTAssertEqual(strategy.host(for: .write)?.url.absoluteString, "algolia1.com")
41-
XCTAssertEqual(strategy.host(for: .read)?.url.absoluteString, "algolia4.com")
51+
// No more writable hosts available
52+
XCTAssertEqual(writeHosts.next()?.url.absoluteString, nil)
53+
// Second read host is available
54+
XCTAssertEqual(readHosts.next()?.url.absoluteString, "algolia4.com")
4255

56+
// Second read host failed
4357
_ = strategy.notify(host: host4, result: retryableErrorResult)
44-
XCTAssertEqual(strategy.host(for: .write)?.url.absoluteString, "algolia1.com")
45-
XCTAssertEqual(strategy.host(for: .read)?.url.absoluteString, "algolia5.com")
58+
// No more writable hosts available
59+
XCTAssertEqual(writeHosts.next()?.url.absoluteString, nil)
60+
// Third read host is available
61+
XCTAssertEqual(readHosts.next()?.url.absoluteString, "algolia5.com")
4662

63+
// Third read host failed
4764
_ = strategy.notify(host: host5, result: retryableErrorResult)
48-
XCTAssertEqual(strategy.host(for: .write)?.url.absoluteString, "algolia1.com")
49-
XCTAssertEqual(strategy.host(for: .read)?.url.absoluteString, "algolia3.com")
65+
// No more writable hosts available
66+
XCTAssertEqual(writeHosts.next()?.url.absoluteString, nil)
67+
// No more writable hosts available
68+
XCTAssertEqual(readHosts.next()?.url.absoluteString, nil)
69+
70+
// When got new write hosts, all the write hosts reseted
71+
let newWriteHosts = strategy.retryableHosts(for: .write)
72+
XCTAssertEqual(newWriteHosts.next()?.url.absoluteString, "algolia1.com")
73+
74+
// Previous write iterator is updated as well as it uses the weak reference to the same retry strategy
75+
XCTAssertEqual(writeHosts.next()?.url.absoluteString, "algolia1.com")
76+
77+
// Still no read host available
78+
XCTAssertEqual(readHosts.next()?.url.absoluteString, nil)
79+
80+
81+
// When got new read hosts, all the read hosts reseted
82+
let newReadHosts = strategy.retryableHosts(for: .read)
83+
XCTAssertEqual(newReadHosts.next()?.url.absoluteString, "algolia3.com")
84+
85+
// Previous read iterator is updated as well as it uses the weak reference to the same retry strategy
86+
XCTAssertEqual(readHosts.next()?.url.absoluteString, "algolia3.com")
87+
88+
// Same state kept for write hosts iterators
89+
XCTAssertEqual(writeHosts.next()?.url.absoluteString, "algolia1.com")
90+
XCTAssertEqual(newWriteHosts.next()?.url.absoluteString, "algolia1.com")
91+
5092

5193
}
5294

5395
func testTimeout() {
5496

5597
let strategy = AlgoliaRetryStrategy(hosts: [host3, host4, host5])
56-
98+
let readHosts = strategy.retryableHosts(for: .read)
99+
57100
var host: RetryableHost?
58-
var lastUpdate: Date? = strategy.host(for: .read)?.lastUpdated
101+
var lastUpdate: Date? = readHosts.next()?.lastUpdated
59102

60103
_ = strategy.notify(host: host3, result: timeoutErrorResult)
61-
host = strategy.host(for: .read)
104+
host = readHosts.next()
62105
XCTAssertGreaterThan(host!.lastUpdated.timeIntervalSince1970, lastUpdate!.timeIntervalSince1970)
63106
lastUpdate = host?.lastUpdated
64107
XCTAssertNotNil(host)
65108
XCTAssertTrue(host!.isUp)
66109
XCTAssertEqual(host!.retryCount, 1)
67110

68111
_ = strategy.notify(host: host3, result: timeoutErrorResult)
69-
host = strategy.host(for: .read)
112+
host = readHosts.next()
70113
XCTAssertGreaterThan(host!.lastUpdated.timeIntervalSince1970, lastUpdate!.timeIntervalSince1970)
71114
XCTAssertNotNil(host)
72115
XCTAssertTrue(host!.isUp)
@@ -77,14 +120,17 @@ class AlgoliaRetryStrategyTests: XCTestCase {
77120
func testResetExpired() {
78121
let expirationDelay: TimeInterval = 3
79122
let strategy = AlgoliaRetryStrategy(hosts: [host3, host4, host5], hostsExpirationDelay: expirationDelay)
80-
123+
_ = strategy.retryableHosts(for: .read)
124+
81125
_ = strategy.notify(host: host3, result: retryableErrorResult)
82126
_ = strategy.notify(host: host4, result: retryableErrorResult)
83127
_ = strategy.notify(host: host5, result: retryableErrorResult)
84128

85129
sleep(UInt32(expirationDelay))
130+
131+
let newReadHosts = strategy.retryableHosts(for: .read)
86132

87-
XCTAssertEqual(strategy.host(for: .read)?.url.absoluteString, "algolia3.com")
133+
XCTAssertEqual(newReadHosts.next()?.url.absoluteString, "algolia3.com")
88134
}
89135

90136
}

0 commit comments

Comments
 (0)