Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
40 commits
Select commit Hold shift + click to select a range
e47fd27
Initial work
TartanLlama Jan 12, 2026
6809d57
Matching Rust (partial)
TartanLlama Feb 12, 2026
2938bb8
Finish draft implementation
TartanLlama Feb 12, 2026
7be7b94
Don't overwrite cache override
TartanLlama Feb 12, 2026
899a6ab
Merge branch 'main' into sy/stale-if-error
TartanLlama Feb 12, 2026
23917ac
Compile clean
TartanLlama Feb 12, 2026
dbf5729
Format
TartanLlama Feb 12, 2026
f66c0ce
Compile
TartanLlama Feb 12, 2026
e552d72
Fmt
TartanLlama Feb 12, 2026
439b2ce
Update runtime/fastly/builtins/fetch/fetch.cpp
TartanLlama Feb 12, 2026
247c3b4
Correct symbol name
TartanLlama Feb 13, 2026
d933b0b
Almost there
TartanLlama Apr 8, 2026
646023b
Revert test change
TartanLlama Apr 8, 2026
d24ab07
Rebase
TartanLlama Apr 8, 2026
cd2ba1b
Merge branch 'main' into sy/stale-if-error
TartanLlama Apr 8, 2026
44213da
Fix test
TartanLlama Apr 8, 2026
0e74678
Revert
TartanLlama Apr 8, 2026
6c6e329
Revert
TartanLlama Apr 8, 2026
c043153
Revert
TartanLlama Apr 8, 2026
3294384
fmt
TartanLlama Apr 8, 2026
41613f7
Remove debug output
TartanLlama Apr 8, 2026
0f20fb0
Cleanup
TartanLlama Apr 8, 2026
d45c030
fmt
TartanLlama Apr 8, 2026
ec666e4
Fmt
TartanLlama Apr 8, 2026
68bf8f5
fmt
TartanLlama Apr 8, 2026
2a5e865
Merge branch 'main' into sy/stale-if-error
TartanLlama Apr 9, 2026
6853ac0
Cleanup
TartanLlama Apr 9, 2026
094cca1
Cleanup
TartanLlama Apr 9, 2026
8b16dde
Merge branch 'sy/stale-if-error' of github.com:fastly/js-compute-runt…
TartanLlama Apr 9, 2026
4145bb6
Cleanup
TartanLlama Apr 9, 2026
b1ae77f
Cleanup
TartanLlama Apr 9, 2026
b738ab8
Complete implementation
TartanLlama Apr 9, 2026
5b16bb3
fmt
TartanLlama Apr 9, 2026
a5d1f9f
tests
TartanLlama Apr 9, 2026
c45e6f6
fmt
TartanLlama Apr 9, 2026
62e5794
Docs
TartanLlama Apr 9, 2026
cab0957
Merge branch 'main' into sy/stale-if-error
TartanLlama Apr 10, 2026
c4c7c32
Update runtime/fastly/builtins/fetch/request-response.cpp
TartanLlama Apr 13, 2026
499c392
Remove special-casing 5XX errors
TartanLlama Apr 13, 2026
27c9332
Feedback
TartanLlama Apr 13, 2026
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
Original file line number Diff line number Diff line change
Expand Up @@ -46,6 +46,10 @@ new CacheOverride(init)
- See the [Fastly surrogate keys guide](https://docs.fastly.com/en/guides/purging-api-cache-with-surrogate-keys) for details.
- `swr` _: number_ _**optional**_
- Override the caching behavior of this request to use the given `stale-while-revalidate` time, in seconds
- `staleWhileRevalidate` _: number_ _**optional**_
- A synonym for `swr`.
- `staleIfError` _: number_ _**optional**_
- Override the caching behavior of this request to use the given `stale-if-error` time, in seconds

- `ttl` _: number_ _**optional**_
- Override the caching behavior of this request to use the given Time to Live (TTL), in seconds.
Expand Down
1 change: 1 addition & 0 deletions integration-tests/js-compute/fixtures/app/src/index.js
Original file line number Diff line number Diff line change
Expand Up @@ -51,6 +51,7 @@ import './secret-store.js';
import './security.js';
import './server.js';
import './shielding.js';
import './stale-if-error.js';
import './tee.js';
import './timers.js';
import './urlsearchparams.js';
Expand Down
353 changes: 353 additions & 0 deletions integration-tests/js-compute/fixtures/app/src/stale-if-error.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,353 @@
/// <reference path="../../../../../types/index.d.ts" />
/* eslint-env serviceworker */

import { CacheOverride } from 'fastly:cache-override';
import { assert, assertThrows, strictEqual } from './assertions.js';
import { isRunningLocally, routes } from './routes.js';

// CacheOverride staleIfError property
{
routes.set(
'/stale-if-error/cache-override/constructor-with-staleIfError',
async () => {
const override = new CacheOverride('override', { staleIfError: 300 });
assert(
override.staleIfError,
300,
`new CacheOverride('override', { staleIfError: 300 }).staleIfError === 300`,
);
},
);

routes.set(
'/stale-if-error/cache-override/constructor-without-staleIfError',
async () => {
const override = new CacheOverride('override', { ttl: 300 });
assert(
override.staleIfError,
undefined,
`new CacheOverride('override', { ttl: 300 }).staleIfError === undefined`,
);
},
);

routes.set('/stale-if-error/cache-override/set-staleIfError', async () => {
const override = new CacheOverride('override', {});
override.staleIfError = 600;
assert(
override.staleIfError,
600,
`Setting override.staleIfError = 600 works correctly`,
);
});
}

// Response staleIfError property
{
routes.set(
'/stale-if-error/response/property-undefined-on-non-cached',
async () => {
const response = new Response('test body', {
status: 200,
headers: { 'Content-Type': 'text/plain' },
});
assert(
response.staleIfError,
undefined,
`Non-cached response.staleIfError === undefined`,
);
},
);

routes.set(
'/stale-if-error/response/setter-throws-on-non-cached',
async () => {
const response = new Response('test body');
assertThrows(
() => {
response.staleIfError = 300;
},
TypeError,
'Response set: staleIfError must be set only on unsent cache transaction responses',
Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

nit: Is this the same criterion as "candidate responses", below?

In the Rust universe, we have separate types for the two, but I think they're the same in JS -- the restriction makes sense here, just might be clearer if it's using the same language.

);
},
);

routes.set(
'/stale-if-error/response/staleIfErrorAvailable-throws-outside-afterSend',
async () => {
const response = new Response('test body');
assertThrows(
() => {
response.staleIfErrorAvailable();
},
TypeError,
'Response: staleIfErrorAvailable() must can only be called on candidate responses inside afterSend callback',
);
},
);
}

// Integration tests with fetch
routes.set('/stale-if-error/fetch/with-cache-override', async () => {
const url = 'https://http-me.fastly.dev/now?stale-if-error-test-1';

// First request: populate cache with staleIfError
const response1 = await fetch(url, {
backend: 'httpme',
cacheOverride: new CacheOverride('override', {
ttl: 300,
staleIfError: 600,
}),
});

assert(
response1.staleIfError,
600,
`First response has staleIfError === 600`,
);
assert(typeof response1.ttl, 'number', `First response has numeric ttl`);
});

routes.set(
'/stale-if-error/fetch/staleIfErrorAvailable-after-caching',
async () => {
const sharedCacheKey = 'stale-if-error-available-test-' + Date.now();

// Step 1: Prime the cache with a response that has staleIfError
const primeRequest = new Request('https://http-me.fastly.dev/now', {
backend: 'httpme',
cacheOverride: new CacheOverride('override', {
ttl: 1, // 1 second TTL - will be stale quickly
staleIfError: 600, // Long stale-if-error window
}),
});
primeRequest.setCacheKey(sharedCacheKey);
await fetch(primeRequest);

// Step 2: Wait for the cached response to go stale
await new Promise((resolve) => setTimeout(resolve, 1500));

// Step 3: Make a second request with same cache key
// There's now a stale cached response with staleIfError available
let staleIfErrorAvailableResult;
let staleIfErrorValue;

const checkRequest = new Request('https://http-me.fastly.dev/now', {
backend: 'httpme',
cacheOverride: new CacheOverride('override', {
staleIfError: 600,
afterSend(candidateResponse) {
// Check staleIfErrorAvailable() on the candidate response
staleIfErrorAvailableResult =
candidateResponse.staleIfErrorAvailable();
staleIfErrorValue = candidateResponse.staleIfError;
},
}),
});
checkRequest.setCacheKey(sharedCacheKey);
await fetch(checkRequest);

assert(
staleIfErrorAvailableResult,
true,
`staleIfErrorAvailable() returns true when stale cached response with staleIfError exists`,
);
assert(
staleIfErrorValue,
600,
`Cached response preserves staleIfError === 600`,
);
},
);

routes.set(
'/stale-if-error/fetch/staleIfErrorAvailable-false-without-staleIfError',
async () => {
const url = `https://http-me.fastly.dev/now?stale-if-error-test-no-sie-${Date.now()}`;

let staleIfErrorAvailableResult;

await fetch(url, {
backend: 'httpme',
cacheOverride: new CacheOverride('override', {
ttl: 10,
// No staleIfError configured
afterSend(candidateResponse) {
// Check staleIfErrorAvailable() on the candidate response
staleIfErrorAvailableResult =
candidateResponse.staleIfErrorAvailable();
},
}),
});

assert(
staleIfErrorAvailableResult,
false,
`staleIfErrorAvailable() returns false when staleIfError is not configured`,
);
},
);

routes.set('/stale-if-error/fetch/with-swr-and-staleIfError', async () => {
const url = 'https://http-me.fastly.dev/now?stale-if-error-test-4';

const response = await fetch(url, {
backend: 'httpme',
cacheOverride: new CacheOverride('override', {
ttl: 60,
swr: 300,
staleIfError: 600,
}),
});

assert(response.ttl, 60, `response.ttl === 60`);
assert(response.swr, 300, `response.swr === 300`);
assert(response.staleIfError, 600, `response.staleIfError === 600`);
});

routes.set('/stale-if-error/fetch/zero-staleIfError', async () => {
const url = 'https://http-me.fastly.dev/now?stale-if-error-test-5';

const response = await fetch(url, {
backend: 'httpme',
cacheOverride: new CacheOverride('override', {
ttl: 300,
staleIfError: 0,
}),
});

assert(
response.staleIfError,
0,
`response.staleIfError === 0 when explicitly set to zero`,
);
});

routes.set(
'/stale-if-error/fetch/serve-stale-on-afterSend-exception',
async () => {
const sharedCacheKey = 'stale-if-error-aftersend-exception-' + Date.now();

// Step 1: Cache a successful response with short TTL and long stale-if-error
const goodRequest = new Request('https://http-me.fastly.dev/now', {
backend: 'httpme',
cacheOverride: new CacheOverride('override', {
ttl: 1, // 1 second TTL - will be stale quickly
staleIfError: 3600, // 1 hour stale-if-error window
}),
});
goodRequest.setCacheKey(sharedCacheKey);

const goodResponse = await fetch(goodRequest);
assert(goodResponse.status, 200, 'Initial response is 200');
const initialBody = await goodResponse.text();

// Step 2: Wait for TTL to expire (make response stale)
await new Promise((resolve) => setTimeout(resolve, 1500));

// Step 3: Request with afterSend hook that throws an exception
const errorRequest = new Request('https://http-me.fastly.dev/now', {
backend: 'httpme',
cacheOverride: new CacheOverride('override', {
staleIfError: 3600,
afterSend(response) {
throw new Error('afterSend hook intentionally failed');
},
}),
});
errorRequest.setCacheKey(sharedCacheKey);

const staleResponse = await fetch(errorRequest);

// Step 4: Verify we got the stale 200 response despite afterSend throwing
assert(
staleResponse.status,
200,
'Stale-if-error serves cached response when afterSend throws',
);

const cachedBody = await staleResponse.text();
strictEqual(
cachedBody,
initialBody,
'Response body is from the original cached response',
);

// The masked error should be the exception that was thrown
assert(
staleResponse.maskedError instanceof Error,
true,
'The masked error is an Error object',
);
strictEqual(
staleResponse.maskedError.message,
'afterSend hook intentionally failed',
'The masked error message matches the thrown exception',
);
},
);

routes.set(
'/stale-if-error/fetch/serve-stale-on-beforeSend-exception',
async () => {
const sharedCacheKey = 'stale-if-error-beforesend-exception-' + Date.now();

// Step 1: Cache a successful response with short TTL and long stale-if-error
const goodRequest = new Request('https://http-me.fastly.dev/now', {
backend: 'httpme',
cacheOverride: new CacheOverride('override', {
ttl: 1, // 1 second TTL - will be stale quickly
staleIfError: 3600, // 1 hour stale-if-error window
}),
});
goodRequest.setCacheKey(sharedCacheKey);

const goodResponse = await fetch(goodRequest);
assert(goodResponse.status, 200, 'Initial response is 200');
const initialBody = await goodResponse.text();

// Step 2: Wait for TTL to expire (make response stale)
await new Promise((resolve) => setTimeout(resolve, 1500));

// Step 3: Request with beforeSend hook that throws an exception
const errorRequest = new Request('https://http-me.fastly.dev/now', {
backend: 'httpme',
cacheOverride: new CacheOverride('override', {
staleIfError: 3600,
beforeSend(request) {
throw new Error('beforeSend hook intentionally failed');
},
}),
});
errorRequest.setCacheKey(sharedCacheKey);

const staleResponse = await fetch(errorRequest);

// Step 4: Verify we got the stale 200 response despite beforeSend throwing
assert(
staleResponse.status,
200,
'Stale-if-error serves cached response when beforeSend throws',
);

const cachedBody = await staleResponse.text();
strictEqual(
cachedBody,
initialBody,
'Response body is from the original cached response',
);

// The masked error should be the exception that was thrown
assert(
staleResponse.maskedError instanceof Error,
true,
'The masked error is an Error object',
);
strictEqual(
staleResponse.maskedError.message,
'beforeSend hook intentionally failed',
'The masked error message matches the thrown exception',
);
},
);
Loading
Loading