Skip to content
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

rfc 5861 (stale-if-error, stale-while-revalidate) #30

Merged
merged 6 commits into from
Mar 1, 2020
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
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();
kornelski marked this conversation as resolved.
Show resolved Hide resolved
}

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);
});
});