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;