TwitterNetworkLayerTests/TNLRequestRetryPolicyTest.m (364 lines of code) (raw):
//
// TNLRequestRetryPolicyTest.m
// TwitterNetworkLayerTests
//
// Created on 8/24/18.
// Copyright © 2020 Twitter. All rights reserved.
//
#import "TNL_Project.h"
#import "TNLXContentEncoding.h"
@import TwitterNetworkLayer;
@import XCTest;
#define kDELAY_TIME_INTERVAL (0.2)
@interface BrokenContentEncoder : NSObject <TNLContentEncoder>
@end
@interface TestRetryPolicy : NSObject <TNLConfiguringRetryPolicyProvider>
@property (nonatomic) NSTimeInterval retryDelay;
@property (nonatomic) NSUInteger maxAttempts;
@end
@interface TimerOperation : NSOperation
- (instancetype)initWithTimeout:(NSTimeInterval)timeout;
@end
@interface TNLRequestRetryPolicyTest : XCTestCase <TNLRequestDelegate>
@end
@implementation TNLRequestRetryPolicyTest
{
TNLResponse *_response;
NSTimeInterval _dependencyDuration;
NSTimeInterval _dependencyDelay;
NSTimeInterval _retryDelay;
dispatch_block_t _onNextRetry;
}
- (void)tearDown
{
[super tearDown];
_response = nil;
_dependencyDuration = 0;
_dependencyDelay = 0;
_retryDelay = 0;
_onNextRetry = nil;
}
- (void)testRetryPolicy
{
for (_retryDelay = 0.0; _retryDelay < (kDELAY_TIME_INTERVAL * 1.5); _retryDelay += kDELAY_TIME_INTERVAL) {
for (_dependencyDuration = 0.0; _dependencyDuration < (kDELAY_TIME_INTERVAL * 1.5); _dependencyDuration += kDELAY_TIME_INTERVAL) {
for (_dependencyDelay = 0.0; _dependencyDelay < (kDELAY_TIME_INTERVAL * 1.5); _dependencyDelay += kDELAY_TIME_INTERVAL) {
NSString *cmdStr = NSStringFromSelector(_cmd);
NSLog(@"%@: retry-delay=%fs, dependency-duration=%fs, dependency-delay=%fs", cmdStr, _retryDelay, _dependencyDuration, _dependencyDelay);
const CFAbsoluteTime start = CFAbsoluteTimeGetCurrent();
[self _runRetryPolicyTest];
const NSTimeInterval duration = CFAbsoluteTimeGetCurrent() - start;
NSLog(@"%@: run=%fs", cmdStr, duration);
_response = nil;
}
}
}
}
- (void)_runRetryPolicyTest
{
TestRetryPolicy *retryPolicy = [[TestRetryPolicy alloc] initWithConfiguration:[TNLRequestRetryPolicyConfiguration standardConfiguration]];
retryPolicy.retryDelay = _retryDelay;
NSURL *URL = [NSURL URLWithString:@"https://fake.domain.com/post/results"];
TNLMutableRequestConfiguration *config = [TNLMutableRequestConfiguration defaultConfiguration];
config.contentEncoder = [[BrokenContentEncoder alloc] init];
config.retryPolicyProvider = retryPolicy;
config.protocolOptions = TNLRequestProtocolOptionPseudo;
NSHTTPURLResponse *URLResponse = [[NSHTTPURLResponse alloc] initWithURL:URL
statusCode:200
HTTPVersion:@"1.1"
headerFields:nil];
NSData *URLResponseBody = [@"{\"success\":true}" dataUsingEncoding:NSUTF8StringEncoding];
[TNLPseudoURLProtocol registerURLResponse:URLResponse
body:URLResponseBody
withEndpoint:URL];
tnl_defer(^{
[TNLPseudoURLProtocol unregisterEndpoint:URL];
});
NSDictionary *results = @{
@"player1" : @{
@"name" : @"Montoya",
@"score" : @5,
},
@"player2" : @{
@"name" : @"Roberts",
@"score" : @6,
}
};
NSData *requestBody = [NSJSONSerialization dataWithJSONObject:results
options:NSJSONWritingPrettyPrinted
error:NULL];
TNLMutableHTTPRequest *request = [TNLMutableHTTPRequest POSTRequestWithURL:URL
HTTPHeaderFields:nil
HTTPBody:requestBody];
TNLRequestOperation *op = [TNLRequestOperation operationWithRequest:request
configuration:config
delegate:self];
[[TNLRequestOperationQueue defaultOperationQueue] enqueueRequestOperation:op];
[op waitUntilFinishedWithoutBlockingRunLoop];
XCTAssertNotNil(_response);
XCTAssertEqual((int)_response.info.statusCode, 200);
XCTAssertNil(_response.operationError);
XCTAssertEqual((int)_response.metrics.attemptCount, 2);
XCTAssertGreaterThan(_response.metrics.totalDuration, MAX(_retryDelay, _dependencyDuration + _dependencyDelay));
#if 0 // disable this since the CI machines can have really slow performance -- feel free to enable when running locally
XCTAssertLessThan(_response.metrics.totalDuration, MAX(_retryDelay, _dependencyDuration + _dependencyDelay) + kDELAY_TIME_INTERVAL);
#endif
do {
TNLAttemptMetrics *firstAttemptMetrics = _response.metrics.attemptMetrics.firstObject;
XCTAssertNotNil(firstAttemptMetrics);
XCTAssertNotNil(firstAttemptMetrics.operationError);
XCTAssertEqualObjects(firstAttemptMetrics.operationError.domain, TNLErrorDomain);
XCTAssertEqual(firstAttemptMetrics.operationError.code, TNLErrorCodeRequestOperationRequestContentEncodingFailed);
} while (0);
do {
NSData *requestBodyBase64 = [requestBody base64EncodedDataWithOptions:(NSDataBase64Encoding64CharacterLineLength |
NSDataBase64EncodingEndLineWithCarriageReturn |
NSDataBase64EncodingEndLineWithLineFeed)];
TNLAttemptMetrics *lastAttemptMetrics = _response.metrics.attemptMetrics.lastObject;
XCTAssertNotNil(lastAttemptMetrics);
XCTAssertNil(lastAttemptMetrics.operationError);
XCTAssertEqualObjects([lastAttemptMetrics.URLRequest valueForHTTPHeaderField:@"Content-Encoding"], @"base64");
XCTAssertEqualObjects(lastAttemptMetrics.URLRequest.HTTPBody, requestBodyBase64);
} while (0);
}
#pragma mark Test specific scenarios
- (void)testSuccessfulRequestDoesNotRetry
{
NSURL *URL = [NSURL URLWithString:@"https://fake.domain.com/"];
TNLMutableHTTPRequest *request = [TNLMutableHTTPRequest GETRequestWithURL:URL HTTPHeaderFields:nil];
NSHTTPURLResponse *response = [[NSHTTPURLResponse alloc] initWithURL:URL statusCode:200 HTTPVersion:@"HTTP/1.1" headerFields:nil];
[TNLPseudoURLProtocol registerURLResponse:response body:nil withEndpoint:URL];
tnl_defer(^{
[TNLPseudoURLProtocol unregisterEndpoint:URL];
});
TNLRequestRetryPolicyConfiguration *retryConfig = [[TNLRequestRetryPolicyConfiguration alloc] initWithAllMethodsRetriableAndRetriableStatusCodes:@[@200] URLErrorCodes:nil POSIXErrorCodes:nil];
TestRetryPolicy *retryPolicy = [[TestRetryPolicy alloc] initWithConfiguration:retryConfig];
TNLMutableRequestConfiguration *config = [TNLMutableRequestConfiguration defaultConfiguration];
config.retryPolicyProvider = retryPolicy;
config.protocolOptions = TNLRequestProtocolOptionPseudo;
TNLRequestOperation *operation = [TNLRequestOperation operationWithRequest:request configuration:config delegate:self];
[[TNLRequestOperationQueue defaultOperationQueue] enqueueRequestOperation:operation];
[operation waitUntilFinishedWithoutBlockingRunLoop];
XCTAssertEqual(operation.attemptCount, 1);
XCTAssertEqual(operation.retryCount, 0);
XCTAssertEqual(operation.response.info.statusCode, 200);
}
- (void)testRetryWhenSubsequentRequestSucceeds
{
NSURL *URL = [NSURL URLWithString:@"https://fake.domain.com/"];
TNLMutableHTTPRequest *request = [TNLMutableHTTPRequest GETRequestWithURL:URL HTTPHeaderFields:nil];
NSHTTPURLResponse *response = [[NSHTTPURLResponse alloc] initWithURL:URL statusCode:503 HTTPVersion:@"HTTP/1.1" headerFields:nil];
NSHTTPURLResponse *successResponse = [[NSHTTPURLResponse alloc] initWithURL:URL statusCode:200 HTTPVersion:@"HTTP/1.1" headerFields:nil];
[TNLPseudoURLProtocol registerURLResponse:response body:nil withEndpoint:URL];
_onNextRetry = ^{
[TNLPseudoURLProtocol registerURLResponse:successResponse body:nil withEndpoint:URL];
};
tnl_defer(^{
[TNLPseudoURLProtocol unregisterEndpoint:URL];
});
TestRetryPolicy *retryPolicy = [[TestRetryPolicy alloc] initWithConfiguration:[TNLRequestRetryPolicyConfiguration standardConfiguration]];
TNLMutableRequestConfiguration *config = [TNLMutableRequestConfiguration defaultConfiguration];
config.retryPolicyProvider = retryPolicy;
config.protocolOptions = TNLRequestProtocolOptionPseudo;
TNLRequestOperation *operation = [TNLRequestOperation operationWithRequest:request configuration:config delegate:self];
[[TNLRequestOperationQueue defaultOperationQueue] enqueueRequestOperation:operation];
[operation waitUntilFinishedWithoutBlockingRunLoop];
XCTAssertEqual(operation.attemptCount, 2);
XCTAssertEqual(operation.retryCount, 1);
XCTAssertEqual(operation.response.info.statusCode, 200);
}
- (void)testOperationTimeoutCancelsRetry
{
// The actual timeout here does not matter, as long as it is enough for
// the initial attempt to complete. If the retryDelay is longer than the
// operationTimeout the retry should not be attempted.
static const NSTimeInterval operationTimeout = 30.0;
static const NSTimeInterval retryDelay = 40.0;
NSURL *URL = [NSURL URLWithString:@"https://fake.domain.com/"];
TNLMutableHTTPRequest *request = [TNLMutableHTTPRequest GETRequestWithURL:URL HTTPHeaderFields:nil];
NSHTTPURLResponse *response = [[NSHTTPURLResponse alloc] initWithURL:URL statusCode:503 HTTPVersion:@"HTTP/1.1" headerFields:nil];
[TNLPseudoURLProtocol registerURLResponse:response body:nil withEndpoint:URL];
tnl_defer(^{
[TNLPseudoURLProtocol unregisterEndpoint:URL];
});
TestRetryPolicy *retryPolicy = [[TestRetryPolicy alloc] initWithConfiguration:[TNLRequestRetryPolicyConfiguration standardConfiguration]];
retryPolicy.retryDelay = retryDelay;
TNLMutableRequestConfiguration *config = [TNLMutableRequestConfiguration defaultConfiguration];
config.retryPolicyProvider = retryPolicy;
config.protocolOptions = TNLRequestProtocolOptionPseudo;
config.operationTimeout = operationTimeout;
config.attemptTimeout = operationTimeout;
config.idleTimeout = operationTimeout;
TNLRequestOperation *operation = [TNLRequestOperation operationWithRequest:request configuration:config delegate:self];
[[TNLRequestOperationQueue defaultOperationQueue] enqueueRequestOperation:operation];
[operation waitUntilFinishedWithoutBlockingRunLoop];
XCTAssertEqual(operation.attemptCount, 1);
XCTAssertEqual(operation.retryCount, 0);
XCTAssertEqual(operation.response.info.statusCode, 503);
}
- (void)testRetryWithNoOperationTimeout
{
// The operation should retry if the `operationTimeout` is unlimited (less than 0.1 per docs).
// There used to be a bug where `operationTimeout` less than 0.1 would NEVER retry.
// This test prevents regression from fixing this particular bug.
NSURL *URL = [NSURL URLWithString:@"https://fake.domain.com/"];
TNLMutableHTTPRequest *request = [TNLMutableHTTPRequest GETRequestWithURL:URL HTTPHeaderFields:nil];
NSHTTPURLResponse *response = [[NSHTTPURLResponse alloc] initWithURL:URL statusCode:503 HTTPVersion:@"HTTP/1.1" headerFields:nil];
[TNLPseudoURLProtocol registerURLResponse:response body:nil withEndpoint:URL];
tnl_defer(^{
[TNLPseudoURLProtocol unregisterEndpoint:URL];
});
TestRetryPolicy *retryPolicy = [[TestRetryPolicy alloc] initWithConfiguration:[TNLRequestRetryPolicyConfiguration standardConfiguration]];
retryPolicy.maxAttempts = 1;
TNLMutableRequestConfiguration *config = [TNLMutableRequestConfiguration defaultConfiguration];
config.retryPolicyProvider = retryPolicy;
config.protocolOptions = TNLRequestProtocolOptionPseudo;
config.operationTimeout = -1;
TNLRequestOperation *operation = [TNLRequestOperation operationWithRequest:request configuration:config delegate:self];
[[TNLRequestOperationQueue defaultOperationQueue] enqueueRequestOperation:operation];
[operation waitUntilFinishedWithoutBlockingRunLoop];
XCTAssertEqual(operation.attemptCount, 2);
XCTAssertEqual(operation.retryCount, 1);
XCTAssertEqual(operation.response.info.statusCode, 503);
}
#pragma mark - TNLRequestDelegate
- (void)tnl_requestOperation:(TNLRequestOperation *)op
willStartRetryFromResponse:(TNLResponse *)responseBeforeRetry
policyProvider:(id<TNLRequestRetryPolicyProvider>)policyProvider
afterDelay:(NSTimeInterval)delay
{
if (_onNextRetry) {
_onNextRetry();
_onNextRetry = nil;
}
if (_dependencyDelay < 0.1 && _dependencyDuration < 0.1) {
return;
}
NSOperation *timeoutOp = [[TimerOperation alloc] initWithTimeout:_dependencyDuration];
[op addDependency:timeoutOp];
NSOperation *delayOp = [[TimerOperation alloc] initWithTimeout:_dependencyDelay];
[timeoutOp addDependency:delayOp];
NSOperationQueue *q = [NSOperationQueue currentQueue] ?: [NSOperationQueue mainQueue];
[q addOperation:timeoutOp];
[q addOperation:delayOp];
}
- (void)tnl_requestOperation:(TNLRequestOperation *)op
didCompleteWithResponse:(TNLResponse *)response
{
_response = response;
}
@end
@implementation BrokenContentEncoder
- (NSString *)tnl_contentEncodingType
{
return @"gzip";
}
- (nullable NSData *)tnl_encodeHTTPBody:(NSData *)bodyData
error:(out NSError * __nullable * __nullable)error
{
if (error) {
*error = [NSError errorWithDomain:@"broken.encoder"
code:-2
userInfo:nil];
}
return nil;
}
@end
@implementation TestRetryPolicy
{
TNLRequestRetryPolicyConfiguration *_config;
}
- (instancetype)initWithConfiguration:(nullable TNLRequestRetryPolicyConfiguration *)config
{
if (self = [super init]) {
_config = [config copy];
}
return self;
}
- (nullable TNLRequestRetryPolicyConfiguration *)configuration
{
return _config;
}
- (BOOL)tnl_shouldRetryRequestOperation:(TNLRequestOperation *)op
withResponse:(TNLResponse *)response
{
if (_maxAttempts && op.attemptCount > _maxAttempts) {
return NO;
}
if ([response.operationError.domain isEqualToString:TNLErrorDomain] && response.operationError.code == TNLErrorCodeRequestOperationRequestContentEncodingFailed) {
if (response.metrics.attemptCount > 3) {
return NO;
}
return YES;
}
return [_config requestCanBeRetriedForResponse:response];
}
- (NSTimeInterval)tnl_delayBeforeRetryForRequestOperation:(TNLRequestOperation *)op
withResponse:(TNLResponse *)response
{
return _retryDelay;
}
- (nullable TNLRequestConfiguration *)tnl_configurationOfRetryForRequestOperation:(TNLRequestOperation *)op
withResponse:(TNLResponse *)response
priorConfiguration:(TNLRequestConfiguration *)priorConfig
{
if (![response.operationError.domain isEqualToString:TNLErrorDomain]) {
return nil;
}
if (response.operationError.code != TNLErrorCodeRequestOperationRequestContentEncodingFailed) {
return nil;
}
TNLMutableRequestConfiguration *config = [priorConfig mutableCopy];
if ([priorConfig.contentEncoder.tnl_contentEncodingType isEqualToString:@"base64"]) {
config.contentEncoder = nil;
} else {
config.contentEncoder = [TNLXContentEncoding Base64ContentEncoder];
}
return config;
}
- (nullable NSString *)tnl_retryPolicyIdentifier
{
return @"test.tnl.retry.policy.1";
}
@end
@implementation TimerOperation
{
NSTimeInterval _timeout;
BOOL _finished;
BOOL _executing;
}
- (instancetype)initWithTimeout:(NSTimeInterval)timeout
{
if (self = [super init]) {
_timeout = timeout;
}
return self;
}
- (void)start
{
if ([self isCancelled]) {
[self willChangeValueForKey:@"isFinished"];
_finished = YES;
[self didChangeValueForKey:@"isFinished"];
return;
}
[self willChangeValueForKey:@"isExecuting"];
_executing = YES;
[self didChangeValueForKey:@"isExecuting"];
dispatch_after(dispatch_time(DISPATCH_TIME_NOW, (int64_t)(_timeout * NSEC_PER_SEC)), dispatch_get_global_queue(DISPATCH_QUEUE_PRIORITY_DEFAULT, 0), ^{
[self completeOperation];
});
}
- (BOOL)isExecuting
{
return _executing;
}
- (BOOL)isFinished
{
return _finished;
}
- (BOOL)isConcurrent
{
return YES;
}
- (BOOL)isAsynchronous
{
return YES;
}
- (void)completeOperation
{
[self willChangeValueForKey:@"isFinished"];
[self willChangeValueForKey:@"isExecuting"];
_executing = NO;
_finished = YES;
[self didChangeValueForKey:@"isExecuting"];
[self didChangeValueForKey:@"isFinished"];
}
@end