diff --git a/documentation/docs/cache-override/CacheOverride/CacheOverride.mdx b/documentation/docs/cache-override/CacheOverride/CacheOverride.mdx
index b079e61473..58a366cb87 100644
--- a/documentation/docs/cache-override/CacheOverride/CacheOverride.mdx
+++ b/documentation/docs/cache-override/CacheOverride/CacheOverride.mdx
@@ -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.
diff --git a/integration-tests/js-compute/fixtures/app/src/index.js b/integration-tests/js-compute/fixtures/app/src/index.js
index 09d9a95a9e..02d525bf59 100644
--- a/integration-tests/js-compute/fixtures/app/src/index.js
+++ b/integration-tests/js-compute/fixtures/app/src/index.js
@@ -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';
diff --git a/integration-tests/js-compute/fixtures/app/src/stale-if-error.js b/integration-tests/js-compute/fixtures/app/src/stale-if-error.js
new file mode 100644
index 0000000000..97ec42c6a7
--- /dev/null
+++ b/integration-tests/js-compute/fixtures/app/src/stale-if-error.js
@@ -0,0 +1,353 @@
+///
+/* 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',
+ );
+ },
+ );
+
+ 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',
+ );
+ },
+);
diff --git a/integration-tests/js-compute/fixtures/app/tests.json b/integration-tests/js-compute/fixtures/app/tests.json
index a4889441f3..9d51211cd2 100644
--- a/integration-tests/js-compute/fixtures/app/tests.json
+++ b/integration-tests/js-compute/fixtures/app/tests.json
@@ -3230,6 +3230,40 @@
"GET /shielding/invalid-shield": {
"environments": ["compute"]
},
+ "GET /stale-if-error/cache-override/constructor-with-staleIfError": {},
+ "GET /stale-if-error/cache-override/constructor-without-staleIfError": {},
+ "GET /stale-if-error/cache-override/set-staleIfError": {},
+ "GET /stale-if-error/response/property-undefined-on-non-cached": {},
+ "GET /stale-if-error/response/setter-throws-on-non-cached": {},
+ "GET /stale-if-error/response/staleIfErrorAvailable-throws-outside-afterSend": {},
+ "GET /stale-if-error/fetch/staleIfErrorAvailable-false-without-staleIfError": {
+ "environments": ["compute"],
+ "features": ["http-cache"]
+ },
+ "GET /stale-if-error/fetch/with-cache-override": {
+ "environments": ["compute"],
+ "features": ["http-cache"]
+ },
+ "GET /stale-if-error/fetch/staleIfErrorAvailable-after-caching": {
+ "environments": ["compute"],
+ "features": ["http-cache"]
+ },
+ "GET /stale-if-error/fetch/with-swr-and-staleIfError": {
+ "environments": ["compute"],
+ "features": ["http-cache"]
+ },
+ "GET /stale-if-error/fetch/zero-staleIfError": {
+ "environments": ["compute"],
+ "features": ["http-cache"]
+ },
+ "GET /stale-if-error/fetch/serve-stale-on-afterSend-exception": {
+ "environments": ["compute"],
+ "features": ["http-cache"]
+ },
+ "GET /stale-if-error/fetch/serve-stale-on-beforeSend-exception": {
+ "environments": ["compute"],
+ "features": ["http-cache"]
+ },
"GET /fastly/security/inspect/invalid-config": {
"environments": ["viceroy"]
},
diff --git a/runtime/fastly/builtins/cache-override.cpp b/runtime/fastly/builtins/cache-override.cpp
index c371de0d82..9457741835 100644
--- a/runtime/fastly/builtins/cache-override.cpp
+++ b/runtime/fastly/builtins/cache-override.cpp
@@ -65,17 +65,31 @@ void CacheOverride::set_ttl(JSObject *self, uint32_t ttl) {
JS::SetReservedSlot(self, CacheOverride::Slots::TTL, JS::Int32Value((int32_t)ttl));
}
-JS::Value CacheOverride::swr(JSObject *self) {
+JS::Value CacheOverride::staleWhileRevalidate(JSObject *self) {
MOZ_ASSERT(is_instance(self));
if (CacheOverride::mode(self) != CacheOverride::CacheOverrideMode::Override)
return JS::UndefinedValue();
- return JS::GetReservedSlot(self, Slots::SWR);
+ return JS::GetReservedSlot(self, Slots::StaleWhileRevalidate);
}
-void CacheOverride::set_swr(JSObject *self, uint32_t swr) {
+void CacheOverride::set_staleWhileRevalidate(JSObject *self, uint32_t swr) {
MOZ_ASSERT(is_instance(self));
MOZ_ASSERT(CacheOverride::mode(self) == CacheOverride::CacheOverrideMode::Override);
- JS::SetReservedSlot(self, CacheOverride::Slots::SWR, JS::Int32Value((int32_t)swr));
+ JS::SetReservedSlot(self, CacheOverride::Slots::StaleWhileRevalidate,
+ JS::Int32Value((int32_t)swr));
+}
+
+JS::Value CacheOverride::staleIfError(JSObject *self) {
+ MOZ_ASSERT(is_instance(self));
+ if (CacheOverride::mode(self) != CacheOverride::CacheOverrideMode::Override)
+ return JS::UndefinedValue();
+ return JS::GetReservedSlot(self, Slots::StaleIfError);
+}
+
+void CacheOverride::set_staleIfError(JSObject *self, uint32_t sie) {
+ MOZ_ASSERT(is_instance(self));
+ MOZ_ASSERT(CacheOverride::mode(self) == CacheOverride::CacheOverrideMode::Override);
+ JS::SetReservedSlot(self, CacheOverride::Slots::StaleIfError, JS::Int32Value((int32_t)sie));
}
JS::Value CacheOverride::surrogate_key(JSObject *self) {
@@ -149,7 +163,7 @@ host_api::CacheOverrideTag CacheOverride::abi_tag(JSObject *self) {
tag.set_ttl();
}
- if (!swr(self).isUndefined()) {
+ if (!staleWhileRevalidate(self).isUndefined()) {
tag.set_stale_while_revalidate();
}
@@ -240,32 +254,65 @@ bool CacheOverride::ttl_set(JSContext *cx, JS::HandleObject self, JS::HandleValu
return true;
}
-bool CacheOverride::swr_get(JSContext *cx, JS::HandleObject self, JS::MutableHandleValue rval) {
+bool CacheOverride::staleWhileRevalidate_get(JSContext *cx, JS::HandleObject self,
+ JS::MutableHandleValue rval) {
if (self == proto_obj) {
- return api::throw_error(cx, api::Errors::WrongReceiver, "swr get", "CacheOverride");
+ return api::throw_error(cx, api::Errors::WrongReceiver, "staleWhileRevalidate get",
+ "CacheOverride");
}
- rval.set(swr(self));
+ rval.set(staleWhileRevalidate(self));
return true;
}
-bool CacheOverride::swr_set(JSContext *cx, JS::HandleObject self, JS::HandleValue val,
- JS::MutableHandleValue rval) {
+bool CacheOverride::staleWhileRevalidate_set(JSContext *cx, JS::HandleObject self,
+ JS::HandleValue val, JS::MutableHandleValue rval) {
+ if (self == proto_obj) {
+ return api::throw_error(cx, api::Errors::WrongReceiver, "staleWhileRevalidate set",
+ "CacheOverride");
+ }
+ if (!ensure_override(cx, self, "staleWhileRevalidate"))
+ return false;
+
+ if (val.isUndefined()) {
+ JS::SetReservedSlot(self, Slots::StaleWhileRevalidate, val);
+ } else {
+ int32_t swr;
+ if (!JS::ToInt32(cx, val, &swr))
+ return false;
+
+ set_staleWhileRevalidate(self, swr);
+ }
+ rval.set(staleWhileRevalidate(self));
+ return true;
+}
+
+bool CacheOverride::staleIfError_get(JSContext *cx, JS::HandleObject self,
+ JS::MutableHandleValue rval) {
+ if (self == proto_obj) {
+ return api::throw_error(cx, api::Errors::WrongReceiver, "staleIfError get", "CacheOverride");
+ }
+ rval.set(staleIfError(self));
+ return true;
+}
+
+bool CacheOverride::staleIfError_set(JSContext *cx, JS::HandleObject self, JS::HandleValue val,
+ JS::MutableHandleValue rval) {
if (self == proto_obj) {
- return api::throw_error(cx, api::Errors::WrongReceiver, "swr set", "CacheOverride");
+ return api::throw_error(cx, api::Errors::WrongReceiver, "staleIfError set", "CacheOverride");
}
- if (!ensure_override(cx, self, "SWR"))
+ if (!ensure_override(cx, self, "staleIfError"))
return false;
if (val.isUndefined()) {
- JS::SetReservedSlot(self, Slots::SWR, val);
+ JS::SetReservedSlot(self, Slots::StaleIfError, val);
} else {
int32_t swr;
if (!JS::ToInt32(cx, val, &swr))
return false;
- set_swr(self, swr);
+ set_staleIfError(self, swr);
}
- rval.set(swr(self));
+ rval.set(staleIfError(self));
return true;
}
@@ -416,7 +463,10 @@ const JSFunctionSpec CacheOverride::methods[] = {JS_FS_END};
const JSPropertySpec CacheOverride::properties[] = {
JS_PSGS("mode", accessor_get, accessor_set, JSPROP_ENUMERATE),
JS_PSGS("ttl", accessor_get, accessor_set, JSPROP_ENUMERATE),
- JS_PSGS("swr", accessor_get, accessor_set, JSPROP_ENUMERATE),
+ JS_PSGS("swr", accessor_get, accessor_set,
+ JSPROP_ENUMERATE),
+ JS_PSGS("staleWhileRevalidate", accessor_get,
+ accessor_set, JSPROP_ENUMERATE),
JS_PSGS("surrogateKey", accessor_get, accessor_set,
JSPROP_ENUMERATE),
JS_PSGS("pci", accessor_get, accessor_set, JSPROP_ENUMERATE),
@@ -424,6 +474,8 @@ const JSPropertySpec CacheOverride::properties[] = {
JSPROP_ENUMERATE),
JS_PSGS("afterSend", accessor_get, accessor_set,
JSPROP_ENUMERATE),
+ JS_PSGS("staleIfError", accessor_get, accessor_set,
+ JSPROP_ENUMERATE),
JS_STRING_SYM_PS(toStringTag, "CacheOverride", JSPROP_READONLY),
JS_PS_END};
@@ -451,7 +503,22 @@ JSObject *CacheOverride::create(JSContext *cx, JS::HandleValue override) {
return nullptr;
}
- if (!JS_GetProperty(cx, override_obj, "swr", &val) || !swr_set(cx, self, val, &val)) {
+ if (!JS_GetProperty(cx, override_obj, "swr", &val) ||
+ !staleWhileRevalidate_set(cx, self, val, &val)) {
+ return nullptr;
+ }
+
+ if (!JS_GetProperty(cx, override_obj, "staleWhileRevalidate", &val)) {
+ return nullptr;
+ }
+ if (!val.isNullOrUndefined()) {
+ if (!staleWhileRevalidate_set(cx, self, val, &val)) {
+ return nullptr;
+ }
+ }
+
+ if (!JS_GetProperty(cx, override_obj, "staleIfError", &val) ||
+ !staleIfError_set(cx, self, val, &val)) {
return nullptr;
}
diff --git a/runtime/fastly/builtins/cache-override.h b/runtime/fastly/builtins/cache-override.h
index 64af6446b2..a91f9af930 100644
--- a/runtime/fastly/builtins/cache-override.h
+++ b/runtime/fastly/builtins/cache-override.h
@@ -19,7 +19,8 @@ class CacheOverride : public builtins::BuiltinImpl {
//
// If `Mode` is `Override`, the values are interpreted in the following way:
//
- // If `TTL`, `SWR`, or `SurrogateKey` are `undefined`, they're ignored.
+ // If `TTL`, `StaleWhileRevalidate`, `StaleIfError`, or `SurrogateKey` are
+ // `undefined`, they're ignored.
// For each of them, if the value isn't `undefined`, a flag gets set in the
// hostcall's `tag` parameter, and the value itself is encoded as a uint32
// parameter.
@@ -29,7 +30,17 @@ class CacheOverride : public builtins::BuiltinImpl {
//
// `BeforeSend` and `AfterSend` are function callbacks that can be set
// to execute before and after sending the request.
- enum Slots { Mode, TTL, SWR, SurrogateKey, PCI, BeforeSend, AfterSend, Count };
+ enum Slots {
+ Mode,
+ TTL,
+ StaleWhileRevalidate,
+ SurrogateKey,
+ PCI,
+ BeforeSend,
+ AfterSend,
+ StaleIfError,
+ Count
+ };
enum class CacheOverrideMode { None, Pass, Override };
@@ -40,8 +51,10 @@ class CacheOverride : public builtins::BuiltinImpl {
static host_api::CacheOverrideTag abi_tag(JSObject *self);
static JS::Value ttl(JSObject *self);
static void set_ttl(JSObject *self, uint32_t ttl);
- static JS::Value swr(JSObject *self);
- static void set_swr(JSObject *self, uint32_t swr);
+ static JS::Value staleWhileRevalidate(JSObject *self);
+ static void set_staleWhileRevalidate(JSObject *self, uint32_t swr);
+ static JS::Value staleIfError(JSObject *self);
+ static void set_staleIfError(JSObject *self, uint32_t sie);
static JS::Value surrogate_key(JSObject *self);
static void set_surrogate_key(JSObject *self, JSString *key);
static JSObject *clone(JSContext *cx, JS::HandleObject self);
@@ -59,9 +72,13 @@ class CacheOverride : public builtins::BuiltinImpl {
static bool ttl_get(JSContext *cx, JS::HandleObject self, JS::MutableHandleValue rval);
static bool ttl_set(JSContext *cx, JS::HandleObject self, JS::HandleValue val,
JS::MutableHandleValue rval);
- static bool swr_get(JSContext *cx, JS::HandleObject self, JS::MutableHandleValue rval);
- static bool swr_set(JSContext *cx, JS::HandleObject self, JS::HandleValue val,
- JS::MutableHandleValue rval);
+ static bool staleWhileRevalidate_get(JSContext *cx, JS::HandleObject self,
+ JS::MutableHandleValue rval);
+ static bool staleWhileRevalidate_set(JSContext *cx, JS::HandleObject self, JS::HandleValue val,
+ JS::MutableHandleValue rval);
+ static bool staleIfError_get(JSContext *cx, JS::HandleObject self, JS::MutableHandleValue rval);
+ static bool staleIfError_set(JSContext *cx, JS::HandleObject self, JS::HandleValue val,
+ JS::MutableHandleValue rval);
static bool surrogate_key_get(JSContext *cx, JS::HandleObject self, JS::MutableHandleValue rval);
static bool surrogate_key_set(JSContext *cx, JS::HandleObject self, JS::HandleValue val,
JS::MutableHandleValue rval);
diff --git a/runtime/fastly/builtins/fetch/fetch.cpp b/runtime/fastly/builtins/fetch/fetch.cpp
index 696ffde932..6b2bef0efb 100644
--- a/runtime/fastly/builtins/fetch/fetch.cpp
+++ b/runtime/fastly/builtins/fetch/fetch.cpp
@@ -394,10 +394,18 @@ bool fetch_process_cache_hooks_origin_request(JSContext *cx, JS::HandleObject re
bool fetch_process_cache_hooks_before_send_reject(JSContext *cx, JS::HandleObject request,
JS::HandleValue ret_promise, JS::CallArgs args) {
+ JS::RootedObject ret_promise_obj(cx, &ret_promise.toObject());
+
+ auto maybe_stale = fastly::fetch::try_serve_stale_if_error(cx, request, args.get(0));
+ if (maybe_stale.has_value()) {
+ JS::RootedValue response_val(cx, JS::ObjectValue(*maybe_stale.value()));
+ return JS::ResolvePromise(cx, ret_promise_obj, response_val);
+ }
+
+ // No stale-if-error available, close cache and reject
if (!RequestOrResponse::close_if_cache_entry(cx, request)) {
return false;
}
- JS::RootedObject ret_promise_obj(cx, &ret_promise.toObject());
JS::RejectPromise(cx, ret_promise_obj, args.get(0));
return true;
}
@@ -863,6 +871,12 @@ std::optional get_found_response(JSContext *cx, host_api::HttpCacheE
return nullptr;
}
override_cache_options->stale_while_revalidate_ns = swr_res.unwrap();
+ auto sie_res = cache_entry.get_stale_if_error_ns();
+ if (auto *err = sie_res.to_err()) {
+ HANDLE_ERROR(cx, *err);
+ return nullptr;
+ }
+ override_cache_options->stale_if_error_ns = sie_res.unwrap();
auto length_res = cache_entry.get_length();
if (auto *err = length_res.to_err()) {
HANDLE_ERROR(cx, *err);
@@ -910,6 +924,7 @@ bool stream_back_then_handler(JSContext *cx, JS::HandleObject request, JS::Handl
// response process.
auto cache_write_options = Response::override_cache_options(response_obj);
MOZ_ASSERT(cache_write_options);
+
switch (storage_action) {
case host_api::HttpStorageAction::Insert: {
auto insert_res = cache_entry.transaction_insert_and_stream_back(
@@ -1086,6 +1101,13 @@ bool stream_back_then_handler(JSContext *cx, JS::HandleObject request, JS::Handl
bool stream_back_catch_handler(JSContext *cx, JS::HandleObject request, JS::HandleValue promise_val,
JS::CallArgs args) {
+ auto maybe_stale = fastly::fetch::try_serve_stale_if_error(cx, request, args.get(0));
+ if (maybe_stale.has_value()) {
+ args.rval().setObject(*maybe_stale.value());
+ return true;
+ }
+
+ // No stale-if-error available, close cache and fail
// we follow the Rust implementation calling "close" instead of "transaction_abandon" here
// this could be reconsidered in future if alternative semantics are required
if (!RequestOrResponse::close_if_cache_entry(cx, request)) {
@@ -1102,6 +1124,65 @@ namespace fastly::fetch {
api::Engine *ENGINE;
+// Helper function to check for and serve stale-if-error responses when errors occur
+// Returns the stale response if available, or std::nullopt if not
+std::optional try_serve_stale_if_error(JSContext *cx,
+ JS::HandleObject request_or_response,
+ JS::HandleValue error_val) {
+ auto maybe_cache_entry = RequestOrResponse::cache_entry(request_or_response);
+ if (!maybe_cache_entry.has_value()) {
+ return std::nullopt;
+ }
+
+ auto cache_entry = maybe_cache_entry.value();
+ auto state_res = cache_entry.get_state();
+ if (state_res.is_err()) {
+ return std::nullopt;
+ }
+
+ auto cache_state = state_res.unwrap();
+ if (!cache_state.is_usable_if_error()) {
+ return std::nullopt;
+ }
+
+ // We have a usable stale-if-error response
+ auto chose_stale_res = cache_entry.transaction_choose_stale();
+ if (auto *err = chose_stale_res.to_err()) {
+ HANDLE_ERROR(cx, *err);
+ return std::nullopt;
+ }
+
+ // Determine the request object for get_found_response
+ JS::RootedObject request(cx);
+ if (Request::is_instance(request_or_response)) {
+ request.set(request_or_response);
+ }
+ // If it's a response, we don't have the request handy, but get_found_response can work without it
+
+ JS::RootedValue no_candidate(cx);
+ auto maybe_response = get_found_response(cx, cache_entry, request, no_candidate, false);
+ if (!maybe_response.has_value() || !maybe_response.value()) {
+ return std::nullopt;
+ }
+
+ JS::RootedObject stale_response(cx, maybe_response.value());
+
+ // Store the error as masked error
+ JS_SetReservedSlot(stale_response, static_cast(Response::Slots::MaskedError),
+ error_val);
+
+ RequestOrResponse::take_cache_entry(stale_response, true);
+
+ if (request) {
+ if (!Response::add_fastly_cache_headers(cx, stale_response, request, cache_entry,
+ "cached response")) {
+ return std::nullopt;
+ }
+ }
+
+ return stale_response;
+}
+
/**
* The `fetch` global function
* https://fetch.spec.whatwg.org/#fetch-method
@@ -1164,7 +1245,7 @@ bool fetch(JSContext *cx, unsigned argc, Value *vp) {
// Lookup in cache
auto request_handle = Request::request_handle(request);
- // Convert override cache key to span if present
+ // Convert override cache key to hash if present
std::vector override_key_hash;
JS::RootedValue override_cache_key(
cx, JS::GetReservedSlot(request, static_cast(Request::Slots::OverrideCacheKey)));
@@ -1176,8 +1257,9 @@ bool fetch(JSContext *cx, unsigned argc, Value *vp) {
override_key_hash.begin(), override_key_hash.end());
}
- auto transaction_res =
+ host_api::Result transaction_res =
host_api::HttpCacheEntry::transaction_lookup(request_handle, override_key_hash);
+
if (auto *err = transaction_res.to_err()) {
DEBUG_LOG("HTTP Cache: Transaction lookup error")
if (host_api::error_is_limit_exceeded(*err)) {
@@ -1256,6 +1338,19 @@ bool fetch(JSContext *cx, unsigned argc, Value *vp) {
}
}
+ if (cache_state.is_usable_if_error()) {
+ // The cached response is a usable stale-if-error response, which implies
+ // that the request collapse leader errored.
+ JS_ReportErrorASCII(cx, "error in request collapse leader");
+ JS::RootedValue exception(cx);
+ if (!JS_GetPendingException(cx, &exception)) {
+ return false;
+ }
+ JS_ClearPendingException(cx);
+ JS_SetReservedSlot(cached_response, static_cast(Response::Slots::MaskedError),
+ exception);
+ }
+
// mark the response cache entry as cached for the cached getter
RequestOrResponse::take_cache_entry(cached_response, true);
@@ -1272,17 +1367,22 @@ bool fetch(JSContext *cx, unsigned argc, Value *vp) {
}
// No valid cached response, need to make backend request
- if (!cache_state.must_insert_or_update()) {
- // transaction entry is done
- if (!RequestOrResponse::close_if_cache_entry(cx, request)) {
- return false;
- }
- // request collapsing has been disabled: pass the original request to the origin without
- // updating the cache and without caching
- return fetch_send_body(cx, request, args.rval());
- } else {
+ if (cache_state.must_insert_or_update()) {
+ // We are responsible for fetching/revalidating
JS::RootedValue stream_back_promise(cx, JS::ObjectValue(*JS::NewPromiseObject(cx, nullptr)));
if (!fetch_send_body_with_cache_hooks(cx, request, cache_entry, &stream_back_promise)) {
+ JS::RootedValue exception(cx);
+ if (JS_GetPendingException(cx, &exception)) {
+ JS_ClearPendingException(cx);
+ }
+ auto maybe_stale = try_serve_stale_if_error(cx, request, exception);
+ if (maybe_stale.has_value()) {
+ JS::RootedObject cached_response(cx, maybe_stale.value());
+ RootedObject response_promise(cx, JS::NewPromiseObject(cx, nullptr));
+ JS::RootedValue response_val(cx, JS::ObjectValue(*cached_response));
+ args.rval().setObject(*response_promise);
+ return JS::ResolvePromise(cx, response_promise, response_val);
+ }
RequestOrResponse::close_if_cache_entry(cx, request);
return false;
}
@@ -1297,6 +1397,16 @@ bool fetch(JSContext *cx, unsigned argc, Value *vp) {
JS::SetReservedSlot(request, static_cast(Request::Slots::ResponsePromise),
JS::ObjectValue(*ret_promise));
args.rval().setObject(*ret_promise);
+ } else {
+ // Request collapsing has been disabled: pass the original request to the origin without
+ // updating the cache and without caching
+
+ // transaction entry is done
+ if (!RequestOrResponse::close_if_cache_entry(cx, request)) {
+ return false;
+ }
+
+ return fetch_send_body(cx, request, args.rval());
}
return true;
diff --git a/runtime/fastly/builtins/fetch/fetch.h b/runtime/fastly/builtins/fetch/fetch.h
index 8a8dc7eff6..fda30c793b 100644
--- a/runtime/fastly/builtins/fetch/fetch.h
+++ b/runtime/fastly/builtins/fetch/fetch.h
@@ -1,7 +1,14 @@
#include "../../../StarlingMonkey/builtins/web/fetch/headers.h"
#include "request-response.h"
+#include
namespace fastly::fetch {
extern api::Engine *ENGINE;
extern bool http_caching_unsupported;
+
+// Try to serve a stale-if-error response when an error occurs.
+// Returns the stale response if available, or std::nullopt if not.
+std::optional try_serve_stale_if_error(JSContext *cx,
+ JS::HandleObject request_or_response,
+ JS::HandleValue error_val);
} // namespace fastly::fetch
diff --git a/runtime/fastly/builtins/fetch/request-response.cpp b/runtime/fastly/builtins/fetch/request-response.cpp
index 0df50fbfb9..ed46f0d22c 100644
--- a/runtime/fastly/builtins/fetch/request-response.cpp
+++ b/runtime/fastly/builtins/fetch/request-response.cpp
@@ -565,6 +565,9 @@ bool after_send_then(JSContext *cx, JS::HandleObject response, JS::HandleValue p
cache_write_options->stale_while_revalidate_ns =
suggested_cache_write_options->stale_while_revalidate_ns;
}
+ if (!cache_write_options->stale_if_error_ns.has_value()) {
+ cache_write_options->stale_if_error_ns = suggested_cache_write_options->stale_if_error_ns;
+ }
if (!cache_write_options->surrogate_keys.has_value()) {
cache_write_options->surrogate_keys = std::move(suggested_cache_write_options->surrogate_keys);
}
@@ -596,6 +599,14 @@ bool after_send_then(JSContext *cx, JS::HandleObject response, JS::HandleValue p
bool after_send_catch(JSContext *cx, JS::HandleObject response, JS::HandleValue promise,
JS::CallArgs args) {
JS::RootedObject promise_obj(cx, &promise.toObject());
+
+ auto maybe_stale = try_serve_stale_if_error(cx, response, args.get(0));
+ if (maybe_stale.has_value()) {
+ JS::RootedValue response_val(cx, JS::ObjectValue(*maybe_stale.value()));
+ return JS::ResolvePromise(cx, promise_obj, response_val);
+ }
+
+ // No stale-if-error available, close cache and reject
if (!RequestOrResponse::close_if_cache_entry(cx, response)) {
return false;
}
@@ -705,12 +716,18 @@ bool RequestOrResponse::process_pending_request(JSContext *cx,
override_cache_options->max_age_ns = ttl_ns + initial_age_ns;
}
- RootedValue override_swr(cx, CacheOverride::swr(cache_override));
+ RootedValue override_swr(cx, CacheOverride::staleWhileRevalidate(cache_override));
if (!override_swr.isUndefined()) {
override_cache_options->stale_while_revalidate_ns =
static_cast(override_swr.toInt32() * 1e9);
}
+ RootedValue override_sie(cx, CacheOverride::staleIfError(cache_override));
+ if (!override_sie.isUndefined()) {
+ override_cache_options->stale_if_error_ns =
+ static_cast(override_sie.toInt32() * 1e9);
+ }
+
// overriding surrogate keys composes suggested surrogate keys with the original cache override
// space-split keys, so again, use the suggested computation to do this.
RootedValue override_surrogate_keys(cx, CacheOverride::surrogate_key(cache_override));
@@ -2105,8 +2122,8 @@ bool Request::set_cache_key(JSContext *cx, JS::HandleObject self, JS::HandleValu
if (!headers) {
return false;
}
- JS::SetReservedSlot(self, static_cast(Slots::OverrideCacheKey), cache_key_str_val);
JS::RootedObject headers_val(cx, headers);
+ JS::SetReservedSlot(self, static_cast(Slots::OverrideCacheKey), cache_key_str_val);
JS::RootedValue value_val(
cx, JS::StringValue(JS_NewStringCopyN(cx, hex_str.c_str(), hex_str.length())));
if (!Headers::append_valid_header(cx, headers_val, "fastly-xqd-cache-key", value_val,
@@ -2198,7 +2215,7 @@ bool Request::apply_cache_override(JSContext *cx, JS::HandleObject self) {
}
std::optional stale_while_revalidate;
- val = CacheOverride::swr(override);
+ val = CacheOverride::staleWhileRevalidate(override);
if (!val.isUndefined()) {
stale_while_revalidate = val.toInt32();
}
@@ -3846,6 +3863,7 @@ const JSFunctionSpec Response::methods[] = {
JS_FN("json", bodyAll, 0, JSPROP_ENUMERATE),
JS_FN("text", bodyAll, 0, JSPROP_ENUMERATE),
JS_FN("setManualFramingHeaders", Response::setManualFramingHeaders, 1, JSPROP_ENUMERATE),
+ JS_FN("staleIfErrorAvailable", staleIfErrorAvailable, 0, JSPROP_ENUMERATE),
JS_FS_END,
};
@@ -3867,10 +3885,14 @@ const JSPropertySpec Response::properties[] = {
JS_PSG("stale", stale_get, JSPROP_ENUMERATE),
JS_PSGS("ttl", ttl_get, ttl_set, JSPROP_ENUMERATE),
JS_PSG("age", age_get, JSPROP_ENUMERATE),
- JS_PSGS("swr", swr_get, swr_set, JSPROP_ENUMERATE),
+ JS_PSGS("swr", staleWhileRevalidate_get, staleWhileRevalidate_set, JSPROP_ENUMERATE),
+ JS_PSGS("staleWhileRevalidate", staleWhileRevalidate_get, staleWhileRevalidate_set,
+ JSPROP_ENUMERATE),
+ JS_PSGS("staleIfError", staleIfError_get, staleIfError_set, JSPROP_ENUMERATE),
JS_PSGS("vary", vary_get, vary_set, JSPROP_ENUMERATE),
JS_PSGS("surrogateKeys", surrogateKeys_get, surrogateKeys_set, JSPROP_ENUMERATE),
JS_PSGS("pci", pci_get, pci_set, JSPROP_ENUMERATE),
+ JS_PSG("maskedError", maskedError_get, JSPROP_ENUMERATE),
JS_STRING_SYM_PS(toStringTag, "Response", JSPROP_READONLY),
JS_PS_END,
};
@@ -4006,7 +4028,7 @@ bool Response::age_get(JSContext *cx, unsigned argc, JS::Value *vp) {
return true;
}
-bool Response::swr_get(JSContext *cx, unsigned argc, JS::Value *vp) {
+bool Response::staleWhileRevalidate_get(JSContext *cx, unsigned argc, JS::Value *vp) {
METHOD_HEADER(0)
auto entry = RequestOrResponse::cache_entry(self);
@@ -4035,6 +4057,35 @@ bool Response::swr_get(JSContext *cx, unsigned argc, JS::Value *vp) {
return true;
}
+bool Response::staleIfError_get(JSContext *cx, unsigned argc, JS::Value *vp) {
+ METHOD_HEADER(0)
+
+ auto entry = RequestOrResponse::cache_entry(self);
+
+ // all caching paths should set the override options as the final options
+ // so if they aren't set we are in the undefined cases of no caching API use / no hostcall support
+ auto override_opts = override_cache_options(self);
+ if (!override_opts) {
+ args.rval().setUndefined();
+ return true;
+ }
+
+ uint64_t sie_ns;
+ // a promoted candidate response must define all cache options
+ if (!entry.has_value() || override_opts->stale_if_error_ns.has_value()) {
+ sie_ns = override_opts->stale_if_error_ns.value();
+ } else {
+ auto suggested_opts = suggested_cache_options(cx, self);
+ if (!suggested_opts) {
+ return false;
+ }
+ sie_ns = suggested_opts->stale_if_error_ns.value();
+ }
+
+ args.rval().setNumber(static_cast(sie_ns) / 1e9);
+ return true;
+}
+
bool Response::vary_get(JSContext *cx, unsigned argc, JS::Value *vp) {
METHOD_HEADER(0)
@@ -4198,6 +4249,12 @@ bool Response::pci_get(JSContext *cx, unsigned argc, JS::Value *vp) {
return true;
}
+bool Response::maskedError_get(JSContext *cx, unsigned argc, JS::Value *vp) {
+ METHOD_HEADER(0)
+ args.rval().set(JS::GetReservedSlot(self, static_cast(Slots::MaskedError)));
+ return true;
+}
+
// Setters for mutable properties
bool Response::ttl_set(JSContext *cx, unsigned argc, JS::Value *vp) {
@@ -4236,12 +4293,12 @@ bool Response::ttl_set(JSContext *cx, unsigned argc, JS::Value *vp) {
return true;
}
-bool Response::swr_set(JSContext *cx, unsigned argc, JS::Value *vp) {
+bool Response::staleWhileRevalidate_set(JSContext *cx, unsigned argc, JS::Value *vp) {
METHOD_HEADER(1)
auto override_opts = override_cache_options(self);
if (!RequestOrResponse::cache_entry(self).has_value()) {
- api::throw_error(cx, api::Errors::TypeError, "Response set", "swr",
+ api::throw_error(cx, api::Errors::TypeError, "Response set", "staleWhileRevalidate",
"be set only on unsent cache transaction responses");
return false;
}
@@ -4253,7 +4310,7 @@ bool Response::swr_set(JSContext *cx, unsigned argc, JS::Value *vp) {
}
if (std::isnan(seconds) || seconds <= 0) {
- api::throw_error(cx, api::Errors::TypeError, "Response set", "swr",
+ api::throw_error(cx, api::Errors::TypeError, "Response set", "staleWhileRevalidate",
"be a number greater than zero");
return false;
}
@@ -4264,6 +4321,34 @@ bool Response::swr_set(JSContext *cx, unsigned argc, JS::Value *vp) {
return true;
}
+bool Response::staleIfError_set(JSContext *cx, unsigned argc, JS::Value *vp) {
+ METHOD_HEADER(1)
+
+ auto override_opts = override_cache_options(self);
+ if (!RequestOrResponse::cache_entry(self).has_value()) {
+ api::throw_error(cx, api::Errors::TypeError, "Response set", "staleIfError",
+ "be set only on unsent cache transaction responses");
+ return false;
+ }
+ MOZ_ASSERT(override_opts);
+
+ double seconds;
+ if (!JS::ToNumber(cx, args[0], &seconds)) {
+ return false;
+ }
+
+ if (std::isnan(seconds) || seconds <= 0) {
+ api::throw_error(cx, api::Errors::TypeError, "Response set", "staleIfError",
+ "be a number greater than zero");
+ return false;
+ }
+
+ override_opts->stale_if_error_ns = static_cast(seconds * 1e9);
+
+ args.rval().setUndefined();
+ return true;
+}
+
bool Response::vary_set(JSContext *cx, unsigned argc, JS::Value *vp) {
METHOD_HEADER(1)
@@ -4423,6 +4508,27 @@ bool Response::pci_set(JSContext *cx, unsigned argc, JS::Value *vp) {
return true;
}
+bool Response::staleIfErrorAvailable(JSContext *cx, unsigned argc, JS::Value *vp) {
+ METHOD_HEADER(0)
+
+ // This method is only valid on candidate responses (inside afterSend callback)
+ auto cache_entry = RequestOrResponse::cache_entry(self);
+ if (!cache_entry) {
+ // Cache entry has been taken - this is not a candidate response
+ api::throw_error(cx, api::Errors::TypeError, "Response", "staleIfErrorAvailable()",
+ "can only be called on candidate responses inside afterSend callback");
+ return false;
+ }
+
+ auto res = cache_entry->get_state();
+ if (auto *err = res.to_err()) {
+ HANDLE_ERROR(cx, *err);
+ return false;
+ }
+
+ args.rval().setBoolean(res.unwrap().is_usable_if_error());
+ return true;
+}
/**
* The `Response` constructor https://fetch.spec.whatwg.org/#dom-response
*/
diff --git a/runtime/fastly/builtins/fetch/request-response.h b/runtime/fastly/builtins/fetch/request-response.h
index 6292ff963e..0e08a8801a 100644
--- a/runtime/fastly/builtins/fetch/request-response.h
+++ b/runtime/fastly/builtins/fetch/request-response.h
@@ -278,6 +278,7 @@ class Response final : public builtins::FinalizableBuiltinImpl {
SuggestedCacheWriteOptions,
OverrideCacheWriteOptions,
CacheBodyTransform,
+ MaskedError, // An error that occured, but was masked by a cached stale-if-error response
Count,
};
static const JSFunctionSpec static_methods[];
@@ -355,14 +356,18 @@ class Response final : public builtins::FinalizableBuiltinImpl {
static bool ttl_get(JSContext *cx, unsigned argc, JS::Value *vp);
static bool ttl_set(JSContext *cx, unsigned argc, JS::Value *vp);
static bool age_get(JSContext *cx, unsigned argc, JS::Value *vp);
- static bool swr_get(JSContext *cx, unsigned argc, JS::Value *vp);
- static bool swr_set(JSContext *cx, unsigned argc, JS::Value *vp);
+ static bool staleWhileRevalidate_get(JSContext *cx, unsigned argc, JS::Value *vp);
+ static bool staleWhileRevalidate_set(JSContext *cx, unsigned argc, JS::Value *vp);
+ static bool staleIfError_get(JSContext *cx, unsigned argc, JS::Value *vp);
+ static bool staleIfError_set(JSContext *cx, unsigned argc, JS::Value *vp);
static bool vary_get(JSContext *cx, unsigned argc, JS::Value *vp);
static bool vary_set(JSContext *cx, unsigned argc, JS::Value *vp);
static bool surrogateKeys_get(JSContext *cx, unsigned argc, JS::Value *vp);
static bool surrogateKeys_set(JSContext *cx, unsigned argc, JS::Value *vp);
static bool pci_get(JSContext *cx, unsigned argc, JS::Value *vp);
static bool pci_set(JSContext *cx, unsigned argc, JS::Value *vp);
+ static bool staleIfErrorAvailable(JSContext *cx, unsigned argc, JS::Value *vp);
+ static bool maskedError_get(JSContext *cx, unsigned argc, JS::Value *vp);
static void finalize(JS::GCContext *gcx, JSObject *self);
};
diff --git a/runtime/fastly/host-api/fastly.h b/runtime/fastly/host-api/fastly.h
index 5dd752ff02..a1b9497c21 100644
--- a/runtime/fastly/host-api/fastly.h
+++ b/runtime/fastly/host-api/fastly.h
@@ -359,6 +359,7 @@ typedef struct __attribute__((aligned(8))) fastly_http_cache_write_options {
const char *surrogate_keys;
size_t surrogate_keys_len;
uint64_t length;
+ uint64_t stale_if_error_ns;
} fastly_http_cache_write_options;
// HTTP Cache write options mask
@@ -369,6 +370,7 @@ typedef struct __attribute__((aligned(8))) fastly_http_cache_write_options {
#define FASTLY_HTTP_CACHE_WRITE_OPTIONS_MASK_SURROGATE_KEYS (1 << 4)
#define FASTLY_HTTP_CACHE_WRITE_OPTIONS_MASK_LENGTH (1 << 5)
#define FASTLY_HTTP_CACHE_WRITE_OPTIONS_MASK_SENSITIVE_DATA (1 << 6)
+#define FASTLY_HTTP_CACHE_WRITE_OPTIONS_MASK_STALE_IF_ERROR_NS (1 << 7)
// HTTP Cache host calls
WASM_IMPORT("fastly_http_cache", "is_request_cacheable")
@@ -404,6 +406,9 @@ int http_cache_transaction_update_and_return_fresh(uint32_t handle, uint32_t res
fastly_http_cache_write_options *options,
uint32_t *fresh_handle_out);
+WASM_IMPORT("fastly_http_cache", "transaction_choose_stale")
+int http_cache_transaction_choose_stale(uint32_t handle);
+
WASM_IMPORT("fastly_http_cache", "transaction_record_not_cacheable")
int http_cache_transaction_record_not_cacheable(uint32_t handle, uint32_t options_mask,
fastly_http_cache_write_options *options);
@@ -445,6 +450,9 @@ int http_cache_get_max_age_ns(uint32_t handle, uint64_t *max_age_ns_out);
WASM_IMPORT("fastly_http_cache", "get_stale_while_revalidate_ns")
int http_cache_get_stale_while_revalidate_ns(uint32_t handle, uint64_t *swr_ns_out);
+WASM_IMPORT("fastly_http_cache", "get_stale_if_error_ns")
+int http_cache_get_stale_if_error_ns(uint32_t handle, uint64_t *sie_ns_out);
+
WASM_IMPORT("fastly_http_cache", "get_age_ns")
int http_cache_get_age_ns(uint32_t handle, uint64_t *age_ns_out);
@@ -961,6 +969,7 @@ typedef struct fastly_host_cache_write_options {
uint64_t length;
fastly_world_list_u8 user_metadata;
bool sensitive_data;
+ uint64_t stale_if_error_ns;
} fastly_host_cache_write_options;
// a cached object was found
@@ -971,6 +980,8 @@ typedef struct fastly_host_cache_write_options {
#define FASTLY_HOST_CACHE_LOOKUP_STATE_STALE (1 << 2)
// this client is requested to insert or revalidate an object
#define FASTLY_HOST_CACHE_LOOKUP_STATE_MUST_INSERT_OR_UPDATE (1 << 3)
+// a cached object was found and it is only usable if synchronous revalidation fails
+#define FASTLY_HOST_CACHE_LOOKUP_STATE_USABLE_IF_ERROR (1 << 4)
WASM_IMPORT("fastly_cache", "lookup")
int cache_lookup(char *cache_key, size_t cache_key_len, uint32_t options_mask,
diff --git a/runtime/fastly/host-api/host_api.cpp b/runtime/fastly/host-api/host_api.cpp
index e80aed88bc..91e2ec9796 100644
--- a/runtime/fastly/host-api/host_api.cpp
+++ b/runtime/fastly/host-api/host_api.cpp
@@ -2245,6 +2245,11 @@ FastlyCacheWriteOptionsOwned to_fastly_cache_write_options(const HttpCacheWriteO
result.mask |= FASTLY_HTTP_CACHE_WRITE_OPTIONS_MASK_STALE_WHILE_REVALIDATE_NS;
}
+ if (opts->stale_if_error_ns) {
+ result.options->stale_if_error_ns = *opts->stale_if_error_ns;
+ result.mask |= FASTLY_HTTP_CACHE_WRITE_OPTIONS_MASK_STALE_IF_ERROR_NS;
+ }
+
if (opts->surrogate_keys.has_value()) {
const auto &keys = opts->surrogate_keys.value();
if (keys.size() == 1) {
@@ -2315,6 +2320,10 @@ from_fastly_cache_write_options(const fastly::fastly_http_cache_write_options &f
opts->stale_while_revalidate_ns = fastly_opts.stale_while_revalidate_ns;
}
+ if (mask & FASTLY_HTTP_CACHE_WRITE_OPTIONS_MASK_STALE_IF_ERROR_NS) {
+ opts->stale_if_error_ns = fastly_opts.stale_if_error_ns;
+ }
+
if (mask & FASTLY_HTTP_CACHE_WRITE_OPTIONS_MASK_SURROGATE_KEYS && fastly_opts.surrogate_keys &&
fastly_opts.surrogate_keys_len > 0) {
opts->surrogate_keys.emplace();
@@ -2437,7 +2446,8 @@ Result Request::inspect(const InspectOptions *config) {
Result HttpCacheEntry::transaction_lookup(const HttpReq &req,
std::span override_key) {
- TRACE_CALL_ARGS(TSV(std::to_string(req.handle)))
+ TRACE_CALL_ARGS(TSV(std::to_string(req.handle)));
+
uint32_t handle_out;
fastly::fastly_http_cache_lookup_options opts{};
uint32_t opts_mask = 0;
@@ -2522,6 +2532,16 @@ HttpCacheEntry::transaction_update_and_return_fresh(const HttpResp &resp,
return Result::ok(HttpCacheEntry(fresh_handle_out));
}
+Result HttpCacheEntry::transaction_choose_stale() {
+ TRACE_CALL()
+ auto res = fastly::http_cache_transaction_choose_stale(this->handle);
+ if (res != 0) {
+ return Result::err(host_api::APIError(res));
+ }
+
+ return Result::ok(Void{});
+}
+
Result
HttpCacheEntry::transaction_record_not_cacheable(uint64_t max_age_ns,
std::optional vary_rule) {
@@ -2584,7 +2604,8 @@ HttpCacheEntry::get_suggested_cache_options(const HttpResp &resp) const {
FASTLY_HTTP_CACHE_WRITE_OPTIONS_MASK_STALE_WHILE_REVALIDATE_NS |
FASTLY_HTTP_CACHE_WRITE_OPTIONS_MASK_SURROGATE_KEYS |
FASTLY_HTTP_CACHE_WRITE_OPTIONS_MASK_LENGTH |
- FASTLY_HTTP_CACHE_WRITE_OPTIONS_MASK_SENSITIVE_DATA;
+ FASTLY_HTTP_CACHE_WRITE_OPTIONS_MASK_SENSITIVE_DATA |
+ FASTLY_HTTP_CACHE_WRITE_OPTIONS_MASK_STALE_IF_ERROR_NS;
// Allocate initial buffers
uint8_t *vary_buffer = static_cast(cabi_malloc(HOSTCALL_BUFFER_LEN, 4));
@@ -2669,6 +2690,7 @@ Result HttpCacheEntry::get_state() const {
if (!convert_result(fastly::http_cache_get_state(this->handle, &state), &err)) {
res.emplace_err(err);
} else {
+ TRACE_CALL_RET(TSV(std::to_string(state)))
res.emplace(CacheState{state});
}
@@ -2714,6 +2736,18 @@ Result HttpCacheEntry::get_stale_while_revalidate_ns() const {
return Result::ok(swr_out);
}
+Result HttpCacheEntry::get_stale_if_error_ns() const {
+ TRACE_CALL()
+ uint64_t sie_out;
+ auto res = fastly::http_cache_get_stale_if_error_ns(this->handle, &sie_out);
+
+ if (res != 0) {
+ return Result::err(host_api::APIError(res));
+ }
+
+ return Result::ok(sie_out);
+}
+
Result HttpCacheEntry::get_age_ns() const {
TRACE_CALL()
uint64_t age_out;
@@ -3267,6 +3301,10 @@ bool CacheState::must_insert_or_update() const {
return this->state & FASTLY_HOST_CACHE_LOOKUP_STATE_MUST_INSERT_OR_UPDATE;
}
+bool CacheState::is_usable_if_error() const {
+ return this->state & FASTLY_HOST_CACHE_LOOKUP_STATE_USABLE_IF_ERROR;
+}
+
Result CacheHandle::lookup(std::string_view key, const CacheLookupOptions &opts) {
TRACE_CALL()
Result res;
diff --git a/runtime/fastly/host-api/host_api_fastly.h b/runtime/fastly/host-api/host_api_fastly.h
index 16c92c9033..cc55140424 100644
--- a/runtime/fastly/host-api/host_api_fastly.h
+++ b/runtime/fastly/host-api/host_api_fastly.h
@@ -764,6 +764,9 @@ struct HttpCacheWriteOptions final {
// Optional flag indicating if this contains sensitive data
std::optional sensitive_data;
+
+ // Optional stale-if-error duration in nanoseconds
+ std::optional stale_if_error_ns;
};
struct CacheState final {
@@ -776,6 +779,7 @@ struct CacheState final {
bool is_usable() const;
bool is_stale() const;
bool must_insert_or_update() const;
+ bool is_usable_if_error() const;
};
enum class HttpStorageAction : uint8_t {
@@ -820,6 +824,9 @@ class HttpCacheEntry final {
transaction_record_not_cacheable(uint64_t max_age_ns,
std::optional vary_rule = std::nullopt);
+ /// Substitute stale-if-error response
+ Result transaction_choose_stale();
+
/// Abandon the transaction
Result transaction_abandon();
@@ -850,6 +857,9 @@ class HttpCacheEntry final {
/// Get stale while revalidate time in nanoseconds
Result get_stale_while_revalidate_ns() const;
+ /// Get stale if error time in nanoseconds
+ Result get_stale_if_error_ns() const;
+
/// Get age in nanoseconds
Result get_age_ns() const;