Skip to content

Commit 992bc41

Browse files
[MBL-19602][S/T/P] Revision of API rate limit exceeded retrial logic (#3809)
refs: MBL-19602 affects: Student, Teacher, Parent builds: Student, Teacher, Parent release note: none
1 parent 510c205 commit 992bc41

File tree

4 files changed

+80
-11
lines changed

4 files changed

+80
-11
lines changed

Core/Core/Common/CommonModels/API/API.swift

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -60,8 +60,8 @@ public class API {
6060
}
6161

6262
// If the request is rejected due to the rate limit being exhausted we retry and hope that the quota is restored in the meantime
63-
if response?.exceededLimit(responseData: data) == true {
64-
DispatchQueue.main.asyncAfter(deadline: .now() + .seconds(1)) { [weak self] in
63+
if let retrialTime = response?.retrialTimeOnRateLimitExceeded(responseData: data) {
64+
DispatchQueue.main.asyncAfter(deadline: retrialTime) { [weak self] in
6565
self?.makeRequest(requestable, callback: callback)
6666
}
6767
return

Core/Core/Common/Extensions/Foundation/URLResponseExtensions.swift

Lines changed: 12 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -59,7 +59,18 @@ extension URLResponse {
5959
}
6060

6161
// hasPrefix is because we don't care about the line break character at the end
62-
return httpResponse.statusCode == 403 && stringData.hasPrefix("403 Forbidden (Rate Limit Exceeded)")
62+
let is403 = httpResponse.statusCode == 403 && stringData.hasPrefix("403 Forbidden (Rate Limit Exceeded)")
63+
64+
// Generally, received as "429 Too Many Requests (Rate Limit Exceeded)"
65+
let is429 = httpResponse.statusCode == 429 && stringData.contains("(Rate Limit Exceeded)")
66+
67+
return is403 || is429
68+
}
69+
70+
public func retrialTimeOnRateLimitExceeded(responseData: Data?) -> DispatchTime? {
71+
guard exceededLimit(responseData: responseData) else { return nil }
72+
73+
return .now() + .seconds(1) + .milliseconds(Int.random(in: 0...500))
6374
}
6475
}
6576

Core/CoreTests/Common/CommonModels/API/APITests.swift

Lines changed: 14 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -82,6 +82,7 @@ class APITests: XCTestCase {
8282
override func setUp() {
8383
super.setUp()
8484
API.resetMocks(useMocks: false)
85+
LoginSession.clearAll()
8586
AppEnvironment.shared.userDidLogin(session: .make())
8687
}
8788

@@ -355,18 +356,26 @@ class APITests: XCTestCase {
355356
XCTAssertNotNil(error)
356357
}
357358

358-
func testRetryOnRateLimitedRequest() throws {
359+
func testRetryOnRateLimitedRequest_403() throws {
360+
testRetry(forErrorCode: 403, message: "403 Forbidden (Rate Limit Exceeded)\n")
361+
}
362+
363+
func testRetryOnRateLimitedRequest_429() throws {
364+
testRetry(forErrorCode: 429, message: "429 Too Many Requests (Rate Limit Exceeded)")
365+
}
366+
367+
private func testRetry(forErrorCode code: Int, message: String) {
359368
API.resetMocks()
360369

361370
let request = GetNoContent()
362-
var invocationCount = 0
363371

372+
var invocationCount = 0
364373
api.mock(withData: request) { _ in
365374
switch invocationCount {
366375
case 0:
367376
invocationCount += 1
368-
let rateLimitResponse = HTTPURLResponse(url: .make(), statusCode: 403, httpVersion: nil, headerFields: nil)!
369-
let rateLimitData = "403 Forbidden (Rate Limit Exceeded)\n".data(using: .utf8)!
377+
let rateLimitResponse = HTTPURLResponse(url: .make(), statusCode: code, httpVersion: nil, headerFields: nil)!
378+
let rateLimitData = message.data(using: .utf8)!
370379
return (data: rateLimitData, response: rateLimitResponse, error: nil)
371380
case 1:
372381
invocationCount += 1
@@ -377,6 +386,7 @@ class APITests: XCTestCase {
377386
return (data: nil, response: nil, error: nil)
378387
}
379388
}
389+
380390
let responseExpectation = expectation(description: "API response")
381391
var receivedResponse: URLResponse?
382392

Core/CoreTests/Common/Extensions/Foundation/URLResponseExtensionsTests.swift

Lines changed: 52 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -75,12 +75,54 @@ class URLResponseExtensionsTests: XCTestCase {
7575
}
7676

7777
func testExceededLimit() {
78-
let httpResponse = HTTPURLResponse(url: URL(string: "https://instructure.com")!, statusCode: 403, httpVersion: "HTTP/2.0", headerFields: [:])!
79-
let data = "403 Forbidden (Rate Limit Exceeded)".data(using: .utf8)
78+
var httpResponse = HTTPURLResponse(url: URL(string: "https://instructure.com")!, statusCode: 403, httpVersion: "HTTP/2.0", headerFields: [:])!
79+
var data = "403 Forbidden (Rate Limit Exceeded)".data(using: .utf8)
80+
XCTAssertTrue(httpResponse.exceededLimit(responseData: data))
8081

82+
httpResponse = HTTPURLResponse(url: URL(string: "https://instructure.com")!, statusCode: 429, httpVersion: "HTTP/2.0", headerFields: [:])!
83+
data = "429 Too Many Requests (Rate Limit Exceeded)".data(using: .utf8)
8184
XCTAssertTrue(httpResponse.exceededLimit(responseData: data))
8285
}
8386

87+
func testRetrialTimeOnRateLimitExceeded() throws {
88+
// Given
89+
let httpResponse = HTTPURLResponse(url: URL(string: "https://instructure.com")!, statusCode: 429, httpVersion: "HTTP/2.0", headerFields: [:])!
90+
let data = "429 Too Many Requests (Rate Limit Exceeded)".data(using: .utf8)
91+
92+
let base = DispatchTime.now()
93+
let limit = base + .seconds(1) + .milliseconds(500)
94+
95+
// When
96+
let retrialTimes = [
97+
try XCTUnwrap(httpResponse.retrialTimeOnRateLimitExceeded(responseData: data)),
98+
try XCTUnwrap(httpResponse.retrialTimeOnRateLimitExceeded(responseData: data)),
99+
try XCTUnwrap(httpResponse.retrialTimeOnRateLimitExceeded(responseData: data)),
100+
try XCTUnwrap(httpResponse.retrialTimeOnRateLimitExceeded(responseData: data))
101+
]
102+
103+
// Then
104+
if case .nanoseconds(let interval) = base.distance(to: limit) {
105+
for time in retrialTimes {
106+
if case .nanoseconds(let nanoseconds) = base.distance(to: time) {
107+
XCTAssertTrue(nanoseconds <= interval)
108+
} else {
109+
XCTFail("Time must be checked against limit")
110+
}
111+
}
112+
} else {
113+
XCTFail("All times must be checked against limit")
114+
}
115+
116+
for i in 0 ..< retrialTimes.count {
117+
let time = retrialTimes[i]
118+
119+
for j in 0 ..< retrialTimes.count where j != i {
120+
let anotherTime = retrialTimes[j]
121+
XCTAssertNotEqual(time, anotherTime)
122+
}
123+
}
124+
}
125+
84126
func testExceededLimitWithoutData() {
85127
let httpResponse = HTTPURLResponse(url: URL(string: "https://instructure.com")!, statusCode: 403, httpVersion: "HTTP/2.0", headerFields: [:])!
86128

@@ -89,15 +131,21 @@ class URLResponseExtensionsTests: XCTestCase {
89131

90132
func testExceededLimitWithMismatchingData() {
91133
let httpResponse = HTTPURLResponse(url: URL(string: "https://instructure.com")!, statusCode: 403, httpVersion: "HTTP/2.0", headerFields: [:])!
92-
let data = "403 Forbidden".data(using: .utf8)
134+
var data = "403 Forbidden".data(using: .utf8)
93135

94136
XCTAssertFalse(httpResponse.exceededLimit(responseData: data))
137+
138+
data = "429 Too Many Requests".data(using: .utf8)
139+
XCTAssertFalse(httpResponse.exceededLimit(responseData: data))
95140
}
96141

97142
func testExceededLimitWithNonHTTPResponse() {
98143
let response = URLResponse(url: URL(string: "https://instructure.com")!, mimeType: nil, expectedContentLength: 0, textEncodingName: nil)
99-
let data = "403 Forbidden (Rate Limit Exceeded)".data(using: .utf8)
100144

145+
var data = "403 Forbidden (Rate Limit Exceeded)".data(using: .utf8)
146+
XCTAssertFalse(response.exceededLimit(responseData: data))
147+
148+
data = "429 Too Many Requests (Rate Limit Exceeded)".data(using: .utf8)
101149
XCTAssertFalse(response.exceededLimit(responseData: data))
102150
}
103151
}

0 commit comments

Comments
 (0)