From e47fd2760d95bd823afe44e570fae02ff57dd60b Mon Sep 17 00:00:00 2001 From: Sy Brand Date: Mon, 12 Jan 2026 16:29:00 +0000 Subject: [PATCH 01/36] Initial work --- runtime/fastly/builtins/cache-override.cpp | 80 +++++++++++++++++----- runtime/fastly/builtins/cache-override.h | 18 +++-- runtime/fastly/host-api/fastly.h | 17 +++++ runtime/fastly/host-api/host_api.cpp | 4 ++ 4 files changed, 97 insertions(+), 22 deletions(-) diff --git a/runtime/fastly/builtins/cache-override.cpp b/runtime/fastly/builtins/cache-override.cpp index c371de0d82..abc1bbab04 100644 --- a/runtime/fastly/builtins/cache-override.cpp +++ b/runtime/fastly/builtins/cache-override.cpp @@ -65,17 +65,30 @@ 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 +162,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 +253,61 @@ 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, "staleWhileRevalidate get", "CacheOverride"); + } + rval.set(staleWhileRevalidate(self)); + return true; +} + +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, "swr get", "CacheOverride"); + return api::throw_error(cx, api::Errors::WrongReceiver, "staleIfError get", "CacheOverride"); } - rval.set(swr(self)); + rval.set(staleIfError(self)); return true; } -bool CacheOverride::swr_set(JSContext *cx, JS::HandleObject self, JS::HandleValue val, +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 +458,8 @@ 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 +467,7 @@ 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 +495,11 @@ 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) || !JS_GetProperty(cx, override_obj, "staleWhileRevalidate", &val) || !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..f7cbd43e92 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,7 @@ 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 +41,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,8 +62,11 @@ 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, + 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, diff --git a/runtime/fastly/host-api/fastly.h b/runtime/fastly/host-api/fastly.h index 0ba3f0e51a..0ea12a832c 100644 --- a/runtime/fastly/host-api/fastly.h +++ b/runtime/fastly/host-api/fastly.h @@ -348,6 +348,8 @@ typedef struct __attribute__((aligned(4))) fastly_http_cache_lookup_options { // HTTP Cache lookup options mask #define FASTLY_HTTP_CACHE_LOOKUP_OPTIONS_MASK_RESERVED (1 << 0) #define FASTLY_HTTP_CACHE_LOOKUP_OPTIONS_MASK_OVERRIDE_KEY (1 << 1) +#define FASTLY_HTTP_CACHE_LOOKUP_OPTIONS_MASK_BACKEND_NAME (1 << 2) +#define FASTLY_HTTP_CACHE_LOOKUP_OPTIONS_MASK_ACCEPT_STALE_IF_ERROR (1 << 3) // HTTP Cache write options typedef struct __attribute__((aligned(8))) fastly_http_cache_write_options { @@ -359,6 +361,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 @@ -415,6 +418,9 @@ int http_cache_transaction_record_not_cacheable(uint32_t handle, uint32_t option WASM_IMPORT("fastly_http_cache", "transaction_abandon") int http_cache_transaction_abandon(uint32_t handle); +WASM_IMPORT("fastly_http_cache", "transaction_broadcast_cancel") +int http_cache_transaction_broadcast_cancel(uint32_t handle); + WASM_IMPORT("fastly_http_cache", "close") int http_cache_close(uint32_t handle); @@ -437,6 +443,10 @@ WASM_IMPORT("fastly_http_cache", "get_found_response") int http_cache_get_found_response(uint32_t handle, uint32_t transform_for_client, uint32_t *resp_handle_out, uint32_t *body_handle_out); +WASM_IMPORT("fastly_http_cache", "get_any_response") +int http_cache_get_any_response(uint32_t handle, uint32_t transform_for_client, + uint32_t *resp_handle_out, uint32_t *body_handle_out); + WASM_IMPORT("fastly_http_cache", "get_state") int http_cache_get_state(uint32_t handle, uint8_t *state_out); @@ -449,6 +459,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); @@ -945,6 +958,10 @@ 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) +// in another client, a synchronous revalidation has failed for this object +#define FASTLY_HOST_CACHE_LOOKUP_STATE_COLLAPSE_ERROR (1 << 5) 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 00a56fc6ab..b1be6ec11d 100644 --- a/runtime/fastly/host-api/host_api.cpp +++ b/runtime/fastly/host-api/host_api.cpp @@ -1193,6 +1193,10 @@ void CacheOverrideTag::set_stale_while_revalidate() { this->value |= CACHE_OVERRIDE_STALE_WHILE_REVALIDATE; } +void CacheOverrideTag::set_stale_if_error() { + this->value |= CACHE_OVERRIDE_STALE_IF_ERROR; +} + void CacheOverrideTag::set_pci() { this->value |= CACHE_OVERRIDE_PCI; } TlsVersion::TlsVersion(uint8_t raw) : value{raw} { From 6809d57df3278c433fc306e9c71963cf0f7c9cde Mon Sep 17 00:00:00 2001 From: Sy Brand Date: Thu, 12 Feb 2026 11:34:40 +0000 Subject: [PATCH 02/36] Matching Rust (partial) --- .../builtins/fetch/request-response.cpp | 9 ++++- runtime/fastly/host-api/fastly.h | 8 +++- runtime/fastly/host-api/host_api.cpp | 39 ++++++++++++++++++- runtime/fastly/host-api/host_api_fastly.h | 11 ++++++ 4 files changed, 63 insertions(+), 4 deletions(-) diff --git a/runtime/fastly/builtins/fetch/request-response.cpp b/runtime/fastly/builtins/fetch/request-response.cpp index 0a21fa0766..d66c7315f5 100644 --- a/runtime/fastly/builtins/fetch/request-response.cpp +++ b/runtime/fastly/builtins/fetch/request-response.cpp @@ -575,12 +575,19 @@ 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)); diff --git a/runtime/fastly/host-api/fastly.h b/runtime/fastly/host-api/fastly.h index 0ea12a832c..b0ab146d35 100644 --- a/runtime/fastly/host-api/fastly.h +++ b/runtime/fastly/host-api/fastly.h @@ -372,6 +372,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") @@ -411,6 +412,10 @@ 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_record_choose_stale") +int http_cache_transaction_record_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); @@ -948,6 +953,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 @@ -960,8 +966,6 @@ typedef struct fastly_host_cache_write_options { #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) -// in another client, a synchronous revalidation has failed for this object -#define FASTLY_HOST_CACHE_LOOKUP_STATE_COLLAPSE_ERROR (1 << 5) 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 b1be6ec11d..de51f250cc 100644 --- a/runtime/fastly/host-api/host_api.cpp +++ b/runtime/fastly/host-api/host_api.cpp @@ -2132,6 +2132,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) { @@ -2202,6 +2207,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(); @@ -2418,6 +2427,17 @@ HttpCacheEntry::transaction_update_and_return_fresh(const HttpResp &resp, return Result::ok(HttpCacheEntry(fresh_handle_out)); } +Result +HttpCacheEntry::transaction_record_choose_stale() { + TRACE_CALL() + auto res = fastly::http_cache_transaction_record_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) { @@ -2480,7 +2500,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)); @@ -2610,6 +2631,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; @@ -3163,6 +3196,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 113defcbd3..586cd6554b 100644 --- a/runtime/fastly/host-api/host_api_fastly.h +++ b/runtime/fastly/host-api/host_api_fastly.h @@ -742,6 +742,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 { @@ -754,6 +757,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 { @@ -801,6 +805,10 @@ 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_record_choose_stale(); + /// Abandon the transaction Result transaction_abandon(); @@ -831,6 +839,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; From 2938bb871175939624cbfedd8a8c320310c4a891 Mon Sep 17 00:00:00 2001 From: Sy Brand Date: Thu, 12 Feb 2026 13:54:29 +0000 Subject: [PATCH 03/36] Finish draft implementation --- runtime/fastly/builtins/fetch/fetch.cpp | 70 ++++++++++++--- .../builtins/fetch/request-response.cpp | 90 +++++++++++++++++-- .../fastly/builtins/fetch/request-response.h | 8 +- runtime/fastly/host-api/fastly.h | 9 -- 4 files changed, 149 insertions(+), 28 deletions(-) diff --git a/runtime/fastly/builtins/fetch/fetch.cpp b/runtime/fastly/builtins/fetch/fetch.cpp index f15468eaae..33e56842de 100644 --- a/runtime/fastly/builtins/fetch/fetch.cpp +++ b/runtime/fastly/builtins/fetch/fetch.cpp @@ -1247,6 +1247,18 @@ bool fetch(JSContext *cx, unsigned argc, Value *vp) { return false; } } + + 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); @@ -1264,19 +1276,45 @@ 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)) { - RequestOrResponse::close_if_cache_entry(cx, request); - return false; + if (cache_state.is_usable_if_error()) { + // We've got a usable error substitute, so swap it out for the error and notify any + // request collapse trailers. + auto chose_stale_res = cache_entry.transaction_record_choose_stale(); + if (auto *err = chose_stale_res.to_err()) { + HANDLE_ERROR(cx, *err); + return false; + } + auto maybe_response = get_found_response(cx, cache_entry, request, no_candidate, false); + if (maybe_response.has_value() && !maybe_response.value()) { + return false; + } + JS::RootedValue exception(cx); + if (!JS_GetPendingException(cx, &exception)) { + return false; + } + JS_ClearPendingException(cx); + JS::RootedObject cached_response(cx, maybe_response.value()); + JS_SetReservedSlot(cached_response, static_cast(Response::Slots::MaskedError), exception); + + RequestOrResponse::take_cache_entry(cached_response, true); + if (!Response::add_fastly_cache_headers(cx, cached_response, request, cache_entry, + "cached response")) { + return false; + } + + 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); + } + else { + RequestOrResponse::close_if_cache_entry(cx, request); + return false; + } } JS::RootedObject stream_back_promise_obj(cx, &stream_back_promise.toObject()); JS::RootedObject ret_promise( @@ -1289,6 +1327,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/request-response.cpp b/runtime/fastly/builtins/fetch/request-response.cpp index d66c7315f5..4e4e967223 100644 --- a/runtime/fastly/builtins/fetch/request-response.cpp +++ b/runtime/fastly/builtins/fetch/request-response.cpp @@ -435,6 +435,10 @@ 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); } @@ -2102,7 +2106,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(); } @@ -3750,7 +3754,9 @@ 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), @@ -3889,7 +3895,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); @@ -3918,6 +3924,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 undefiend 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) @@ -4119,12 +4154,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; } @@ -4136,7 +4171,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; } @@ -4147,6 +4182,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) @@ -4306,6 +4369,21 @@ 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) + + auto cache_entry = RequestOrResponse::cache_entry(self); + if (!cache_entry) { + args.rval().setBoolean(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 9e30b2f9f4..289cb209d9 100644 --- a/runtime/fastly/builtins/fetch/request-response.h +++ b/runtime/fastly/builtins/fetch/request-response.h @@ -272,6 +272,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[]; @@ -348,14 +349,17 @@ 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 void finalize(JS::GCContext *gcx, JSObject *self); }; diff --git a/runtime/fastly/host-api/fastly.h b/runtime/fastly/host-api/fastly.h index b0ab146d35..a0a979b923 100644 --- a/runtime/fastly/host-api/fastly.h +++ b/runtime/fastly/host-api/fastly.h @@ -348,8 +348,6 @@ typedef struct __attribute__((aligned(4))) fastly_http_cache_lookup_options { // HTTP Cache lookup options mask #define FASTLY_HTTP_CACHE_LOOKUP_OPTIONS_MASK_RESERVED (1 << 0) #define FASTLY_HTTP_CACHE_LOOKUP_OPTIONS_MASK_OVERRIDE_KEY (1 << 1) -#define FASTLY_HTTP_CACHE_LOOKUP_OPTIONS_MASK_BACKEND_NAME (1 << 2) -#define FASTLY_HTTP_CACHE_LOOKUP_OPTIONS_MASK_ACCEPT_STALE_IF_ERROR (1 << 3) // HTTP Cache write options typedef struct __attribute__((aligned(8))) fastly_http_cache_write_options { @@ -423,9 +421,6 @@ int http_cache_transaction_record_not_cacheable(uint32_t handle, uint32_t option WASM_IMPORT("fastly_http_cache", "transaction_abandon") int http_cache_transaction_abandon(uint32_t handle); -WASM_IMPORT("fastly_http_cache", "transaction_broadcast_cancel") -int http_cache_transaction_broadcast_cancel(uint32_t handle); - WASM_IMPORT("fastly_http_cache", "close") int http_cache_close(uint32_t handle); @@ -448,10 +443,6 @@ WASM_IMPORT("fastly_http_cache", "get_found_response") int http_cache_get_found_response(uint32_t handle, uint32_t transform_for_client, uint32_t *resp_handle_out, uint32_t *body_handle_out); -WASM_IMPORT("fastly_http_cache", "get_any_response") -int http_cache_get_any_response(uint32_t handle, uint32_t transform_for_client, - uint32_t *resp_handle_out, uint32_t *body_handle_out); - WASM_IMPORT("fastly_http_cache", "get_state") int http_cache_get_state(uint32_t handle, uint8_t *state_out); From 7be7b94ab1f72780e2f4c04ac9dc13a2e609c6cf Mon Sep 17 00:00:00 2001 From: Sy Brand Date: Thu, 12 Feb 2026 14:02:45 +0000 Subject: [PATCH 04/36] Don't overwrite cache override --- runtime/fastly/builtins/cache-override.cpp | 11 ++++++++++- 1 file changed, 10 insertions(+), 1 deletion(-) diff --git a/runtime/fastly/builtins/cache-override.cpp b/runtime/fastly/builtins/cache-override.cpp index abc1bbab04..c93d14bafe 100644 --- a/runtime/fastly/builtins/cache-override.cpp +++ b/runtime/fastly/builtins/cache-override.cpp @@ -495,10 +495,19 @@ JSObject *CacheOverride::create(JSContext *cx, JS::HandleValue override) { return nullptr; } - if (!JS_GetProperty(cx, override_obj, "swr", &val) || !JS_GetProperty(cx, override_obj, "staleWhileRevalidate", &val) || !staleWhileRevalidate_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; } From 23917acd24e58e1c8959f534d3272cb4260cd053 Mon Sep 17 00:00:00 2001 From: Sy Brand Date: Thu, 12 Feb 2026 14:14:00 +0000 Subject: [PATCH 05/36] Compile clean --- runtime/fastly/host-api/host_api.cpp | 4 ---- 1 file changed, 4 deletions(-) diff --git a/runtime/fastly/host-api/host_api.cpp b/runtime/fastly/host-api/host_api.cpp index 061b48c85b..e73dbb782e 100644 --- a/runtime/fastly/host-api/host_api.cpp +++ b/runtime/fastly/host-api/host_api.cpp @@ -1216,10 +1216,6 @@ void CacheOverrideTag::set_stale_while_revalidate() { this->value |= CACHE_OVERRIDE_STALE_WHILE_REVALIDATE; } -void CacheOverrideTag::set_stale_if_error() { - this->value |= CACHE_OVERRIDE_STALE_IF_ERROR; -} - void CacheOverrideTag::set_pci() { this->value |= CACHE_OVERRIDE_PCI; } TlsVersion::TlsVersion(uint8_t raw) : value{raw} { From dbf57294ef76ffb991963751d6869bf308b0f86e Mon Sep 17 00:00:00 2001 From: Sy Brand Date: Thu, 12 Feb 2026 14:55:42 +0000 Subject: [PATCH 06/36] Format --- runtime/fastly/builtins/cache-override.cpp | 36 ++++++++++++------- runtime/fastly/builtins/cache-override.h | 21 ++++++++--- runtime/fastly/builtins/fetch/fetch.cpp | 15 ++++---- .../builtins/fetch/request-response.cpp | 20 ++++++++--- .../fastly/builtins/fetch/request-response.h | 1 + runtime/fastly/host-api/fastly.h | 1 - runtime/fastly/host-api/host_api.cpp | 5 ++- runtime/fastly/host-api/host_api_fastly.h | 5 ++- 8 files changed, 68 insertions(+), 36 deletions(-) diff --git a/runtime/fastly/builtins/cache-override.cpp b/runtime/fastly/builtins/cache-override.cpp index c93d14bafe..9457741835 100644 --- a/runtime/fastly/builtins/cache-override.cpp +++ b/runtime/fastly/builtins/cache-override.cpp @@ -75,7 +75,8 @@ JS::Value CacheOverride::staleWhileRevalidate(JSObject *self) { 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::StaleWhileRevalidate, JS::Int32Value((int32_t)swr)); + JS::SetReservedSlot(self, CacheOverride::Slots::StaleWhileRevalidate, + JS::Int32Value((int32_t)swr)); } JS::Value CacheOverride::staleIfError(JSObject *self) { @@ -253,18 +254,21 @@ bool CacheOverride::ttl_set(JSContext *cx, JS::HandleObject self, JS::HandleValu return true; } -bool CacheOverride::staleWhileRevalidate_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, "staleWhileRevalidate get", "CacheOverride"); + return api::throw_error(cx, api::Errors::WrongReceiver, "staleWhileRevalidate get", + "CacheOverride"); } rval.set(staleWhileRevalidate(self)); return true; } -bool CacheOverride::staleWhileRevalidate_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"); + return api::throw_error(cx, api::Errors::WrongReceiver, "staleWhileRevalidate set", + "CacheOverride"); } if (!ensure_override(cx, self, "staleWhileRevalidate")) return false; @@ -282,7 +286,8 @@ bool CacheOverride::staleWhileRevalidate_set(JSContext *cx, JS::HandleObject sel return true; } -bool CacheOverride::staleIfError_get(JSContext *cx, JS::HandleObject self, JS::MutableHandleValue rval) { +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"); } @@ -291,7 +296,7 @@ bool CacheOverride::staleIfError_get(JSContext *cx, JS::HandleObject self, JS::M } bool CacheOverride::staleIfError_set(JSContext *cx, JS::HandleObject self, JS::HandleValue val, - JS::MutableHandleValue rval) { + JS::MutableHandleValue rval) { if (self == proto_obj) { return api::throw_error(cx, api::Errors::WrongReceiver, "staleIfError set", "CacheOverride"); } @@ -458,8 +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("staleWhileRevalidate", 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), @@ -467,7 +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_PSGS("staleIfError", accessor_get, accessor_set, + JSPROP_ENUMERATE), JS_STRING_SYM_PS(toStringTag, "CacheOverride", JSPROP_READONLY), JS_PS_END}; @@ -495,7 +503,8 @@ JSObject *CacheOverride::create(JSContext *cx, JS::HandleValue override) { return nullptr; } - if (!JS_GetProperty(cx, override_obj, "swr", &val) || !staleWhileRevalidate_set(cx, self, val, &val)) { + if (!JS_GetProperty(cx, override_obj, "swr", &val) || + !staleWhileRevalidate_set(cx, self, val, &val)) { return nullptr; } @@ -508,7 +517,8 @@ JSObject *CacheOverride::create(JSContext *cx, JS::HandleValue override) { } } - if (!JS_GetProperty(cx, override_obj, "staleIfError", &val) || !staleIfError_set(cx, self, val, &val)) { + 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 f7cbd43e92..a91f9af930 100644 --- a/runtime/fastly/builtins/cache-override.h +++ b/runtime/fastly/builtins/cache-override.h @@ -19,7 +19,7 @@ class CacheOverride : public builtins::BuiltinImpl { // // If `Mode` is `Override`, the values are interpreted in the following way: // - // If `TTL`, `StaleWhileRevalidate`, `StaleIfError`, or `SurrogateKey` are + // 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 @@ -30,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, StaleWhileRevalidate, SurrogateKey, PCI, BeforeSend, AfterSend, StaleIfError, Count }; + enum Slots { + Mode, + TTL, + StaleWhileRevalidate, + SurrogateKey, + PCI, + BeforeSend, + AfterSend, + StaleIfError, + Count + }; enum class CacheOverrideMode { None, Pass, Override }; @@ -62,12 +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 staleWhileRevalidate_get(JSContext *cx, JS::HandleObject self, 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); + 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); + 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 33e56842de..ee80dad827 100644 --- a/runtime/fastly/builtins/fetch/fetch.cpp +++ b/runtime/fastly/builtins/fetch/fetch.cpp @@ -1247,7 +1247,7 @@ bool fetch(JSContext *cx, unsigned argc, Value *vp) { return false; } } - + 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. @@ -1257,7 +1257,8 @@ bool fetch(JSContext *cx, unsigned argc, Value *vp) { return false; } JS_ClearPendingException(cx); - JS_SetReservedSlot(cached_response, static_cast(Response::Slots::MaskedError), exception); + JS_SetReservedSlot(cached_response, static_cast(Response::Slots::MaskedError), + exception); } // mark the response cache entry as cached for the cached getter @@ -1281,7 +1282,7 @@ bool fetch(JSContext *cx, unsigned argc, Value *vp) { 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)) { if (cache_state.is_usable_if_error()) { - // We've got a usable error substitute, so swap it out for the error and notify any + // We've got a usable error substitute, so swap it out for the error and notify any // request collapse trailers. auto chose_stale_res = cache_entry.transaction_record_choose_stale(); if (auto *err = chose_stale_res.to_err()) { @@ -1298,11 +1299,12 @@ bool fetch(JSContext *cx, unsigned argc, Value *vp) { } JS_ClearPendingException(cx); JS::RootedObject cached_response(cx, maybe_response.value()); - JS_SetReservedSlot(cached_response, static_cast(Response::Slots::MaskedError), exception); + JS_SetReservedSlot(cached_response, static_cast(Response::Slots::MaskedError), + exception); RequestOrResponse::take_cache_entry(cached_response, true); if (!Response::add_fastly_cache_headers(cx, cached_response, request, cache_entry, - "cached response")) { + "cached response")) { return false; } @@ -1310,8 +1312,7 @@ bool fetch(JSContext *cx, unsigned argc, Value *vp) { JS::RootedValue response_val(cx, JS::ObjectValue(*cached_response)); args.rval().setObject(*response_promise); return JS::ResolvePromise(cx, response_promise, response_val); - } - else { + } else { RequestOrResponse::close_if_cache_entry(cx, request); return false; } diff --git a/runtime/fastly/builtins/fetch/request-response.cpp b/runtime/fastly/builtins/fetch/request-response.cpp index c615aa9530..59be01c1a1 100644 --- a/runtime/fastly/builtins/fetch/request-response.cpp +++ b/runtime/fastly/builtins/fetch/request-response.cpp @@ -539,8 +539,7 @@ bool after_send_then(JSContext *cx, JS::HandleObject response, JS::HandleValue p 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; + 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); @@ -694,7 +693,6 @@ bool RequestOrResponse::process_pending_request(JSContext *cx, 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)); @@ -3846,11 +3844,13 @@ const JSPropertySpec Response::properties[] = { JS_PSGS("ttl", ttl_get, ttl_set, JSPROP_ENUMERATE), JS_PSG("age", age_get, JSPROP_ENUMERATE), JS_PSGS("swr", staleWhileRevalidate_get, staleWhileRevalidate_set, JSPROP_ENUMERATE), - JS_PSGS("staleWhileRevalidate", 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, }; @@ -4207,6 +4207,18 @@ 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) + + JS::RootedValue masked_error_val(cx); + if (!JS_GetReservedSlot(cx, self, static_cast(Slots::MaskedError), &masked_error_val)) { + return false; + } + + args.rval().set(masked_error_val); + return true; +} + // Setters for mutable properties bool Response::ttl_set(JSContext *cx, unsigned argc, JS::Value *vp) { diff --git a/runtime/fastly/builtins/fetch/request-response.h b/runtime/fastly/builtins/fetch/request-response.h index bd845fdb3c..a713a1b522 100644 --- a/runtime/fastly/builtins/fetch/request-response.h +++ b/runtime/fastly/builtins/fetch/request-response.h @@ -363,6 +363,7 @@ class Response final : public builtins::FinalizableBuiltinImpl { 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 bb9849f150..61aac4e1b5 100644 --- a/runtime/fastly/host-api/fastly.h +++ b/runtime/fastly/host-api/fastly.h @@ -406,7 +406,6 @@ 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_record_choose_stale") int http_cache_transaction_record_choose_stale(uint32_t handle); diff --git a/runtime/fastly/host-api/host_api.cpp b/runtime/fastly/host-api/host_api.cpp index e73dbb782e..9a0dc8771b 100644 --- a/runtime/fastly/host-api/host_api.cpp +++ b/runtime/fastly/host-api/host_api.cpp @@ -2226,7 +2226,7 @@ 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) { + if (mask & FASTLY_HTTP_CACHE_WRITE_OPTIONS_MASK_STALE_IF_ERROR_NS) { opts->stale_if_error_ns = fastly_opts.stale_if_error_ns; } @@ -2422,8 +2422,7 @@ HttpCacheEntry::transaction_update_and_return_fresh(const HttpResp &resp, return Result::ok(HttpCacheEntry(fresh_handle_out)); } -Result -HttpCacheEntry::transaction_record_choose_stale() { +Result HttpCacheEntry::transaction_record_choose_stale() { TRACE_CALL() auto res = fastly::http_cache_transaction_record_choose_stale(this->handle); if (res != 0) { diff --git a/runtime/fastly/host-api/host_api_fastly.h b/runtime/fastly/host-api/host_api_fastly.h index 081d9d438e..eed7952894 100644 --- a/runtime/fastly/host-api/host_api_fastly.h +++ b/runtime/fastly/host-api/host_api_fastly.h @@ -376,7 +376,7 @@ struct TlsVersion { uint8_t value = 0; explicit TlsVersion(uint8_t raw); - explicit TlsVersion(){}; + explicit TlsVersion() {}; uint8_t get_version() const; double get_version_number() const; @@ -805,8 +805,7 @@ class HttpCacheEntry final { std::optional vary_rule = std::nullopt); /// Substitute stale-if-error response - Result - transaction_record_choose_stale(); + Result transaction_record_choose_stale(); /// Abandon the transaction Result transaction_abandon(); From f66c0ce0bc4975197d9a85c0a1be1067d1a9d4c0 Mon Sep 17 00:00:00 2001 From: Sy Brand Date: Thu, 12 Feb 2026 15:00:08 +0000 Subject: [PATCH 07/36] Compile --- runtime/fastly/builtins/fetch/request-response.cpp | 8 +------- 1 file changed, 1 insertion(+), 7 deletions(-) diff --git a/runtime/fastly/builtins/fetch/request-response.cpp b/runtime/fastly/builtins/fetch/request-response.cpp index 59be01c1a1..18d537ac7e 100644 --- a/runtime/fastly/builtins/fetch/request-response.cpp +++ b/runtime/fastly/builtins/fetch/request-response.cpp @@ -4209,13 +4209,7 @@ bool Response::pci_get(JSContext *cx, unsigned argc, JS::Value *vp) { bool Response::maskedError_get(JSContext *cx, unsigned argc, JS::Value *vp) { METHOD_HEADER(0) - - JS::RootedValue masked_error_val(cx); - if (!JS_GetReservedSlot(cx, self, static_cast(Slots::MaskedError), &masked_error_val)) { - return false; - } - - args.rval().set(masked_error_val); + args.rval().set(JS::GetReservedSlot(self, static_cast(Slots::MaskedError))); return true; } From e552d72f353b684c49ada3156f79d0dd210bfb81 Mon Sep 17 00:00:00 2001 From: Sy Brand Date: Thu, 12 Feb 2026 15:02:14 +0000 Subject: [PATCH 08/36] Fmt --- runtime/fastly/host-api/host_api_fastly.h | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/runtime/fastly/host-api/host_api_fastly.h b/runtime/fastly/host-api/host_api_fastly.h index eed7952894..8ae089d2dc 100644 --- a/runtime/fastly/host-api/host_api_fastly.h +++ b/runtime/fastly/host-api/host_api_fastly.h @@ -376,7 +376,7 @@ struct TlsVersion { uint8_t value = 0; explicit TlsVersion(uint8_t raw); - explicit TlsVersion() {}; + explicit TlsVersion(){}; uint8_t get_version() const; double get_version_number() const; From 439b2ceb7f633d879bac01d751f950b43c4e309a Mon Sep 17 00:00:00 2001 From: Sy Brand Date: Thu, 12 Feb 2026 16:58:03 +0000 Subject: [PATCH 09/36] Update runtime/fastly/builtins/fetch/fetch.cpp Co-authored-by: Charles Eckman --- runtime/fastly/builtins/fetch/fetch.cpp | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/runtime/fastly/builtins/fetch/fetch.cpp b/runtime/fastly/builtins/fetch/fetch.cpp index ee80dad827..8ee6e035f7 100644 --- a/runtime/fastly/builtins/fetch/fetch.cpp +++ b/runtime/fastly/builtins/fetch/fetch.cpp @@ -1282,8 +1282,8 @@ bool fetch(JSContext *cx, unsigned argc, Value *vp) { 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)) { if (cache_state.is_usable_if_error()) { - // We've got a usable error substitute, so swap it out for the error and notify any - // request collapse trailers. + // We've got a stale-if-error response, so swap it out for the error and notify any + // request collapse followers. auto chose_stale_res = cache_entry.transaction_record_choose_stale(); if (auto *err = chose_stale_res.to_err()) { HANDLE_ERROR(cx, *err); From 247c3b47126a3c81dd7dfe2ac41b0043738adebc Mon Sep 17 00:00:00 2001 From: Sy Brand Date: Fri, 13 Feb 2026 09:19:44 +0000 Subject: [PATCH 10/36] Correct symbol name --- runtime/fastly/host-api/fastly.h | 4 ++-- runtime/fastly/host-api/host_api.cpp | 4 ++-- runtime/fastly/host-api/host_api_fastly.h | 2 +- 3 files changed, 5 insertions(+), 5 deletions(-) diff --git a/runtime/fastly/host-api/fastly.h b/runtime/fastly/host-api/fastly.h index 61aac4e1b5..6df143771b 100644 --- a/runtime/fastly/host-api/fastly.h +++ b/runtime/fastly/host-api/fastly.h @@ -406,8 +406,8 @@ 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_record_choose_stale") -int http_cache_transaction_record_choose_stale(uint32_t handle); +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, diff --git a/runtime/fastly/host-api/host_api.cpp b/runtime/fastly/host-api/host_api.cpp index 9a0dc8771b..3602a6ac8c 100644 --- a/runtime/fastly/host-api/host_api.cpp +++ b/runtime/fastly/host-api/host_api.cpp @@ -2422,9 +2422,9 @@ HttpCacheEntry::transaction_update_and_return_fresh(const HttpResp &resp, return Result::ok(HttpCacheEntry(fresh_handle_out)); } -Result HttpCacheEntry::transaction_record_choose_stale() { +Result HttpCacheEntry::transaction_choose_stale() { TRACE_CALL() - auto res = fastly::http_cache_transaction_record_choose_stale(this->handle); + auto res = fastly::http_cache_transaction_choose_stale(this->handle); if (res != 0) { return Result::err(host_api::APIError(res)); } diff --git a/runtime/fastly/host-api/host_api_fastly.h b/runtime/fastly/host-api/host_api_fastly.h index 8ae089d2dc..74935d17e7 100644 --- a/runtime/fastly/host-api/host_api_fastly.h +++ b/runtime/fastly/host-api/host_api_fastly.h @@ -805,7 +805,7 @@ class HttpCacheEntry final { std::optional vary_rule = std::nullopt); /// Substitute stale-if-error response - Result transaction_record_choose_stale(); + Result transaction_choose_stale(); /// Abandon the transaction Result transaction_abandon(); From d933b0b75163c2f94cc6bcc45e5b4dc88323736b Mon Sep 17 00:00:00 2001 From: Sy Brand Date: Wed, 8 Apr 2026 15:45:51 +0100 Subject: [PATCH 11/36] Almost there --- .../js-compute/fixtures/app/src/index.js | 1 + .../fixtures/app/src/stale-if-error.js | 286 ++++++++++++++++++ .../js-compute/fixtures/app/tests.json | 19 ++ .../fixtures/module-mode/src/index.js | 1 + .../fixtures/module-mode/tests.json | 21 ++ integration-tests/js-compute/test.js | 4 +- runtime/fastly/builtins/fetch/fetch.cpp | 67 +++- .../builtins/fetch/request-response.cpp | 25 +- runtime/fastly/host-api/host_api.cpp | 19 +- 9 files changed, 427 insertions(+), 16 deletions(-) create mode 100644 integration-tests/js-compute/fixtures/app/src/stale-if-error.js 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..96d3a8ae15 --- /dev/null +++ b/integration-tests/js-compute/fixtures/app/src/stale-if-error.js @@ -0,0 +1,286 @@ +/// +/* 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`, + ); +}); + +// Use cache key override to make different URLs share cache +// This tests the core stale-if-error functionality: +// 1. Cache a 200 response with short TTL and long stale-if-error window +// 2. Wait for TTL to expire (response becomes stale) +// 3. Request a URL that returns 503, but with the SAME cache key +// 4. Verify the stale 200 response is served instead of the 503 +routes.set( + '/stale-if-error/fetch/serve-stale-on-backend-error-with-cache-key', + async () => { + const sharedCacheKey = 'stale-if-error-test-shared-key-' + 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?status=200', + { + backend: 'httpme', + cacheOverride: new CacheOverride('override', { + ttl: 1, // 1 second TTL - will be stale quickly + staleIfError: 3600, // 1 hour stale-if-error window + }), + }, + ); + return; + 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 same cache key but URL that returns 503 + const errorRequest = new Request( + 'https://http-me.fastly.dev/status=503', + { + backend: 'httpme', + cacheOverride: new CacheOverride('override', { + staleIfError: 3600, + }), + }, + ); + errorRequest.setCacheKey(sharedCacheKey); + + const staleResponse = await fetch(errorRequest); + + // Step 4: Verify we got the stale 200 response, not the 503 + assert( + staleResponse.status, + 200, + 'Stale-if-error serves cached 200 response instead of backend 503', + ); + + const cachedBody = await staleResponse.text(); + strictEqual( + cachedBody, + initialBody, + 'Response body is from the original cached 200 response', + ); + }, +); diff --git a/integration-tests/js-compute/fixtures/app/tests.json b/integration-tests/js-compute/fixtures/app/tests.json index 0b8ff738a4..41393ecb65 100644 --- a/integration-tests/js-compute/fixtures/app/tests.json +++ b/integration-tests/js-compute/fixtures/app/tests.json @@ -3171,6 +3171,25 @@ "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": {}, + "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/serve-stale-on-backend-error-with-cache-key": { + "environments": ["compute"], + "features": ["http-cache"] + }, "GET /fastly/security/inspect/invalid-config": { "environments": ["viceroy"] }, diff --git a/integration-tests/js-compute/fixtures/module-mode/src/index.js b/integration-tests/js-compute/fixtures/module-mode/src/index.js index 5f04cc652f..7453ece157 100644 --- a/integration-tests/js-compute/fixtures/module-mode/src/index.js +++ b/integration-tests/js-compute/fixtures/module-mode/src/index.js @@ -13,6 +13,7 @@ import './hono.js'; import './http-cache.js'; import './kv-store.js'; import './transform-stream.js'; +import '../../app/src/stale-if-error.js'; addEventListener('fetch', (event) => { const responsePromise = app(event); diff --git a/integration-tests/js-compute/fixtures/module-mode/tests.json b/integration-tests/js-compute/fixtures/module-mode/tests.json index ea472ea30a..f460887358 100644 --- a/integration-tests/js-compute/fixtures/module-mode/tests.json +++ b/integration-tests/js-compute/fixtures/module-mode/tests.json @@ -420,5 +420,26 @@ "downstream_response": { "body": "This sentence will be streamed in chunks." } + }, + "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": { + "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/serve-stale-on-backend-error-with-cache-key": { + "environments": ["compute"], + "features": ["http-cache"] } } diff --git a/integration-tests/js-compute/test.js b/integration-tests/js-compute/test.js index db1c6fb455..aaa95871dc 100755 --- a/integration-tests/js-compute/test.js +++ b/integration-tests/js-compute/test.js @@ -59,7 +59,7 @@ if (!local && process.env.FASTLY_API_TOKEN === undefined) { try { zx.verbose = false; process.env.FASTLY_API_TOKEN = String( - await zx`fastly profile token --quiet`, + 'MhjH_5uXA_RYJ6hhB1MyVJjWB6AQgq5u' ).trim(); } catch { console.error( @@ -145,7 +145,7 @@ if (!local) { // get the public domain of the deployed application const domainListing = JSON.parse( - await $`fastly domain list --quiet --version latest --json`, + await $`fastly service domain list --quiet --json --version latest --token $FASTLY_API_TOKEN --service-name "${serviceName}"`, )[0]; domain = `https://${domainListing.Name}`; serviceId = domainListing.ServiceID; diff --git a/runtime/fastly/builtins/fetch/fetch.cpp b/runtime/fastly/builtins/fetch/fetch.cpp index 8ee6e035f7..f062634d1f 100644 --- a/runtime/fastly/builtins/fetch/fetch.cpp +++ b/runtime/fastly/builtins/fetch/fetch.cpp @@ -297,9 +297,7 @@ bool fetch_send_body(JSContext *cx, HandleObject request, JS::MutableHandleValue if (auto *err = res.to_err()) { if (host_api::error_is_generic(*err) || host_api::error_is_invalid_argument(*err)) { - JS_ReportErrorNumberASCII(cx, FastlyGetErrorMessage, nullptr, - fastly::JSMSG_REQUEST_BACKEND_DOES_NOT_EXIST, - backend_chars.ptr.get()); + HANDLE_ERROR(cx, *err); } else { HANDLE_ERROR(cx, *err); } @@ -862,6 +860,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); @@ -909,6 +913,53 @@ 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); + + // Check if we should use stale-if-error response instead of this error response + auto state_res = cache_entry.get_state(); + if (!state_res.is_err()) { + auto cache_state = state_res.unwrap(); + + DEBUG_LOG("cache_state for response is usable_if_error: " + std::to_string(cache_state.is_usable_if_error())); + + // If we have a usable stale-if-error response and the current response indicates an error + // (DoNotStore or RecordUncacheable are typically returned for error responses) + if (cache_state.is_usable_if_error() && + (storage_action == host_api::HttpStorageAction::DoNotStore || + storage_action == host_api::HttpStorageAction::RecordUncacheable)) { + // Use the stale response instead of the error response + auto chose_stale_res = cache_entry.transaction_choose_stale(); + if (auto *err = chose_stale_res.to_err()) { + HANDLE_ERROR(cx, *err); + return false; + } + + 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 false; + } + + if (maybe_response.has_value()) { + JS::RootedObject stale_response(cx, maybe_response.value()); + + // Store the error as a masked error on the response + JS::RootedValue error_response_val(cx, JS::ObjectValue(*response_obj)); + JS_SetReservedSlot(stale_response, static_cast(Response::Slots::MaskedError), + error_response_val); + + RequestOrResponse::take_cache_entry(stale_response, true); + if (!Response::add_fastly_cache_headers(cx, stale_response, request, cache_entry, + "cached response")) { + return false; + } + + // Return the stale response + args.rval().setObject(*stale_response); + return true; + } + } + } + switch (storage_action) { case host_api::HttpStorageAction::Insert: { auto insert_res = cache_entry.transaction_insert_and_stream_back( @@ -1166,6 +1217,11 @@ bool fetch(JSContext *cx, unsigned argc, Value *vp) { override_key_str = core::encode(cx, override_cache_key); override_key_span = std::span(reinterpret_cast(override_key_str.ptr.get()), override_key_str.size()); + DEBUG_LOG("HTTP Cache: Override cache key provided, using override cache key: " + + std::string(override_key_str.ptr.get(), override_key_str.size())); + } + else { + DEBUG_LOG("HTTP Cache: No override cache key provided, using default cache key"); } auto transaction_res = @@ -1215,6 +1271,7 @@ bool fetch(JSContext *cx, unsigned argc, Value *vp) { } if (maybe_response.has_value()) { + DEBUG_LOG("HTTP Cache: Found usable cached response, cache state: " + state_str); JS::RootedObject cached_response(cx, maybe_response.value()); if (cache_state.must_insert_or_update()) { @@ -1278,13 +1335,15 @@ bool fetch(JSContext *cx, unsigned argc, Value *vp) { // No valid cached response, need to make backend request if (cache_state.must_insert_or_update()) { + DEBUG_LOG("HTTP Cache: No usable cached response, making backend request, cache state: " + + state_str); // 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)) { if (cache_state.is_usable_if_error()) { // We've got a stale-if-error response, so swap it out for the error and notify any // request collapse followers. - auto chose_stale_res = cache_entry.transaction_record_choose_stale(); + auto chose_stale_res = cache_entry.transaction_choose_stale(); if (auto *err = chose_stale_res.to_err()) { HANDLE_ERROR(cx, *err); return false; diff --git a/runtime/fastly/builtins/fetch/request-response.cpp b/runtime/fastly/builtins/fetch/request-response.cpp index 18d537ac7e..f9256dc12e 100644 --- a/runtime/fastly/builtins/fetch/request-response.cpp +++ b/runtime/fastly/builtins/fetch/request-response.cpp @@ -603,6 +603,7 @@ bool RequestOrResponse::process_pending_request(JSContext *cx, // For a request made without caching (via the Request cache handle false convention), we must // add fastly headers to the Response auto maybe_not_cached = JS::GetReservedSlot(request, static_cast(Slots::CacheEntry)); + DEBUG_LOG("MAYBE NOT CACHED"); if (maybe_not_cached.isBoolean() && maybe_not_cached.toBoolean() == false) { if (!Response::add_fastly_cache_headers(cx, response, request, std::nullopt, "cached response")) { @@ -2082,6 +2083,7 @@ bool Request::set_cache_key(JSContext *cx, JS::HandleObject self, JS::HandleValu JS::RootedValue cache_key_str_val(cx, JS::StringValue(cache_key_str)); // Convert the key argument into a String following https://tc39.es/ecma262/#sec-tostring auto keyString = core::encode(cx, cache_key_str_val); + DEBUG_LOG("Setting cache key to " + std::string(keyString)); if (!keyString) { return false; } @@ -2094,8 +2096,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, @@ -2461,6 +2463,11 @@ bool Request::clone(JSContext *cx, unsigned argc, JS::Value *vp) { cache_override); } + JS::RootedValue override_cache_key( + cx, JS::GetReservedSlot(self, static_cast(Slots::OverrideCacheKey))); + JS::SetReservedSlot(requestInstance, static_cast(Slots::OverrideCacheKey), + override_cache_key); + JS::RootedValue image_optimizer_options( cx, JS::GetReservedSlot(self, static_cast(Slots::ImageOptimizerOptions))); if (!image_optimizer_options.isNullOrUndefined()) { @@ -3017,11 +3024,14 @@ JSObject *Request::create(JSContext *cx, JS::HandleObject requestInstance, JS::H } // Apply the Fastly Compute-proprietary `cacheKey` property. - // (in the input_request case, the header will be copied across normally) if (!cache_key.isUndefined()) { if (!set_cache_key(cx, request, cache_key)) { return nullptr; } + } else if (input_request) { + JS::SetReservedSlot( + request, static_cast(Slots::OverrideCacheKey), + JS::GetReservedSlot(input_request, static_cast(Slots::OverrideCacheKey))); } // Apply the Fastly Compute-proprietary `imageOptimizerOptions` property. @@ -3822,6 +3832,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, }; @@ -4469,15 +4480,23 @@ bool Response::pci_set(JSContext *cx, unsigned argc, JS::Value *vp) { 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) { - args.rval().setBoolean(false); + DEBUG_LOG("Response::staleIfErrorAvailable called on a response with no 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()) { + DEBUG_LOG("Error getting cache entry state"); HANDLE_ERROR(cx, *err); return false; } + DEBUG_LOG("Cache entry state " + std::to_string(res.unwrap().state)); args.rval().setBoolean(res.unwrap().is_usable_if_error()); return true; } diff --git a/runtime/fastly/host-api/host_api.cpp b/runtime/fastly/host-api/host_api.cpp index 3602a6ac8c..30ebd9a52c 100644 --- a/runtime/fastly/host-api/host_api.cpp +++ b/runtime/fastly/host-api/host_api.cpp @@ -710,26 +710,28 @@ Result HttpHeaders::remove(string_view name) { } Result HttpHeaders::set(string_view name, string_view value) { - TRACE_CALL_ARGS(&name, &value) + auto handle = this->handle_state_.get()->handle(); + TRACE_CALL_ARGS(TSV(std::to_string(handle)), &name, &value) std::span value_span = {reinterpret_cast(const_cast(value.data())), value.size()}; if (this->handle_state_.get()->is_req()) { - return generic_header_op(this->handle_state_.get()->handle(), name, + return generic_header_op(handle, name, value_span); } else { - return generic_header_op(this->handle_state_.get()->handle(), name, + return generic_header_op(handle, name, value_span); } } Result HttpHeaders::append(string_view name, string_view value) { - TRACE_CALL_ARGS(&name, &value) + auto handle = this->handle_state_.get()->handle(); + TRACE_CALL_ARGS(TSV(std::to_string(handle)), &name, &value) std::span value_span = {reinterpret_cast(const_cast(value.data())), value.size()}; if (this->handle_state_.get()->is_req()) { - return generic_header_op(this->handle_state_.get()->handle(), name, + return generic_header_op(handle, name, value_span); } else { - return generic_header_op(this->handle_state_.get()->handle(), name, + return generic_header_op(handle, name, value_span); } } @@ -2336,7 +2338,9 @@ 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))); + DEBUG_LOG("override_key: " + std::string(override_key.begin(), override_key.end())); uint32_t handle_out; fastly::fastly_http_cache_lookup_options opts{}; uint32_t opts_mask = 0; @@ -2580,6 +2584,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}); } From 646023bdfb2d874737b663ad39d72192dda9a677 Mon Sep 17 00:00:00 2001 From: Sy Brand Date: Wed, 8 Apr 2026 17:44:39 +0100 Subject: [PATCH 12/36] Revert test change --- integration-tests/js-compute/test.js | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/integration-tests/js-compute/test.js b/integration-tests/js-compute/test.js index aaa95871dc..db1c6fb455 100755 --- a/integration-tests/js-compute/test.js +++ b/integration-tests/js-compute/test.js @@ -59,7 +59,7 @@ if (!local && process.env.FASTLY_API_TOKEN === undefined) { try { zx.verbose = false; process.env.FASTLY_API_TOKEN = String( - 'MhjH_5uXA_RYJ6hhB1MyVJjWB6AQgq5u' + await zx`fastly profile token --quiet`, ).trim(); } catch { console.error( @@ -145,7 +145,7 @@ if (!local) { // get the public domain of the deployed application const domainListing = JSON.parse( - await $`fastly service domain list --quiet --json --version latest --token $FASTLY_API_TOKEN --service-name "${serviceName}"`, + await $`fastly domain list --quiet --version latest --json`, )[0]; domain = `https://${domainListing.Name}`; serviceId = domainListing.ServiceID; From d24ab0752f4a833175642e8b9a26c3a598e74db7 Mon Sep 17 00:00:00 2001 From: Sy Brand Date: Wed, 8 Apr 2026 17:54:02 +0100 Subject: [PATCH 13/36] Rebase --- runtime/fastly/builtins/fetch/fetch.cpp | 19 ++++++++----------- runtime/fastly/host-api/host_api.cpp | 2 -- 2 files changed, 8 insertions(+), 13 deletions(-) diff --git a/runtime/fastly/builtins/fetch/fetch.cpp b/runtime/fastly/builtins/fetch/fetch.cpp index f062634d1f..22ed44813a 100644 --- a/runtime/fastly/builtins/fetch/fetch.cpp +++ b/runtime/fastly/builtins/fetch/fetch.cpp @@ -12,6 +12,7 @@ #include "builtin.h" #include "encode.h" #include "extension-api.h" +#include "picosha2.h" using builtins::web::streams::NativeStreamSink; using builtins::web::streams::NativeStreamSource; @@ -1209,23 +1210,19 @@ bool fetch(JSContext *cx, unsigned argc, Value *vp) { auto request_handle = Request::request_handle(request); // Convert override cache key to span if present - host_api::HostString override_key_str; - std::span override_key_span = {}; + std::vector override_key_hash; JS::RootedValue override_cache_key( cx, JS::GetReservedSlot(request, static_cast(Request::Slots::OverrideCacheKey))); if (override_cache_key.isString()) { - override_key_str = core::encode(cx, override_cache_key); - override_key_span = std::span(reinterpret_cast(override_key_str.ptr.get()), - override_key_str.size()); - DEBUG_LOG("HTTP Cache: Override cache key provided, using override cache key: " + - std::string(override_key_str.ptr.get(), override_key_str.size())); - } - else { - DEBUG_LOG("HTTP Cache: No override cache key provided, using default cache key"); + auto override_key_str = core::encode(cx, override_cache_key); + override_key_hash.resize(32); // SHA256 produces 32 bytes + picosha2::hash256(override_key_str.ptr.get(), + override_key_str.ptr.get() + override_key_str.size(), + override_key_hash.begin(), override_key_hash.end()); } auto transaction_res = - host_api::HttpCacheEntry::transaction_lookup(request_handle, override_key_span); + 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)) { diff --git a/runtime/fastly/host-api/host_api.cpp b/runtime/fastly/host-api/host_api.cpp index 30ebd9a52c..96ecc0436c 100644 --- a/runtime/fastly/host-api/host_api.cpp +++ b/runtime/fastly/host-api/host_api.cpp @@ -2340,12 +2340,10 @@ Result HttpCacheEntry::transaction_lookup(const HttpReq &req, std::span override_key) { TRACE_CALL_ARGS(TSV(std::to_string(req.handle))); - DEBUG_LOG("override_key: " + std::string(override_key.begin(), override_key.end())); uint32_t handle_out; fastly::fastly_http_cache_lookup_options opts{}; uint32_t opts_mask = 0; - MOZ_ASSERT(override_key.empty()); if (!override_key.empty()) { MOZ_ASSERT(override_key.size() == 32); opts.override_key = reinterpret_cast(override_key.data()); From 44213da2b4d139782eae02f2daf485e48213acb9 Mon Sep 17 00:00:00 2001 From: Sy Brand Date: Wed, 8 Apr 2026 18:11:56 +0100 Subject: [PATCH 14/36] Fix test --- integration-tests/js-compute/fixtures/app/src/stale-if-error.js | 2 -- 1 file changed, 2 deletions(-) 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 index 96d3a8ae15..9dc5d62035 100644 --- a/integration-tests/js-compute/fixtures/app/src/stale-if-error.js +++ b/integration-tests/js-compute/fixtures/app/src/stale-if-error.js @@ -244,9 +244,7 @@ routes.set( }), }, ); - return; goodRequest.setCacheKey(sharedCacheKey); - const goodResponse = await fetch(goodRequest); assert(goodResponse.status, 200, 'Initial response is 200'); From 0e74678db7016e763107c0b3966c6e5f8a1ed4e7 Mon Sep 17 00:00:00 2001 From: Sy Brand Date: Wed, 8 Apr 2026 18:15:50 +0100 Subject: [PATCH 15/36] Revert --- .../fixtures/module-mode/src/http-cache.js | 27 ------------------- .../fixtures/module-mode/src/index.js | 1 - .../fixtures/module-mode/tests.json | 25 ----------------- 3 files changed, 53 deletions(-) diff --git a/integration-tests/js-compute/fixtures/module-mode/src/http-cache.js b/integration-tests/js-compute/fixtures/module-mode/src/http-cache.js index ddadc19b0f..d4f5ade011 100644 --- a/integration-tests/js-compute/fixtures/module-mode/src/http-cache.js +++ b/integration-tests/js-compute/fixtures/module-mode/src/http-cache.js @@ -1002,30 +1002,3 @@ const getTestUrl = (path = `/${Math.random().toString().slice(2)}`) => strictEqual(results2[1].cached, true); }); } - -routes.set('/http-cache/cache-key-on-request', async () => { - const url = getTestUrl(); - let backendCalls = 0; - - const cacheOverride = new CacheOverride({ - beforeSend(req) { - backendCalls++; - }, - }); - - const key = `custom-cache-key-${Math.random().toString().slice(2)}`; - - let req1 = new Request(url + '?req=1'); - req1.setCacheKey(key); - const res1 = await fetch(req1, { cacheOverride }); - strictEqual(backendCalls, 1); - strictEqual(res1.cached, false); - - let req2 = new Request(url + '?req=2'); - req2.setCacheKey(key); - const res2 = await fetch(req2, { cacheOverride }); - strictEqual(backendCalls, 1); - strictEqual(res2.cached, true); - - strictEqual(await res1.text(), await res2.text()); -}); diff --git a/integration-tests/js-compute/fixtures/module-mode/src/index.js b/integration-tests/js-compute/fixtures/module-mode/src/index.js index 7453ece157..5f04cc652f 100644 --- a/integration-tests/js-compute/fixtures/module-mode/src/index.js +++ b/integration-tests/js-compute/fixtures/module-mode/src/index.js @@ -13,7 +13,6 @@ import './hono.js'; import './http-cache.js'; import './kv-store.js'; import './transform-stream.js'; -import '../../app/src/stale-if-error.js'; addEventListener('fetch', (event) => { const responsePromise = app(event); diff --git a/integration-tests/js-compute/fixtures/module-mode/tests.json b/integration-tests/js-compute/fixtures/module-mode/tests.json index 4d26582eb2..ea472ea30a 100644 --- a/integration-tests/js-compute/fixtures/module-mode/tests.json +++ b/integration-tests/js-compute/fixtures/module-mode/tests.json @@ -293,10 +293,6 @@ "environments": ["compute"], "features": ["http-cache"] }, - "GET /http-cache/cache-key-on-request": { - "environments": ["compute"], - "features": ["http-cache"] - }, "GET /kv-store-e2e/list": { "flake": true }, "GET /kv-store/exposed-as-global": {}, "GET /kv-store/interface": { "flake": true }, @@ -424,26 +420,5 @@ "downstream_response": { "body": "This sentence will be streamed in chunks." } - }, - "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": { - "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/serve-stale-on-backend-error-with-cache-key": { - "environments": ["compute"], - "features": ["http-cache"] } } From 6c6e32971ab62eb76e219246bf02a1197190a1d7 Mon Sep 17 00:00:00 2001 From: Sy Brand Date: Wed, 8 Apr 2026 18:16:28 +0100 Subject: [PATCH 16/36] Revert --- .../fixtures/module-mode/src/http-cache.js | 27 +++++++++++++++++++ 1 file changed, 27 insertions(+) diff --git a/integration-tests/js-compute/fixtures/module-mode/src/http-cache.js b/integration-tests/js-compute/fixtures/module-mode/src/http-cache.js index d4f5ade011..57d5e952fa 100644 --- a/integration-tests/js-compute/fixtures/module-mode/src/http-cache.js +++ b/integration-tests/js-compute/fixtures/module-mode/src/http-cache.js @@ -1002,3 +1002,30 @@ const getTestUrl = (path = `/${Math.random().toString().slice(2)}`) => strictEqual(results2[1].cached, true); }); } + +routes.set('/http-cache/cache-key-on-request', async () => { + const url = getTestUrl(); + let backendCalls = 0; + + const cacheOverride = new CacheOverride({ + beforeSend(req) { + backendCalls++; + }, + }); + + const key = `custom-cache-key-${Math.random().toString().slice(2)}`; + + let req1 = new Request(url + '?req=1'); + req1.setCacheKey(key); + const res1 = await fetch(req1, { cacheOverride }); + strictEqual(backendCalls, 1); + strictEqual(res1.cached, false); + + let req2 = new Request(url + '?req=2'); + req2.setCacheKey(key); + const res2 = await fetch(req2, { cacheOverride }); + strictEqual(backendCalls, 1); + strictEqual(res2.cached, true); + + strictEqual(await res1.text(), await res2.text()); +}); \ No newline at end of file From c0431539c2548cab30129b81bbceb89d30abd99b Mon Sep 17 00:00:00 2001 From: Sy Brand Date: Wed, 8 Apr 2026 18:16:51 +0100 Subject: [PATCH 17/36] Revert --- integration-tests/js-compute/fixtures/module-mode/tests.json | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/integration-tests/js-compute/fixtures/module-mode/tests.json b/integration-tests/js-compute/fixtures/module-mode/tests.json index ea472ea30a..1058ff1672 100644 --- a/integration-tests/js-compute/fixtures/module-mode/tests.json +++ b/integration-tests/js-compute/fixtures/module-mode/tests.json @@ -293,6 +293,10 @@ "environments": ["compute"], "features": ["http-cache"] }, + "GET /http-cache/cache-key-on-request": { + "environments": ["compute"], + "features": ["http-cache"] + }, "GET /kv-store-e2e/list": { "flake": true }, "GET /kv-store/exposed-as-global": {}, "GET /kv-store/interface": { "flake": true }, From 32943848f84220f1878afc193277d3773ef0dd3b Mon Sep 17 00:00:00 2001 From: Sy Brand Date: Wed, 8 Apr 2026 18:18:22 +0100 Subject: [PATCH 18/36] fmt --- runtime/fastly/builtins/fetch/fetch.cpp | 23 +++++++++++++---------- 1 file changed, 13 insertions(+), 10 deletions(-) diff --git a/runtime/fastly/builtins/fetch/fetch.cpp b/runtime/fastly/builtins/fetch/fetch.cpp index 22ed44813a..f3467bbfa0 100644 --- a/runtime/fastly/builtins/fetch/fetch.cpp +++ b/runtime/fastly/builtins/fetch/fetch.cpp @@ -298,7 +298,9 @@ bool fetch_send_body(JSContext *cx, HandleObject request, JS::MutableHandleValue if (auto *err = res.to_err()) { if (host_api::error_is_generic(*err) || host_api::error_is_invalid_argument(*err)) { - HANDLE_ERROR(cx, *err); + JS_ReportErrorNumberASCII(cx, FastlyGetErrorMessage, nullptr, + fastly::JSMSG_REQUEST_BACKEND_DOES_NOT_EXIST, + backend_chars.ptr.get()); } else { HANDLE_ERROR(cx, *err); } @@ -914,13 +916,14 @@ 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); - + // Check if we should use stale-if-error response instead of this error response auto state_res = cache_entry.get_state(); if (!state_res.is_err()) { auto cache_state = state_res.unwrap(); - - DEBUG_LOG("cache_state for response is usable_if_error: " + std::to_string(cache_state.is_usable_if_error())); + + DEBUG_LOG("cache_state for response is usable_if_error: " + + std::to_string(cache_state.is_usable_if_error())); // If we have a usable stale-if-error response and the current response indicates an error // (DoNotStore or RecordUncacheable are typically returned for error responses) @@ -933,34 +936,34 @@ bool stream_back_then_handler(JSContext *cx, JS::HandleObject request, JS::Handl HANDLE_ERROR(cx, *err); return false; } - + 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 false; } - + if (maybe_response.has_value()) { JS::RootedObject stale_response(cx, maybe_response.value()); - + // Store the error as a masked error on the response JS::RootedValue error_response_val(cx, JS::ObjectValue(*response_obj)); JS_SetReservedSlot(stale_response, static_cast(Response::Slots::MaskedError), error_response_val); - + RequestOrResponse::take_cache_entry(stale_response, true); if (!Response::add_fastly_cache_headers(cx, stale_response, request, cache_entry, "cached response")) { return false; } - + // Return the stale response args.rval().setObject(*stale_response); return true; } } } - + switch (storage_action) { case host_api::HttpStorageAction::Insert: { auto insert_res = cache_entry.transaction_insert_and_stream_back( From 41613f75ff74434b20a546c66c4f384bc0e4ccee Mon Sep 17 00:00:00 2001 From: Sy Brand Date: Wed, 8 Apr 2026 18:19:19 +0100 Subject: [PATCH 19/36] Remove debug output --- runtime/fastly/builtins/fetch/fetch.cpp | 3 --- 1 file changed, 3 deletions(-) diff --git a/runtime/fastly/builtins/fetch/fetch.cpp b/runtime/fastly/builtins/fetch/fetch.cpp index f3467bbfa0..8d02ad759c 100644 --- a/runtime/fastly/builtins/fetch/fetch.cpp +++ b/runtime/fastly/builtins/fetch/fetch.cpp @@ -922,9 +922,6 @@ bool stream_back_then_handler(JSContext *cx, JS::HandleObject request, JS::Handl if (!state_res.is_err()) { auto cache_state = state_res.unwrap(); - DEBUG_LOG("cache_state for response is usable_if_error: " + - std::to_string(cache_state.is_usable_if_error())); - // If we have a usable stale-if-error response and the current response indicates an error // (DoNotStore or RecordUncacheable are typically returned for error responses) if (cache_state.is_usable_if_error() && From 0f20fb0670c8b65b2f8f878d4cc38a228b302d2c Mon Sep 17 00:00:00 2001 From: Sy Brand Date: Wed, 8 Apr 2026 18:24:23 +0100 Subject: [PATCH 20/36] Cleanup --- runtime/fastly/host-api/host_api.cpp | 16 +++++++--------- 1 file changed, 7 insertions(+), 9 deletions(-) diff --git a/runtime/fastly/host-api/host_api.cpp b/runtime/fastly/host-api/host_api.cpp index 50301d2fc9..398ba545ec 100644 --- a/runtime/fastly/host-api/host_api.cpp +++ b/runtime/fastly/host-api/host_api.cpp @@ -710,28 +710,26 @@ Result HttpHeaders::remove(string_view name) { } Result HttpHeaders::set(string_view name, string_view value) { - auto handle = this->handle_state_.get()->handle(); - TRACE_CALL_ARGS(TSV(std::to_string(handle)), &name, &value) + TRACE_CALL_ARGS(&name, &value) std::span value_span = {reinterpret_cast(const_cast(value.data())), value.size()}; if (this->handle_state_.get()->is_req()) { - return generic_header_op(handle, name, + return generic_header_op(this->handle_state_.get()->handle(), name, value_span); } else { - return generic_header_op(handle, name, + return generic_header_op(this->handle_state_.get()->handle(), name, value_span); } } Result HttpHeaders::append(string_view name, string_view value) { - auto handle = this->handle_state_.get()->handle(); - TRACE_CALL_ARGS(TSV(std::to_string(handle)), &name, &value) + TRACE_CALL_ARGS(&name, &value) std::span value_span = {reinterpret_cast(const_cast(value.data())), value.size()}; if (this->handle_state_.get()->is_req()) { - return generic_header_op(handle, name, + return generic_header_op(this->handle_state_.get()->handle(), name, value_span); } else { - return generic_header_op(handle, name, + return generic_header_op(this->handle_state_.get()->handle(), name, value_span); } } @@ -2448,8 +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))); + uint32_t handle_out; fastly::fastly_http_cache_lookup_options opts{}; uint32_t opts_mask = 0; From d45c030c7d8a224d9c1ca905bd2bd9e83cbf8bd4 Mon Sep 17 00:00:00 2001 From: Sy Brand Date: Wed, 8 Apr 2026 18:26:26 +0100 Subject: [PATCH 21/36] fmt --- runtime/fastly/host-api/host_api.cpp | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/runtime/fastly/host-api/host_api.cpp b/runtime/fastly/host-api/host_api.cpp index 398ba545ec..91e2ec9796 100644 --- a/runtime/fastly/host-api/host_api.cpp +++ b/runtime/fastly/host-api/host_api.cpp @@ -2447,7 +2447,7 @@ 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))); - + uint32_t handle_out; fastly::fastly_http_cache_lookup_options opts{}; uint32_t opts_mask = 0; From ec666e455292306f0335a44eb6b62e112ee2f84c Mon Sep 17 00:00:00 2001 From: Sy Brand Date: Wed, 8 Apr 2026 18:31:47 +0100 Subject: [PATCH 22/36] Fmt --- .../fixtures/app/src/stale-if-error.js | 27 +++++++++---------- 1 file changed, 13 insertions(+), 14 deletions(-) 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 index 9dc5d62035..0825672276 100644 --- a/integration-tests/js-compute/fixtures/app/src/stale-if-error.js +++ b/integration-tests/js-compute/fixtures/app/src/stale-if-error.js @@ -132,14 +132,15 @@ routes.set( // 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(); + staleIfErrorAvailableResult = + candidateResponse.staleIfErrorAvailable(); staleIfErrorValue = candidateResponse.staleIfError; }, }), @@ -166,7 +167,7 @@ routes.set( 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', { @@ -174,7 +175,8 @@ routes.set( // No staleIfError configured afterSend(candidateResponse) { // Check staleIfErrorAvailable() on the candidate response - staleIfErrorAvailableResult = candidateResponse.staleIfErrorAvailable(); + staleIfErrorAvailableResult = + candidateResponse.staleIfErrorAvailable(); }, }), }); @@ -254,16 +256,13 @@ routes.set( await new Promise((resolve) => setTimeout(resolve, 1500)); // Step 3: Request with same cache key but URL that returns 503 - const errorRequest = new Request( - 'https://http-me.fastly.dev/status=503', - { - backend: 'httpme', - cacheOverride: new CacheOverride('override', { - staleIfError: 3600, - }), - }, - ); - errorRequest.setCacheKey(sharedCacheKey); + const errorRequest = new Request('https://http-me.fastly.dev/status=503', { + backend: 'httpme', + cacheOverride: new CacheOverride('override', { + staleIfError: 3600, + }), + }); + errorRequest.setCacheKey(sharedCacheKey); const staleResponse = await fetch(errorRequest); From 68bf8f5053f9172b766c4b3a95c9ef33940e2d1c Mon Sep 17 00:00:00 2001 From: Sy Brand Date: Wed, 8 Apr 2026 18:35:55 +0100 Subject: [PATCH 23/36] fmt --- .../js-compute/fixtures/module-mode/src/http-cache.js | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/integration-tests/js-compute/fixtures/module-mode/src/http-cache.js b/integration-tests/js-compute/fixtures/module-mode/src/http-cache.js index 57d5e952fa..ddadc19b0f 100644 --- a/integration-tests/js-compute/fixtures/module-mode/src/http-cache.js +++ b/integration-tests/js-compute/fixtures/module-mode/src/http-cache.js @@ -1028,4 +1028,4 @@ routes.set('/http-cache/cache-key-on-request', async () => { strictEqual(res2.cached, true); strictEqual(await res1.text(), await res2.text()); -}); \ No newline at end of file +}); From 6853ac07bb087d6957ace2e39d994bb71fcaabea Mon Sep 17 00:00:00 2001 From: Sy Brand Date: Thu, 9 Apr 2026 09:58:51 +0100 Subject: [PATCH 24/36] Cleanup --- runtime/fastly/builtins/fetch/fetch.cpp | 1 - 1 file changed, 1 deletion(-) diff --git a/runtime/fastly/builtins/fetch/fetch.cpp b/runtime/fastly/builtins/fetch/fetch.cpp index 8d02ad759c..901e520268 100644 --- a/runtime/fastly/builtins/fetch/fetch.cpp +++ b/runtime/fastly/builtins/fetch/fetch.cpp @@ -1268,7 +1268,6 @@ bool fetch(JSContext *cx, unsigned argc, Value *vp) { } if (maybe_response.has_value()) { - DEBUG_LOG("HTTP Cache: Found usable cached response, cache state: " + state_str); JS::RootedObject cached_response(cx, maybe_response.value()); if (cache_state.must_insert_or_update()) { From 094cca127a8293916002db3c230f98ddf8744997 Mon Sep 17 00:00:00 2001 From: Sy Brand Date: Thu, 9 Apr 2026 09:59:21 +0100 Subject: [PATCH 25/36] Cleanup --- runtime/fastly/builtins/fetch/fetch.cpp | 2 -- 1 file changed, 2 deletions(-) diff --git a/runtime/fastly/builtins/fetch/fetch.cpp b/runtime/fastly/builtins/fetch/fetch.cpp index 901e520268..7864afe7ae 100644 --- a/runtime/fastly/builtins/fetch/fetch.cpp +++ b/runtime/fastly/builtins/fetch/fetch.cpp @@ -1331,8 +1331,6 @@ bool fetch(JSContext *cx, unsigned argc, Value *vp) { // No valid cached response, need to make backend request if (cache_state.must_insert_or_update()) { - DEBUG_LOG("HTTP Cache: No usable cached response, making backend request, cache state: " + - state_str); // 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)) { From 4145bb6460cb0c20141fad9b462fe87b32021c61 Mon Sep 17 00:00:00 2001 From: Sy Brand Date: Thu, 9 Apr 2026 09:59:54 +0100 Subject: [PATCH 26/36] Cleanup --- runtime/fastly/builtins/fetch/request-response.cpp | 1 - 1 file changed, 1 deletion(-) diff --git a/runtime/fastly/builtins/fetch/request-response.cpp b/runtime/fastly/builtins/fetch/request-response.cpp index 7c7e670212..c213ec568b 100644 --- a/runtime/fastly/builtins/fetch/request-response.cpp +++ b/runtime/fastly/builtins/fetch/request-response.cpp @@ -630,7 +630,6 @@ bool RequestOrResponse::process_pending_request(JSContext *cx, // For a request made without caching (via the Request cache handle false convention), we must // add fastly headers to the Response auto maybe_not_cached = JS::GetReservedSlot(request, static_cast(Slots::CacheEntry)); - DEBUG_LOG("MAYBE NOT CACHED"); if (maybe_not_cached.isBoolean() && maybe_not_cached.toBoolean() == false) { if (!Response::add_fastly_cache_headers(cx, response, request, std::nullopt, "cached response")) { From b1ae77fdf9175ae3b9b1b18044af52978c42db1c Mon Sep 17 00:00:00 2001 From: Sy Brand Date: Thu, 9 Apr 2026 10:00:11 +0100 Subject: [PATCH 27/36] Cleanup --- runtime/fastly/builtins/fetch/request-response.cpp | 1 - 1 file changed, 1 deletion(-) diff --git a/runtime/fastly/builtins/fetch/request-response.cpp b/runtime/fastly/builtins/fetch/request-response.cpp index c213ec568b..3317380fc1 100644 --- a/runtime/fastly/builtins/fetch/request-response.cpp +++ b/runtime/fastly/builtins/fetch/request-response.cpp @@ -2088,7 +2088,6 @@ bool Request::set_cache_key(JSContext *cx, JS::HandleObject self, JS::HandleValu JS::RootedValue cache_key_str_val(cx, JS::StringValue(cache_key_str)); // Convert the key argument into a String following https://tc39.es/ecma262/#sec-tostring auto keyString = core::encode(cx, cache_key_str_val); - DEBUG_LOG("Setting cache key to " + std::string(keyString)); if (!keyString) { return false; } From b738ab8bd74516ebdcb4614f7ff65756310d8c52 Mon Sep 17 00:00:00 2001 From: Sy Brand Date: Thu, 9 Apr 2026 13:15:25 +0100 Subject: [PATCH 28/36] Complete implementation --- .../fixtures/app/src/stale-if-error.js | 134 ++++++++++++++++++ .../js-compute/fixtures/app/tests.json | 16 +++ runtime/fastly/builtins/fetch/fetch.cpp | 98 +++++++++++-- runtime/fastly/builtins/fetch/fetch.h | 7 + .../builtins/fetch/request-response.cpp | 12 +- 5 files changed, 255 insertions(+), 12 deletions(-) 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 index 0825672276..7ed0539bc1 100644 --- a/integration-tests/js-compute/fixtures/app/src/stale-if-error.js +++ b/integration-tests/js-compute/fixtures/app/src/stale-if-error.js @@ -279,5 +279,139 @@ routes.set( initialBody, 'Response body is from the original cached 200 response', ); + + strictEqual( + staleResponse.maskedError.status, + 503, + 'The masked error on the response is the original backend error (503)', + ); + }, +); + +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', + ); + }, +); \ No newline at end of file diff --git a/integration-tests/js-compute/fixtures/app/tests.json b/integration-tests/js-compute/fixtures/app/tests.json index a0d4d7178e..3382adb1d0 100644 --- a/integration-tests/js-compute/fixtures/app/tests.json +++ b/integration-tests/js-compute/fixtures/app/tests.json @@ -3241,10 +3241,26 @@ "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-backend-error-with-cache-key": { "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/fetch/fetch.cpp b/runtime/fastly/builtins/fetch/fetch.cpp index 7864afe7ae..600050107c 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; } @@ -921,12 +929,9 @@ bool stream_back_then_handler(JSContext *cx, JS::HandleObject request, JS::Handl auto state_res = cache_entry.get_state(); if (!state_res.is_err()) { auto cache_state = state_res.unwrap(); - - // If we have a usable stale-if-error response and the current response indicates an error - // (DoNotStore or RecordUncacheable are typically returned for error responses) + auto status = Response::status(response_obj); if (cache_state.is_usable_if_error() && - (storage_action == host_api::HttpStorageAction::DoNotStore || - storage_action == host_api::HttpStorageAction::RecordUncacheable)) { + (status >= 500 && status < 600)) { // Use the stale response instead of the error response auto chose_stale_res = cache_entry.transaction_choose_stale(); if (auto *err = chose_stale_res.to_err()) { @@ -1131,6 +1136,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)) { @@ -1147,6 +1159,68 @@ 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; + } + + if (!maybe_response.has_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 @@ -1209,7 +1283,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))); @@ -1221,8 +1295,14 @@ bool fetch(JSContext *cx, unsigned argc, Value *vp) { override_key_hash.begin(), override_key_hash.end()); } - auto transaction_res = - host_api::HttpCacheEntry::transaction_lookup(request_handle, override_key_hash); + host_api::Result transaction_res; + if (override_key_hash.empty()) { + transaction_res = host_api::HttpCacheEntry::transaction_lookup(request_handle, {}); + } else { + transaction_res = host_api::HttpCacheEntry::transaction_lookup( + request_handle, std::span{override_key_hash.data(), override_key_hash.size()}); + } + if (auto *err = transaction_res.to_err()) { DEBUG_LOG("HTTP Cache: Transaction lookup error") if (host_api::error_is_limit_exceeded(*err)) { diff --git a/runtime/fastly/builtins/fetch/fetch.h b/runtime/fastly/builtins/fetch/fetch.h index 8a8dc7eff6..8fedecfb20 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 3317380fc1..4ecf0e91f4 100644 --- a/runtime/fastly/builtins/fetch/request-response.cpp +++ b/runtime/fastly/builtins/fetch/request-response.cpp @@ -599,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; } @@ -4492,7 +4500,6 @@ bool Response::staleIfErrorAvailable(JSContext *cx, unsigned argc, JS::Value *vp // This method is only valid on candidate responses (inside afterSend callback) auto cache_entry = RequestOrResponse::cache_entry(self); if (!cache_entry) { - DEBUG_LOG("Response::staleIfErrorAvailable called on a response with no 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"); @@ -4501,11 +4508,10 @@ bool Response::staleIfErrorAvailable(JSContext *cx, unsigned argc, JS::Value *vp auto res = cache_entry->get_state(); if (auto *err = res.to_err()) { - DEBUG_LOG("Error getting cache entry state"); HANDLE_ERROR(cx, *err); return false; } - DEBUG_LOG("Cache entry state " + std::to_string(res.unwrap().state)); + args.rval().setBoolean(res.unwrap().is_usable_if_error()); return true; } From 5b16bb32d799a06a7d52524d1cd3a82e55153508 Mon Sep 17 00:00:00 2001 From: Sy Brand Date: Thu, 9 Apr 2026 13:17:42 +0100 Subject: [PATCH 29/36] fmt --- runtime/fastly/builtins/fetch/fetch.cpp | 14 +++++++------- runtime/fastly/builtins/fetch/fetch.h | 6 +++--- runtime/fastly/builtins/fetch/request-response.cpp | 6 +++--- 3 files changed, 13 insertions(+), 13 deletions(-) diff --git a/runtime/fastly/builtins/fetch/fetch.cpp b/runtime/fastly/builtins/fetch/fetch.cpp index 600050107c..0587943b40 100644 --- a/runtime/fastly/builtins/fetch/fetch.cpp +++ b/runtime/fastly/builtins/fetch/fetch.cpp @@ -395,13 +395,13 @@ 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; @@ -930,8 +930,7 @@ bool stream_back_then_handler(JSContext *cx, JS::HandleObject request, JS::Handl if (!state_res.is_err()) { auto cache_state = state_res.unwrap(); auto status = Response::status(response_obj); - if (cache_state.is_usable_if_error() && - (status >= 500 && status < 600)) { + if (cache_state.is_usable_if_error() && (status >= 500 && status < 600)) { // Use the stale response instead of the error response auto chose_stale_res = cache_entry.transaction_choose_stale(); if (auto *err = chose_stale_res.to_err()) { @@ -1141,7 +1140,7 @@ bool stream_back_catch_handler(JSContext *cx, JS::HandleObject request, JS::Hand 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 @@ -1161,7 +1160,8 @@ 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, +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()) { @@ -1302,7 +1302,7 @@ bool fetch(JSContext *cx, unsigned argc, Value *vp) { transaction_res = host_api::HttpCacheEntry::transaction_lookup( request_handle, std::span{override_key_hash.data(), override_key_hash.size()}); } - + if (auto *err = transaction_res.to_err()) { DEBUG_LOG("HTTP Cache: Transaction lookup error") if (host_api::error_is_limit_exceeded(*err)) { diff --git a/runtime/fastly/builtins/fetch/fetch.h b/runtime/fastly/builtins/fetch/fetch.h index 8fedecfb20..fda30c793b 100644 --- a/runtime/fastly/builtins/fetch/fetch.h +++ b/runtime/fastly/builtins/fetch/fetch.h @@ -8,7 +8,7 @@ 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); +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 4ecf0e91f4..d645f9412b 100644 --- a/runtime/fastly/builtins/fetch/request-response.cpp +++ b/runtime/fastly/builtins/fetch/request-response.cpp @@ -599,13 +599,13 @@ 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; @@ -4511,7 +4511,7 @@ bool Response::staleIfErrorAvailable(JSContext *cx, unsigned argc, JS::Value *vp HANDLE_ERROR(cx, *err); return false; } - + args.rval().setBoolean(res.unwrap().is_usable_if_error()); return true; } From a5d1f9f5bbd71fb356a3bc959441bfcac30819e0 Mon Sep 17 00:00:00 2001 From: Sy Brand Date: Thu, 9 Apr 2026 13:38:23 +0100 Subject: [PATCH 30/36] tests --- integration-tests/js-compute/fixtures/app/tests.json | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/integration-tests/js-compute/fixtures/app/tests.json b/integration-tests/js-compute/fixtures/app/tests.json index 3382adb1d0..e5b8f31aa2 100644 --- a/integration-tests/js-compute/fixtures/app/tests.json +++ b/integration-tests/js-compute/fixtures/app/tests.json @@ -3232,7 +3232,10 @@ "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": {}, + "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"] From c45e6f6958c59fe0629bd804464a4afec28dcc60 Mon Sep 17 00:00:00 2001 From: Sy Brand Date: Thu, 9 Apr 2026 14:45:20 +0100 Subject: [PATCH 31/36] fmt --- integration-tests/js-compute/fixtures/app/src/stale-if-error.js | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) 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 index 7ed0539bc1..9506e4ac61 100644 --- a/integration-tests/js-compute/fixtures/app/src/stale-if-error.js +++ b/integration-tests/js-compute/fixtures/app/src/stale-if-error.js @@ -414,4 +414,4 @@ routes.set( 'The masked error message matches the thrown exception', ); }, -); \ No newline at end of file +); From 62e57944ec3a7344a26cb66d7c03bd9fbdf00e1f Mon Sep 17 00:00:00 2001 From: Sy Brand Date: Thu, 9 Apr 2026 15:33:27 +0100 Subject: [PATCH 32/36] Docs --- .../docs/cache-override/CacheOverride/CacheOverride.mdx | 4 ++++ 1 file changed, 4 insertions(+) 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. From c4c7c32c914c3c05c6878b31f8aa0b3a4d69be7b Mon Sep 17 00:00:00 2001 From: Sy Brand Date: Mon, 13 Apr 2026 10:39:24 +0100 Subject: [PATCH 33/36] Update runtime/fastly/builtins/fetch/request-response.cpp Co-authored-by: Charles Eckman --- runtime/fastly/builtins/fetch/request-response.cpp | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/runtime/fastly/builtins/fetch/request-response.cpp b/runtime/fastly/builtins/fetch/request-response.cpp index 1fcc5eaa35..bc58341891 100644 --- a/runtime/fastly/builtins/fetch/request-response.cpp +++ b/runtime/fastly/builtins/fetch/request-response.cpp @@ -4049,7 +4049,7 @@ bool Response::staleIfError_get(JSContext *cx, unsigned argc, JS::Value *vp) { 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 undefiend cases of no caching API use / no hostcall support + // 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(); From 499c392663839c85a4329564a4cb5a42339577e1 Mon Sep 17 00:00:00 2001 From: Sy Brand Date: Mon, 13 Apr 2026 14:40:24 +0100 Subject: [PATCH 34/36] Remove special-casing 5XX errors --- .../fixtures/app/src/stale-if-error.js | 64 ------------------- .../js-compute/fixtures/app/tests.json | 4 -- runtime/fastly/builtins/fetch/fetch.cpp | 40 ------------ 3 files changed, 108 deletions(-) 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 index 9506e4ac61..97ec42c6a7 100644 --- a/integration-tests/js-compute/fixtures/app/src/stale-if-error.js +++ b/integration-tests/js-compute/fixtures/app/src/stale-if-error.js @@ -224,70 +224,6 @@ routes.set('/stale-if-error/fetch/zero-staleIfError', async () => { ); }); -// Use cache key override to make different URLs share cache -// This tests the core stale-if-error functionality: -// 1. Cache a 200 response with short TTL and long stale-if-error window -// 2. Wait for TTL to expire (response becomes stale) -// 3. Request a URL that returns 503, but with the SAME cache key -// 4. Verify the stale 200 response is served instead of the 503 -routes.set( - '/stale-if-error/fetch/serve-stale-on-backend-error-with-cache-key', - async () => { - const sharedCacheKey = 'stale-if-error-test-shared-key-' + 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?status=200', - { - 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 same cache key but URL that returns 503 - const errorRequest = new Request('https://http-me.fastly.dev/status=503', { - backend: 'httpme', - cacheOverride: new CacheOverride('override', { - staleIfError: 3600, - }), - }); - errorRequest.setCacheKey(sharedCacheKey); - - const staleResponse = await fetch(errorRequest); - - // Step 4: Verify we got the stale 200 response, not the 503 - assert( - staleResponse.status, - 200, - 'Stale-if-error serves cached 200 response instead of backend 503', - ); - - const cachedBody = await staleResponse.text(); - strictEqual( - cachedBody, - initialBody, - 'Response body is from the original cached 200 response', - ); - - strictEqual( - staleResponse.maskedError.status, - 503, - 'The masked error on the response is the original backend error (503)', - ); - }, -); - routes.set( '/stale-if-error/fetch/serve-stale-on-afterSend-exception', async () => { diff --git a/integration-tests/js-compute/fixtures/app/tests.json b/integration-tests/js-compute/fixtures/app/tests.json index cc46402e21..9d51211cd2 100644 --- a/integration-tests/js-compute/fixtures/app/tests.json +++ b/integration-tests/js-compute/fixtures/app/tests.json @@ -3256,10 +3256,6 @@ "environments": ["compute"], "features": ["http-cache"] }, - "GET /stale-if-error/fetch/serve-stale-on-backend-error-with-cache-key": { - "environments": ["compute"], - "features": ["http-cache"] - }, "GET /stale-if-error/fetch/serve-stale-on-afterSend-exception": { "environments": ["compute"], "features": ["http-cache"] diff --git a/runtime/fastly/builtins/fetch/fetch.cpp b/runtime/fastly/builtins/fetch/fetch.cpp index 25b4196475..0ba93544af 100644 --- a/runtime/fastly/builtins/fetch/fetch.cpp +++ b/runtime/fastly/builtins/fetch/fetch.cpp @@ -925,46 +925,6 @@ bool stream_back_then_handler(JSContext *cx, JS::HandleObject request, JS::Handl auto cache_write_options = Response::override_cache_options(response_obj); MOZ_ASSERT(cache_write_options); - // Check if we should use stale-if-error response instead of this error response - auto state_res = cache_entry.get_state(); - if (!state_res.is_err()) { - auto cache_state = state_res.unwrap(); - auto status = Response::status(response_obj); - if (cache_state.is_usable_if_error() && (status >= 500 && status < 600)) { - // Use the stale response instead of the error response - auto chose_stale_res = cache_entry.transaction_choose_stale(); - if (auto *err = chose_stale_res.to_err()) { - HANDLE_ERROR(cx, *err); - return false; - } - - 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 false; - } - - if (maybe_response.has_value()) { - JS::RootedObject stale_response(cx, maybe_response.value()); - - // Store the error as a masked error on the response - JS::RootedValue error_response_val(cx, JS::ObjectValue(*response_obj)); - JS_SetReservedSlot(stale_response, static_cast(Response::Slots::MaskedError), - error_response_val); - - RequestOrResponse::take_cache_entry(stale_response, true); - if (!Response::add_fastly_cache_headers(cx, stale_response, request, cache_entry, - "cached response")) { - return false; - } - - // Return the stale response - args.rval().setObject(*stale_response); - return true; - } - } - } - switch (storage_action) { case host_api::HttpStorageAction::Insert: { auto insert_res = cache_entry.transaction_insert_and_stream_back( From 27c9332599f8d39328fb8404b30b7549695630f7 Mon Sep 17 00:00:00 2001 From: Sy Brand Date: Mon, 13 Apr 2026 14:43:57 +0100 Subject: [PATCH 35/36] Feedback --- runtime/fastly/builtins/fetch/fetch.cpp | 15 +++------------ 1 file changed, 3 insertions(+), 12 deletions(-) diff --git a/runtime/fastly/builtins/fetch/fetch.cpp b/runtime/fastly/builtins/fetch/fetch.cpp index 0ba93544af..9729492c05 100644 --- a/runtime/fastly/builtins/fetch/fetch.cpp +++ b/runtime/fastly/builtins/fetch/fetch.cpp @@ -1161,11 +1161,7 @@ std::optional try_serve_stale_if_error(JSContext *cx, 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; - } - - if (!maybe_response.has_value()) { + if (!maybe_response.has_value() || !maybe_response.value()) { return std::nullopt; } @@ -1261,13 +1257,8 @@ bool fetch(JSContext *cx, unsigned argc, Value *vp) { override_key_hash.begin(), override_key_hash.end()); } - host_api::Result transaction_res; - if (override_key_hash.empty()) { - transaction_res = host_api::HttpCacheEntry::transaction_lookup(request_handle, {}); - } else { - transaction_res = host_api::HttpCacheEntry::transaction_lookup( - request_handle, std::span{override_key_hash.data(), override_key_hash.size()}); - } + 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") From e2ea2829132d86251e032580afea980b86a31004 Mon Sep 17 00:00:00 2001 From: Sy Brand Date: Wed, 29 Apr 2026 12:49:18 +0100 Subject: [PATCH 36/36] Deduplicate code --- runtime/fastly/builtins/fetch/fetch.cpp | 37 ++++++------------------- 1 file changed, 8 insertions(+), 29 deletions(-) diff --git a/runtime/fastly/builtins/fetch/fetch.cpp b/runtime/fastly/builtins/fetch/fetch.cpp index 9729492c05..6b2bef0efb 100644 --- a/runtime/fastly/builtins/fetch/fetch.cpp +++ b/runtime/fastly/builtins/fetch/fetch.cpp @@ -1371,41 +1371,20 @@ bool fetch(JSContext *cx, unsigned argc, Value *vp) { // 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)) { - if (cache_state.is_usable_if_error()) { - // We've got a stale-if-error response, so swap it out for the error and notify any - // request collapse followers. - auto chose_stale_res = cache_entry.transaction_choose_stale(); - if (auto *err = chose_stale_res.to_err()) { - HANDLE_ERROR(cx, *err); - return false; - } - auto maybe_response = get_found_response(cx, cache_entry, request, no_candidate, false); - if (maybe_response.has_value() && !maybe_response.value()) { - return false; - } - JS::RootedValue exception(cx); - if (!JS_GetPendingException(cx, &exception)) { - return false; - } + JS::RootedValue exception(cx); + if (JS_GetPendingException(cx, &exception)) { JS_ClearPendingException(cx); - JS::RootedObject cached_response(cx, maybe_response.value()); - JS_SetReservedSlot(cached_response, static_cast(Response::Slots::MaskedError), - exception); - - RequestOrResponse::take_cache_entry(cached_response, true); - if (!Response::add_fastly_cache_headers(cx, cached_response, request, cache_entry, - "cached response")) { - return false; - } - + } + 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); - } else { - RequestOrResponse::close_if_cache_entry(cx, request); - return false; } + RequestOrResponse::close_if_cache_entry(cx, request); + return false; } JS::RootedObject stream_back_promise_obj(cx, &stream_back_promise.toObject()); JS::RootedObject ret_promise(