diff --git a/README.md b/README.md index 03ec9e6..2d37b3a 100644 --- a/README.md +++ b/README.md @@ -1,6 +1,8 @@ # Can I cache this? [![Build Status](https://travis-ci.org/kornelski/http-cache-semantics.svg?branch=master)](https://travis-ci.org/kornelski/http-cache-semantics) -`CachePolicy` tells when responses can be reused from a cache, taking into account [HTTP RFC 7234](http://httpwg.org/specs/rfc7234.html) rules for user agents and shared caches. It's aware of many tricky details such as the `Vary` header, proxy revalidation, and authenticated responses. +`CachePolicy` tells when responses can be reused from a cache, taking into account [HTTP RFC 7234](http://httpwg.org/specs/rfc7234.html) rules for user agents and shared caches. +It also implements [RFC 5861](https://tools.ietf.org/html/rfc5861), implementing `stale-if-error` and `stale-while-revalidate`. +It's aware of many tricky details such as the `Vary` header, proxy revalidation, and authenticated responses. ## Usage @@ -104,6 +106,7 @@ cachedResponse.headers = cachePolicy.responseHeaders(cachedResponse); Returns approximate time in _milliseconds_ until the response becomes stale (i.e. not fresh). After that time (when `timeToLive() <= 0`) the response might not be usable without revalidation. However, there are exceptions, e.g. a client can explicitly allow stale responses, so always check with `satisfiesWithoutRevalidation()`. +`stale-if-error` and `stale-while-revalidate` extend the time to live of the cache, that can still be used if stale. ### `toObject()`/`fromObject(json)` @@ -131,7 +134,7 @@ Use this method to update the cache after receiving a new response from the orig - `policy` — A new `CachePolicy` with HTTP headers updated from `revalidationResponse`. You can always replace the old cached `CachePolicy` with the new one. - `modified` — Boolean indicating whether the response body has changed. - - If `false`, then a valid 304 Not Modified response has been received, and you can reuse the old cached response body. + - If `false`, then a valid 304 Not Modified response has been received, and you can reuse the old cached response body. This is also affected by `stale-if-error`. - If `true`, you should use new response's body (if present), or make another request to the origin server without any conditional headers (i.e. don't use `revalidationHeaders()` this time) to get the new resource. ```js diff --git a/index.js b/index.js index f3ff441..4f6c2f3 100644 --- a/index.js +++ b/index.js @@ -1,6 +1,6 @@ 'use strict'; // rfc7231 6.1 -const statusCodeCacheableByDefault = [ +const statusCodeCacheableByDefault = new Set([ 200, 203, 204, @@ -12,10 +12,10 @@ const statusCodeCacheableByDefault = [ 410, 414, 501, -]; +]); // This implementation does not understand partial responses (206) -const understoodStatuses = [ +const understoodStatuses = new Set([ 200, 203, 204, @@ -30,7 +30,14 @@ const understoodStatuses = [ 410, 414, 501, -]; +]); + +const errorStatusCodes = new Set([ + 500, + 502, + 503, + 504, +]); const hopByHopHeaders = { date: true, // included, because we add Age update Date @@ -43,6 +50,7 @@ const hopByHopHeaders = { 'transfer-encoding': true, upgrade: true, }; + const excludedFromRevalidationUpdate = { // Since the old body is reused, it doesn't make sense to change properties of the body 'content-length': true, @@ -51,6 +59,20 @@ const excludedFromRevalidationUpdate = { 'content-range': true, }; +function toNumberOrZero(s) { + const n = parseInt(s, 10); + return isFinite(n) ? n : 0; +} + +// RFC 5861 +function isErrorResponse(response) { + // consider undefined response as faulty + if(!response) { + return true + } + return errorStatusCodes.has(response.status); +} + function parseCacheControl(header) { const cc = {}; if (!header) return cc; @@ -162,7 +184,7 @@ module.exports = class CachePolicy { 'HEAD' === this._method || ('POST' === this._method && this._hasExplicitExpiration())) && // the response status code is understood by the cache, and - understoodStatuses.indexOf(this._status) !== -1 && + understoodStatuses.has(this._status) && // the "no-store" cache directive does not appear in request or response header fields, and !this._rescc['no-store'] && // the "private" response directive does not appear in the response, if the cache is shared, and @@ -181,7 +203,7 @@ module.exports = class CachePolicy { (this._isShared && this._rescc['s-maxage']) || this._rescc.public || // has a status code that is defined as cacheable by default - statusCodeCacheableByDefault.indexOf(this._status) !== -1) + statusCodeCacheableByDefault.has(this._status)) ); } @@ -353,8 +375,7 @@ module.exports = class CachePolicy { } _ageValue() { - const ageValue = parseInt(this._resHeaders.age); - return isFinite(ageValue) ? ageValue : 0; + return toNumberOrZero(this._resHeaders.age); } /** @@ -390,13 +411,13 @@ module.exports = class CachePolicy { } // if a response includes the s-maxage directive, a shared cache recipient MUST ignore the Expires field. if (this._rescc['s-maxage']) { - return parseInt(this._rescc['s-maxage'], 10); + return toNumberOrZero(this._rescc['s-maxage']); } } // If a response includes a Cache-Control field with the max-age directive, a recipient MUST ignore the Expires field. if (this._rescc['max-age']) { - return parseInt(this._rescc['max-age'], 10); + return toNumberOrZero(this._rescc['max-age']); } const defaultMinTtl = this._rescc.immutable ? this._immutableMinTtl : 0; @@ -425,13 +446,24 @@ module.exports = class CachePolicy { } timeToLive() { - return Math.max(0, this.maxAge() - this.age()) * 1000; + const age = this.maxAge() - this.age(); + const staleIfErrorAge = age + toNumberOrZero(this._rescc['stale-if-error']); + const staleWhileRevalidateAge = age + toNumberOrZero(this._rescc['stale-while-revalidate']); + return Math.max(0, age, staleIfErrorAge, staleWhileRevalidateAge) * 1000; } stale() { return this.maxAge() <= this.age(); } + _useStaleIfError() { + return this.maxAge() + toNumberOrZero(this._rescc['stale-if-error']) > this.age(); + } + + useStaleWhileRevalidate() { + return this.maxAge() + toNumberOrZero(this._rescc['stale-while-revalidate']) > this.age(); + } + static fromObject(obj) { return new this(undefined, undefined, { _fromObject: obj }); } @@ -549,6 +581,13 @@ module.exports = class CachePolicy { */ revalidatedPolicy(request, response) { this._assertRequestHasHeaders(request); + if(this._useStaleIfError() && isErrorResponse(response)) { // I consider the revalidation request unsuccessful + return { + modified: false, + matches: false, + policy: this, + }; + } if (!response || !response.headers) { throw Error('Response headers missing'); } diff --git a/test/okhttptest.js b/test/okhttptest.js index 3ffcdb7..7b4b215 100644 --- a/test/okhttptest.js +++ b/test/okhttptest.js @@ -160,6 +160,56 @@ describe('okhttp tests', function() { assert(!cache.stale()); }); + it('maxAge timetolive', function() { + const cache = new CachePolicy( + { headers: {} }, + { + headers: { + date: formatDate(120, 1), + 'cache-control': 'max-age=60', + }, + }, + { shared: false } + ); + const now = Date.now(); + cache.now = () => now + + assert(!cache.stale()); + assert.equal(cache.timeToLive(), 60000); + }); + + it('stale-if-error timetolive', function() { + const cache = new CachePolicy( + { headers: {} }, + { + headers: { + date: formatDate(120, 1), + 'cache-control': 'max-age=60, stale-if-error=200', + }, + }, + { shared: false } + ); + + assert(!cache.stale()); + assert.equal(cache.timeToLive(), 260000); + }); + + it('stale-while-revalidate timetolive', function() { + const cache = new CachePolicy( + { headers: {} }, + { + headers: { + date: formatDate(120, 1), + 'cache-control': 'max-age=60, stale-while-revalidate=200', + }, + }, + { shared: false } + ); + + assert(!cache.stale()); + assert.equal(cache.timeToLive(), 260000); + }); + it('max age preferred over lower shared max age', function() { const cache = new CachePolicy( { headers: {} }, diff --git a/test/updatetest.js b/test/updatetest.js index 53d33b4..f1a9c16 100644 --- a/test/updatetest.js +++ b/test/updatetest.js @@ -259,4 +259,28 @@ describe('Update revalidated', function() { 'bad lastmod' ); }); + + it("staleIfError revalidate, no response", function() { + const cacheableStaleResponse = { headers: { 'cache-control': 'max-age=200, stale-if-error=300' } }; + const cache = new CachePolicy(simpleRequest, cacheableStaleResponse); + + const { policy, modified } = cache.revalidatedPolicy( + simpleRequest, + null + ); + assert(policy === cache); + assert(modified === false); + }); + + it("staleIfError revalidate, server error", function() { + const cacheableStaleResponse = { headers: { 'cache-control': 'max-age=200, stale-if-error=300' } }; + const cache = new CachePolicy(simpleRequest, cacheableStaleResponse); + + const { policy, modified } = cache.revalidatedPolicy( + simpleRequest, + { status: 500 } + ); + assert(policy === cache); + assert(modified === false); + }); });