Skip to content

Commit

Permalink
rfc 5861 (stale-if-error, stale-while-revalidate)
Browse files Browse the repository at this point in the history
  • Loading branch information
sithmel authored Mar 1, 2020
1 parent 2c2fac2 commit 1b35980
Show file tree
Hide file tree
Showing 4 changed files with 129 additions and 13 deletions.
7 changes: 5 additions & 2 deletions README.md
Original file line number Diff line number Diff line change
@@ -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

Expand Down Expand Up @@ -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)`

Expand Down Expand Up @@ -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
Expand Down
61 changes: 50 additions & 11 deletions index.js
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
'use strict';
// rfc7231 6.1
const statusCodeCacheableByDefault = [
const statusCodeCacheableByDefault = new Set([
200,
203,
204,
Expand All @@ -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,
Expand All @@ -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
Expand All @@ -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,
Expand All @@ -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;
Expand Down Expand Up @@ -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
Expand All @@ -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))
);
}

Expand Down Expand Up @@ -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);
}

/**
Expand Down Expand Up @@ -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;
Expand Down Expand Up @@ -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 });
}
Expand Down Expand Up @@ -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');
}
Expand Down
50 changes: 50 additions & 0 deletions test/okhttptest.js
Original file line number Diff line number Diff line change
Expand Up @@ -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: {} },
Expand Down
24 changes: 24 additions & 0 deletions test/updatetest.js
Original file line number Diff line number Diff line change
Expand Up @@ -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);
});
});

0 comments on commit 1b35980

Please sign in to comment.