-
Notifications
You must be signed in to change notification settings - Fork 514
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
Respect Cache-Control headers #462
Conversation
090834c
to
4549931
Compare
4549931
to
a5e7f7b
Compare
Update CHANGELOG
a5e7f7b
to
f047d3c
Compare
Correct selector [NSDate stopMockingDate]; Update .podspec
f645a38
to
4534575
Compare
…into cache-control
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
This is a really great improvement. Thanks for working on it, @wiseoldduck ! It definitely adds value to PINRemoteImage (in my opinion), as most sophisticated backends do set these headers.
@garrettmoon take a look when you have time — since we'll be merging this internally by Wednesday EOD, it would be ideal to have the final tweaks done. We can re-sync again later if it takes longer to review, though. Thanks!
@@ -116,6 +116,7 @@ @interface PINRemoteImageManager () <PINURLSessionManagerDelegate> | |||
@property (nonatomic, copy) id<PINRequestRetryStrategy> (^retryStrategyCreationBlock)(void); | |||
@property (nonatomic, copy) PINRemoteImageManagerRequestConfigurationHandler requestConfigurationHandler; | |||
@property (nonatomic, strong) NSMutableDictionary <NSString *, NSString *> *httpHeaderFields; | |||
@property(nonatomic, assign) BOOL isTtlCache; |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
To match the codebase, space before ( -- which is not done in some other codebases.
I would suggest a different name, maybe respectCacheControlHeaders, trackImageExpiry, enforceCacheAgeLimits - since this class is not by itself a cache, it's more like a class managing a cache than "isTtlCache".
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Spacing added.
Since the underlying cache is not necessarily PINCache, this var is storing (caching) whether the cache implements the optional - (void)setObjectOnDisk:(id)object forKey:(NSString *)key withAgeLimit:(NSTimeInterval)ageLimit
method, that is whether it is a Ttl cache or not. So I think the name is accurate, (although maybe could be cacheIsTtlCache
)...
@@ -784,13 +786,62 @@ - (void)downloadImageWithURL:(NSURL *)url | |||
NSError *remoteImageError = error; | |||
PINImage *image = nil; | |||
id alternativeRepresentation = nil; | |||
__block NSNumber *maxAge = nil; |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Any reason to box this as an NSNumber? If not, NSInteger should result shorter / faster code, as long as cases like NSNotFound or other sentinel values are handled.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Yes, we need to distinguish between "set to 0" and "not set" (nil)
if (remoteImageError == nil) { | ||
//stores the object in the caches | ||
[self materializeAndCacheObject:data cacheInDisk:data additionalCost:0 url:url key:key options:options outImage:&image outAltRep:&alternativeRepresentation]; | ||
if (_isTtlCache && !(options & PINRemoteImageManagerDownloadOptionsIgnoreCacheControlHeaders)) { |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Believe it or not, this bitwise-AND should not be inverted / handled as a BOOL as it can fail on specifically 32-bit ARM! Our team had some nasty issues with this last Fall.
If other parts of the file have this bug as well, we should introduce a macro similar to ASInterfaceStateIncludes() or ASCellLayoutModeIncludes() to resolve.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
lolwut Citation Needed
No I believe you of course but would love to learn more! The details would inform me how to rewrite this, is putting the x & y result into a BOOL first sufficient?
if ([response isKindOfClass:[NSHTTPURLResponse class]]) { | ||
NSHTTPURLResponse *httpResponse = (NSHTTPURLResponse *) response; | ||
|
||
[[[httpResponse allHeaderFields][@"Cache-Control"] componentsSeparatedByString:@","] enumerateObjectsUsingBlock:^(id obj, NSUInteger idx, BOOL *stop) { |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Since these are complex lines of code, and we access this twice, local variable?
NSArray *allHeaderFields = ...
|
||
if ([trimmed isEqualToString:@"no-store"] || [trimmed isEqualToString:@"must-revalidate"] || [trimmed isEqualToString:@"no-cache"]) { | ||
maxAge = @(0); | ||
*stop = YES; |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
I'd suggest we just use for( ... in ...), as it is both faster - NSFastEnumeration and avoids the internal @autorelease pool that is otherwise created around every block invocation - and it also is a bit nicer looking (less block boilerplate with the arguments, easier to write "break";, etc)
This could be, for (NSString *field in [allHeaderFields[@"Cache-Control"] componentsSeparatedByString:@",") { }
if (!maxAge && (expires = [httpResponse allHeaderFields][@"Expires"])) { | ||
// https://developer.apple.com/library/archive/qa/qa1480/_index.html | ||
|
||
static NSDateFormatter *sRFC7231PreferredDateFormatter; |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Assign nil. I'm not sure we should rely on static variables being zero-initialized.
Also, we should move this out to an accessor method. Ideally static variables are declared outside methods (even if just above the method). In this case a relatively complex method would be shortened if we moved the cached formatter to an accessor like +preferredCacheControlDateFormatter.
} | ||
} | ||
} | ||
//stores the object in the caches |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Space & caps after //
@@ -1356,7 +1419,15 @@ - (BOOL)materializeAndCacheObject:(id)object | |||
} | |||
|
|||
if (diskData) { | |||
[self.cache setObjectOnDisk:diskData forKey:key]; | |||
// maxAge of 0 is not stored at all |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Could you clarify this comment? Does this mean "maxAge of 0 means that images should not be stored at all" ?
Is there a value for maxAge that means infinite?
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Done (yes that's what it meant).
There is no header that can be sent to indicate "infinite", which is why you will see so many HTTP responses with values like "one year in the future". However not setting a value at all, which in our case is represented by maxAge == nil
, does effectively mean that. (In our case it will use the cache-global maxAge)
/** | ||
* Do not honor HTTP Cache-Control headers | ||
* Currently PINRemoteImage will by default respect 'no-store', 'no-cache', 'max-age', 'Expires', and 'must-revalidate'. Set this flag to ignore those headers. | ||
* TODO: Currently PINRemoteImage will re-download images that only must be re-validated. In the future this could be improved with revalidation behavior that stores ETag or Last-Modified values and only |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Minor / optional: "By default, PINRemoteImage will respect...".
Could also drop "Currently" in the next line, as presumably that will be updated if it becomes supported in the future. The next line also seems like it could use a line break to stay within a ~120 (or a bit more) column width — which isn't a hard rule for these frameworks, but up to ~150 works pretty well for Obj-C.
Great comment overall, thanks for including the IETF and Mozilla links!
Tests/PINRemoteImageTests.m
Outdated
@@ -943,6 +944,51 @@ - (void)testQOS | |||
XCTAssert(dispatch_semaphore_wait(semaphore, [self timeout]) == 0, @"Semaphore timed out."); | |||
} | |||
|
|||
- (void)testMaxAge |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Thanks for adding tests! Maybe call this testCacheControlMaxAgeExpiry, or just testCacheControl? Since the maxAge comes from cache control, the cache-control seems like the root nature / focus of the test.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
"testCacheControlSupport"?
Whitespace
Reword `PINRemoteImageManagerDownloadOptionsIgnoreCacheControlHeaders` comment create static accessor for `RFC7231PreferredDateFormatter` Replace `enumerateObjectsUsingBlock` with `for (NSString *component in`... Rename test
Added a comment
08b54d0
to
4dca22a
Compare
{ | ||
[self.diskCache setObject:object forKey:key withAgeLimit:ageLimit]; | ||
} | ||
|
||
-(void)setObjectOnDisk:(id)object forKey:(NSString *)key | ||
{ | ||
[self.diskCache setObject:object forKey:key]; |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Super minor: maybe have this call [self setObjectOnDisk:object forKey:key withAgeLimit:0];
?
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
OK
if ([key hasPrefix:PINRemoteImageCacheKeyResumePrefix]) { | ||
return [NSKeyedUnarchiver unarchiveObjectWithData:data]; | ||
} | ||
return data; | ||
}]; | ||
} keyEncoder:nil keyDecoder:nil ttlCache:YES]; |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Dang, so setting this to YES will cause a significant performance hit (PINDiskCache can't asynchronously startup), even if you don't end up using the feature. I'm wondering if we should actually have a flag on init which allows you to disable (or perhaps it should actually be off by default) support of TTL? We could possibly log warnings if we encounter a cache control header and this is NO.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Nooooooooooooooooooooooooo
@@ -116,6 +116,7 @@ @interface PINRemoteImageManager () <PINURLSessionManagerDelegate> | |||
@property (nonatomic, copy) id<PINRequestRetryStrategy> (^retryStrategyCreationBlock)(void); | |||
@property (nonatomic, copy) PINRemoteImageManagerRequestConfigurationHandler requestConfigurationHandler; | |||
@property (nonatomic, strong) NSMutableDictionary <NSString *, NSString *> *httpHeaderFields; | |||
@property (nonatomic, assign) BOOL isTtlCache; |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
This should probably be readonly since it's accessed outside the lock and only set on init?
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Yes
if (remoteImageError == nil) { | ||
//stores the object in the caches | ||
[self materializeAndCacheObject:data cacheInDisk:data additionalCost:0 url:url key:key options:options outImage:&image outAltRep:&alternativeRepresentation]; | ||
BOOL ignoreHeaders = (options & PINRemoteImageManagerDownloadOptionsIgnoreCacheControlHeaders) != 0; |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
nit: name this ignoreCacheHeaders
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Would it be more readable to make a category on the response which returns maxAge? RFC7231PreferredDateFormatter
could live there too?
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
🤔
// and while I cannot find any explicit instruction of how to behave | ||
// with a malformed "max-age" header, it seems like a reasonable approach. | ||
maxAge = @([split[1] integerValue]); | ||
} |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Since invalid means do not cache, should there be an else here setting maxAge to 0?
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
hmm well the else
would be if [split count] == 0
, in which case it was not set at all (and nil
is correct), or [split count] > 2
, in which case you are correct but it's a pretty weird case with a header like Cache-Control: maxage=maxage=444
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
I added a condition for the pretty weird case
// maxAge of 0 means that images should not be stored at all. | ||
// There is no HTTP header that can be sent to indicate "infinite". However not setting a value at all, which in | ||
// our case is represented by maxAge == nil, effectively means that. | ||
if (!(maxAge && [maxAge integerValue] == 0)) { |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Is this more readable (honest question)? (I think it's still correct)
if (maxAge && [maxAge integerValue] != 0 && _isTtlCache) {
[self.cache setObjectOnDisk:diskData forKey:key withAgeLimit:[maxAge integerValue]];
} else if (!maxAge) {
// unset (nil) maxAge, or a cache that is not _isTtlCache behave as before (will use cache global ageLimit)
[self.cache setObjectOnDisk:diskData forKey:key];
}
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
I dunno? Yes it looks still correct, and the first case is more readable, but it seems harder to reason through the other two possibilities...
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
No I think it is also incorrect in that if maxAge
is set but _isTtlCache
is false, the proposal fails to call the old method (does not cache at all)
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
I think this may suggest we should have a simple local variable with a more descriptive name, like:
BOOL neverStoreImage = (maxAge && [maxAge integerValue] == 0);
if (neverStoreImage == NO) {
}
There's probably a better variable name or another way to do it that is more readable still, like you could check:
BOOL storeImageIndefinitely = !maxAge;
if (storeImageIndefinitely || [maxAge integerValue] > 0) {
}
This second one might be the most readable?
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Excellent suggestion! See the newest edition 👍
@@ -943,6 +944,51 @@ - (void)testQOS | |||
XCTAssert(dispatch_semaphore_wait(semaphore, [self timeout]) == 0, @"Semaphore timed out."); | |||
} | |||
|
|||
- (void)testCacheControlSupport |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
I think these would be (slightly) better tests if they actually checked the results of a second download instead of just seeing if the objects are still in the cache. Or perhaps both? PINRemoteImage will tell you how it got an object in result.resultType.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
OK.
Well it would be second & third downloads, to confirm that it got it from the cache and then did not.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
This change will make more obvious another limitation - The PINCache TTL implementation only applies to the disk cache. If you kept an app alive long enough, without memory warnings, you could still get results from the memory cache that the header would have suggested you shouldn't.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Done
isTtlCache is now readonly property setObjectOnDisk:forKey: uses setObjectOnDisk:forKey:withAgeLimit: weird case of multiple maxage=maxage=maxages covered
…rent startup impl of ttlCache) Provide + (nonnull id<PINRemoteImageCaching>)defaultImageTtlCache; to get a TTL cache if you want it Other code review updates: add local vars for readability in -[PINRemoteImageManager materializeAndCacheObject...
Thanks so much for your contribution @wiseoldduck ! |
The PINCache upgrade bundled into this has also been submitted as a standalone PR