Skip to content

Commit 1b35980

Browse files
authoredMar 1, 2020
rfc 5861 (stale-if-error, stale-while-revalidate)
1 parent 2c2fac2 commit 1b35980

File tree

4 files changed

+129
-13
lines changed

4 files changed

+129
-13
lines changed
 

‎README.md

+5-2
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,8 @@
11
# 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)
22

3-
`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.
3+
`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.
4+
It also implements [RFC 5861](https://tools.ietf.org/html/rfc5861), implementing `stale-if-error` and `stale-while-revalidate`.
5+
It's aware of many tricky details such as the `Vary` header, proxy revalidation, and authenticated responses.
46

57
## Usage
68

@@ -104,6 +106,7 @@ cachedResponse.headers = cachePolicy.responseHeaders(cachedResponse);
104106
Returns approximate time in _milliseconds_ until the response becomes stale (i.e. not fresh).
105107

106108
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()`.
109+
`stale-if-error` and `stale-while-revalidate` extend the time to live of the cache, that can still be used if stale.
107110

108111
### `toObject()`/`fromObject(json)`
109112

@@ -131,7 +134,7 @@ Use this method to update the cache after receiving a new response from the orig
131134

132135
- `policy` — A new `CachePolicy` with HTTP headers updated from `revalidationResponse`. You can always replace the old cached `CachePolicy` with the new one.
133136
- `modified` — Boolean indicating whether the response body has changed.
134-
- If `false`, then a valid 304 Not Modified response has been received, and you can reuse the old cached response body.
137+
- 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`.
135138
- 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.
136139

137140
```js

‎index.js

+50-11
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
'use strict';
22
// rfc7231 6.1
3-
const statusCodeCacheableByDefault = [
3+
const statusCodeCacheableByDefault = new Set([
44
200,
55
203,
66
204,
@@ -12,10 +12,10 @@ const statusCodeCacheableByDefault = [
1212
410,
1313
414,
1414
501,
15-
];
15+
]);
1616

1717
// This implementation does not understand partial responses (206)
18-
const understoodStatuses = [
18+
const understoodStatuses = new Set([
1919
200,
2020
203,
2121
204,
@@ -30,7 +30,14 @@ const understoodStatuses = [
3030
410,
3131
414,
3232
501,
33-
];
33+
]);
34+
35+
const errorStatusCodes = new Set([
36+
500,
37+
502,
38+
503,
39+
504,
40+
]);
3441

3542
const hopByHopHeaders = {
3643
date: true, // included, because we add Age update Date
@@ -43,6 +50,7 @@ const hopByHopHeaders = {
4350
'transfer-encoding': true,
4451
upgrade: true,
4552
};
53+
4654
const excludedFromRevalidationUpdate = {
4755
// Since the old body is reused, it doesn't make sense to change properties of the body
4856
'content-length': true,
@@ -51,6 +59,20 @@ const excludedFromRevalidationUpdate = {
5159
'content-range': true,
5260
};
5361

62+
function toNumberOrZero(s) {
63+
const n = parseInt(s, 10);
64+
return isFinite(n) ? n : 0;
65+
}
66+
67+
// RFC 5861
68+
function isErrorResponse(response) {
69+
// consider undefined response as faulty
70+
if(!response) {
71+
return true
72+
}
73+
return errorStatusCodes.has(response.status);
74+
}
75+
5476
function parseCacheControl(header) {
5577
const cc = {};
5678
if (!header) return cc;
@@ -162,7 +184,7 @@ module.exports = class CachePolicy {
162184
'HEAD' === this._method ||
163185
('POST' === this._method && this._hasExplicitExpiration())) &&
164186
// the response status code is understood by the cache, and
165-
understoodStatuses.indexOf(this._status) !== -1 &&
187+
understoodStatuses.has(this._status) &&
166188
// the "no-store" cache directive does not appear in request or response header fields, and
167189
!this._rescc['no-store'] &&
168190
// the "private" response directive does not appear in the response, if the cache is shared, and
@@ -181,7 +203,7 @@ module.exports = class CachePolicy {
181203
(this._isShared && this._rescc['s-maxage']) ||
182204
this._rescc.public ||
183205
// has a status code that is defined as cacheable by default
184-
statusCodeCacheableByDefault.indexOf(this._status) !== -1)
206+
statusCodeCacheableByDefault.has(this._status))
185207
);
186208
}
187209

@@ -353,8 +375,7 @@ module.exports = class CachePolicy {
353375
}
354376

355377
_ageValue() {
356-
const ageValue = parseInt(this._resHeaders.age);
357-
return isFinite(ageValue) ? ageValue : 0;
378+
return toNumberOrZero(this._resHeaders.age);
358379
}
359380

360381
/**
@@ -390,13 +411,13 @@ module.exports = class CachePolicy {
390411
}
391412
// if a response includes the s-maxage directive, a shared cache recipient MUST ignore the Expires field.
392413
if (this._rescc['s-maxage']) {
393-
return parseInt(this._rescc['s-maxage'], 10);
414+
return toNumberOrZero(this._rescc['s-maxage']);
394415
}
395416
}
396417

397418
// If a response includes a Cache-Control field with the max-age directive, a recipient MUST ignore the Expires field.
398419
if (this._rescc['max-age']) {
399-
return parseInt(this._rescc['max-age'], 10);
420+
return toNumberOrZero(this._rescc['max-age']);
400421
}
401422

402423
const defaultMinTtl = this._rescc.immutable ? this._immutableMinTtl : 0;
@@ -425,13 +446,24 @@ module.exports = class CachePolicy {
425446
}
426447

427448
timeToLive() {
428-
return Math.max(0, this.maxAge() - this.age()) * 1000;
449+
const age = this.maxAge() - this.age();
450+
const staleIfErrorAge = age + toNumberOrZero(this._rescc['stale-if-error']);
451+
const staleWhileRevalidateAge = age + toNumberOrZero(this._rescc['stale-while-revalidate']);
452+
return Math.max(0, age, staleIfErrorAge, staleWhileRevalidateAge) * 1000;
429453
}
430454

431455
stale() {
432456
return this.maxAge() <= this.age();
433457
}
434458

459+
_useStaleIfError() {
460+
return this.maxAge() + toNumberOrZero(this._rescc['stale-if-error']) > this.age();
461+
}
462+
463+
useStaleWhileRevalidate() {
464+
return this.maxAge() + toNumberOrZero(this._rescc['stale-while-revalidate']) > this.age();
465+
}
466+
435467
static fromObject(obj) {
436468
return new this(undefined, undefined, { _fromObject: obj });
437469
}
@@ -549,6 +581,13 @@ module.exports = class CachePolicy {
549581
*/
550582
revalidatedPolicy(request, response) {
551583
this._assertRequestHasHeaders(request);
584+
if(this._useStaleIfError() && isErrorResponse(response)) { // I consider the revalidation request unsuccessful
585+
return {
586+
modified: false,
587+
matches: false,
588+
policy: this,
589+
};
590+
}
552591
if (!response || !response.headers) {
553592
throw Error('Response headers missing');
554593
}

‎test/okhttptest.js

+50
Original file line numberDiff line numberDiff line change
@@ -160,6 +160,56 @@ describe('okhttp tests', function() {
160160
assert(!cache.stale());
161161
});
162162

163+
it('maxAge timetolive', function() {
164+
const cache = new CachePolicy(
165+
{ headers: {} },
166+
{
167+
headers: {
168+
date: formatDate(120, 1),
169+
'cache-control': 'max-age=60',
170+
},
171+
},
172+
{ shared: false }
173+
);
174+
const now = Date.now();
175+
cache.now = () => now
176+
177+
assert(!cache.stale());
178+
assert.equal(cache.timeToLive(), 60000);
179+
});
180+
181+
it('stale-if-error timetolive', function() {
182+
const cache = new CachePolicy(
183+
{ headers: {} },
184+
{
185+
headers: {
186+
date: formatDate(120, 1),
187+
'cache-control': 'max-age=60, stale-if-error=200',
188+
},
189+
},
190+
{ shared: false }
191+
);
192+
193+
assert(!cache.stale());
194+
assert.equal(cache.timeToLive(), 260000);
195+
});
196+
197+
it('stale-while-revalidate timetolive', function() {
198+
const cache = new CachePolicy(
199+
{ headers: {} },
200+
{
201+
headers: {
202+
date: formatDate(120, 1),
203+
'cache-control': 'max-age=60, stale-while-revalidate=200',
204+
},
205+
},
206+
{ shared: false }
207+
);
208+
209+
assert(!cache.stale());
210+
assert.equal(cache.timeToLive(), 260000);
211+
});
212+
163213
it('max age preferred over lower shared max age', function() {
164214
const cache = new CachePolicy(
165215
{ headers: {} },

‎test/updatetest.js

+24
Original file line numberDiff line numberDiff line change
@@ -259,4 +259,28 @@ describe('Update revalidated', function() {
259259
'bad lastmod'
260260
);
261261
});
262+
263+
it("staleIfError revalidate, no response", function() {
264+
const cacheableStaleResponse = { headers: { 'cache-control': 'max-age=200, stale-if-error=300' } };
265+
const cache = new CachePolicy(simpleRequest, cacheableStaleResponse);
266+
267+
const { policy, modified } = cache.revalidatedPolicy(
268+
simpleRequest,
269+
null
270+
);
271+
assert(policy === cache);
272+
assert(modified === false);
273+
});
274+
275+
it("staleIfError revalidate, server error", function() {
276+
const cacheableStaleResponse = { headers: { 'cache-control': 'max-age=200, stale-if-error=300' } };
277+
const cache = new CachePolicy(simpleRequest, cacheableStaleResponse);
278+
279+
const { policy, modified } = cache.revalidatedPolicy(
280+
simpleRequest,
281+
{ status: 500 }
282+
);
283+
assert(policy === cache);
284+
assert(modified === false);
285+
});
262286
});

0 commit comments

Comments
 (0)