From 64a116c1ed9f8a010856164e9b04edbfd0c7880e Mon Sep 17 00:00:00 2001 From: Damian Gryski Date: Tue, 24 Feb 2026 11:27:52 -0800 Subject: [PATCH 1/8] internal: add stale-if-error hostcall pieces --- fsthttp/cache.go | 83 +++++++++++++++++++----- fsthttp/request.go | 6 ++ fsthttp/response.go | 11 +++- internal/abi/fastly/hostcalls_noguest.go | 12 ++++ internal/abi/fastly/httpcache_guest.go | 77 +++++++++++++++++++++- internal/abi/fastly/types.go | 9 +++ 6 files changed, 181 insertions(+), 17 deletions(-) diff --git a/fsthttp/cache.go b/fsthttp/cache.go index 1da242f..6f221ef 100644 --- a/fsthttp/cache.go +++ b/fsthttp/cache.go @@ -27,6 +27,9 @@ type CandidateResponse struct { overrideStaleWhileRevalidate uint32 // seconds useSWR bool + overrideStaleIfError uint32 // seconds + useSIE bool + extraSurrogateKeys string overrideSurrogateKeys string useSurrogate bool @@ -47,15 +50,16 @@ type cacheResponse struct { } type cacheWriteOptions struct { - maxAge uint32 // seconds - vary string - useVary bool - age uint32 // seconds - stale uint32 // seconds - surrogate string - length uint64 - useLength bool - sensitive bool + maxAge uint32 // seconds + vary string + useVary bool + age uint32 // seconds + staleRevalidate uint32 // seconds + surrogate string + length uint64 + useLength bool + sensitive bool + staleError uint32 abiOpts fastly.HTTPCacheWriteOptions } @@ -69,9 +73,10 @@ func (opts *cacheWriteOptions) flushToABI() { opts.abiOpts.SetMaxAgeNs(u32sTou64ns(opts.maxAge)) opts.abiOpts.SetVaryRule(opts.vary) opts.abiOpts.SetInitialAgeNs(u32sTou64ns(opts.age)) - opts.abiOpts.SetStaleWhileRevalidateNs(u32sTou64ns(opts.stale)) + opts.abiOpts.SetStaleWhileRevalidateNs(u32sTou64ns(opts.staleRevalidate)) opts.abiOpts.SetSurrogateKeys(opts.surrogate) opts.abiOpts.SetSensitiveData(opts.sensitive) + opts.abiOpts.SetStaleIfErrorNs(u32sTou64ns(opts.staleError)) } func (opts *cacheWriteOptions) loadFromABI() { @@ -81,11 +86,15 @@ func (opts *cacheWriteOptions) loadFromABI() { opts.age = u64nsTou32s(ns) } if ns, ok := opts.abiOpts.StaleWhileRevalidateNs(); ok { - opts.stale = u64nsTou32s(ns) + opts.staleRevalidate = u64nsTou32s(ns) } opts.surrogate, _ = opts.abiOpts.SurrogateKeys() opts.length, opts.useLength = opts.abiOpts.Length() opts.sensitive = opts.abiOpts.SensitiveData() + + if ns, ok := opts.abiOpts.StaleIfErrorNs(); ok { + opts.staleError = u64nsTou32s(ns) + } } func (opts *cacheWriteOptions) loadFromHandle(c *fastly.HTTPCacheHandle) error { @@ -111,7 +120,7 @@ func (opts *cacheWriteOptions) loadFromHandle(c *fastly.HTTPCacheHandle) error { if ns, err := fastly.HTTPCacheGetStaleWhileRevalidateNs(c); err != nil { return fmt.Errorf("get stale while revalidate: %w", err) } else { - opts.stale = u64nsTou32s(uint64(ns)) + opts.staleRevalidate = u64nsTou32s(uint64(ns)) } opts.surrogate, err = fastly.HTTPCacheGetSurrogateKeys(c) @@ -136,6 +145,12 @@ func (opts *cacheWriteOptions) loadFromHandle(c *fastly.HTTPCacheHandle) error { return fmt.Errorf("get sensitive data: %w", err) } + if ns, err := fastly.HTTPCacheGetStaleIfErrorNs(c); err != nil { + return fmt.Errorf("get stale if error: %w", err) + } else { + opts.staleError = u64nsTou32s(uint64(ns)) + } + return nil } @@ -239,6 +254,7 @@ func newCandidate(c *fastly.HTTPCacheHandle, opts *CacheOptions, abiResp *fastly overrideStorageAction: 0, overridePCI: opts.PCI, overrideStaleWhileRevalidate: opts.StaleWhileRevalidate, + overrideStaleIfError: opts.StaleIfError, extraSurrogateKeys: opts.SurrogateKey, overrideSurrogateKeys: "", overrideTTL: opts.TTL, @@ -253,6 +269,10 @@ func newCandidate(c *fastly.HTTPCacheHandle, opts *CacheOptions, abiResp *fastly candidate.useSWR = true } + if candidate.overrideStaleIfError != 0 { + candidate.useSIE = true + } + if candidate.overridePCI { candidate.usePCI = true } @@ -403,7 +423,40 @@ func (candidateResponse *CandidateResponse) StaleWhileRevalidate() (uint32, erro if err != nil { return 0, err } - return opts.stale, nil + return opts.staleRevalidate, nil +} + +// SetStaleWhileRevalidate sets the time in seconds for which a cached item can be delivered stale if synchronous revalidation produces an error. +func (candidateResponse *CandidateResponse) SetStaleIfError(sie uint32) { + candidateResponse.overrideStaleIfError = sie + candidateResponse.useSIE = true +} + +// StaleIfError returns the time in seconds for which a cached item be delivered stale if synchronous revalidation produces an error. +func (candidateResponse *CandidateResponse) StaleIfError() (uint32, error) { + if candidateResponse.useSIE { + return candidateResponse.overrideStaleIfError, nil + } + opts, err := candidateResponse.getSuggestedCacheWriteOptions() + if err != nil { + return 0, err + } + return opts.staleError, nil +} + +// Returns whether there is a stale-if-error response available from the cache. +// +// A CandidateResponse represents an HTTP response returned from a Backend. However, it may be +// preferable to return a cached response rather than the Backend's response -- for instance, +// if the Backend's response is a 5xx error. +// +// This method returns true if there is a cached response that is within the stale-if-error +// period. If a stale-if-error response is available, and the after_send hook returns an +// error, the response from the Backend will not be cached, and the [Request::send] call will +// return the stale-if-error response. +func (candidateResponse *CandidateResponse) StaleIfErrorAvailable() bool { + state, _ := fastly.HTTPCacheGetState(candidateResponse.cacheHandle) + return state&fastly.CacheLookupStateUsableIfError == fastly.CacheLookupStateUsableIfError } // SetSensitive sets the caching behavior of this response to enable or disable PCI/HIPAA-compliant @@ -582,9 +635,9 @@ func (candidateResponse *CandidateResponse) finalizeOptions() (fastly.HTTPCacheS opts.age = suggestedCacheWriteOptions.age if candidateResponse.useSWR { - opts.stale = candidateResponse.overrideStaleWhileRevalidate + opts.staleRevalidate = candidateResponse.overrideStaleWhileRevalidate } else { - opts.stale = suggestedCacheWriteOptions.stale + opts.staleRevalidate = suggestedCacheWriteOptions.staleRevalidate } if candidateResponse.useVary { diff --git a/fsthttp/request.go b/fsthttp/request.go index 2c4908a..dc2101c 100644 --- a/fsthttp/request.go +++ b/fsthttp/request.go @@ -998,6 +998,12 @@ type CacheOptions struct { // bypass the cache. StaleWhileRevalidate uint32 + // The maximum duration after `max_age` during which the response may be delivered stale + // if synchronous revalidation produces an error. + // + // If this field is not set, the default value is zero. + StaleIfError uint32 + // SurrogateKey represents an explicit surrogate key for the request, which // will be added to any `Surrogate-Key` response headers received from the // backend. If nonempty, the request will not be forced to bypass the cache. diff --git a/fsthttp/response.go b/fsthttp/response.go index 7598507..9087770 100644 --- a/fsthttp/response.go +++ b/fsthttp/response.go @@ -236,7 +236,16 @@ func (resp *Response) StaleWhileRevalidate() (uint32, bool) { return 0, false } - return resp.cacheResponse.cacheWriteOptions.stale, true + return resp.cacheResponse.cacheWriteOptions.staleRevalidate, true +} + +// StaleIfError returns the time in seconds for which a cached item delivered stale if synchronous revalidation produces an error. +func (resp *Response) StaleIfError() (uint32, bool) { + if resp.wasWrittenToCache() { + return 0, false + } + + return resp.cacheResponse.cacheWriteOptions.staleError, true } // Vary returns the set of request headers for which the response may vary. diff --git a/internal/abi/fastly/hostcalls_noguest.go b/internal/abi/fastly/hostcalls_noguest.go index 988984e..e25b716 100644 --- a/internal/abi/fastly/hostcalls_noguest.go +++ b/internal/abi/fastly/hostcalls_noguest.go @@ -777,6 +777,10 @@ func (o *HTTPCacheWriteOptions) SetSensitiveData(sensitive bool) {} func (o *HTTPCacheWriteOptions) SensitiveData() bool { return false } +func (o *HTTPCacheWriteOptions) SetStaleIfErrorNs(staleIfErrorNs uint64) {} + +func (o *HTTPCacheWriteOptions) StaleIfErrorNs() (uint64, bool) { return 0, false } + func HTTPCacheTransactionInsert(h *HTTPCacheHandle, resp *HTTPResponse, opts *HTTPCacheWriteOptions) (*HTTPBody, error) { return nil, fmt.Errorf("not implemented") } @@ -793,6 +797,10 @@ func HTTPCacheTransactionUpdateAndReturnFresh(h *HTTPCacheHandle, resp *HTTPResp return nil, fmt.Errorf("not implemented") } +func HTTPCacheTransactionChooseStale(h *HTTPCacheHandle) error { + return fmt.Errorf("not implemented") +} + func HTTPCacheTransactionRecordNotCacheable(h *HTTPCacheHandle, opts *HTTPCacheWriteOptions) error { return fmt.Errorf("not implemented") } @@ -837,6 +845,10 @@ func HTTPCacheGetStaleWhileRevalidateNs(h *HTTPCacheHandle) (httpCacheDurationNs return 0, fmt.Errorf("not implemented") } +func HTTPCacheGetStaleIfErrorNs(h *HTTPCacheHandle) (httpCacheDurationNs, error) { + return 0, fmt.Errorf("not implemented") +} + func HTTPCacheGetAgeNs(h *HTTPCacheHandle) (httpCacheDurationNs, error) { return 0, fmt.Errorf("not implemented") } diff --git a/internal/abi/fastly/httpcache_guest.go b/internal/abi/fastly/httpcache_guest.go index 71d7fc4..2c6b14b 100644 --- a/internal/abi/fastly/httpcache_guest.go +++ b/internal/abi/fastly/httpcache_guest.go @@ -115,6 +115,15 @@ func (o *HTTPCacheWriteOptions) SensitiveData() bool { return o.mask&httpCacheWriteOptionsFlagSensitiveData == httpCacheWriteOptionsFlagSensitiveData } +func (o *HTTPCacheWriteOptions) SetStaleIfErrorNs(staleNs uint64) { + o.opts.staleIfErrorNs = httpCacheDurationNs(staleNs) + o.mask |= httpCacheWriteOptionsFlagStaleIfError +} + +func (o *HTTPCacheWriteOptions) StaleIfErrorNs() (uint64, bool) { + return uint64(o.opts.staleIfErrorNs), o.mask&httpCacheWriteOptionsFlagStaleIfError == httpCacheWriteOptionsFlagStaleIfError +} + func (o *HTTPCacheWriteOptions) FillConfigMask() { o.mask = 0 | httpCacheWriteOptionsFlagReserved | @@ -123,7 +132,8 @@ func (o *HTTPCacheWriteOptions) FillConfigMask() { httpCacheWriteOptionsFlagStaleWhileRevalidate | httpCacheWriteOptionsFlagSurrogateKeys | httpCacheWriteOptionsFlagLength | - httpCacheWriteOptionsFlagSensitiveData + httpCacheWriteOptionsFlagSensitiveData | + httpCacheWriteOptionsFlagStaleIfError } // (module $fastly_http_cache @@ -427,6 +437,42 @@ func HTTPCacheTransactionUpdateAndReturnFresh(h *HTTPCacheHandle, resp *HTTPResp return &HTTPCacheHandle{h: newh}, nil } +// witx: +// +// ;;; Fulfill an obligation to provide a response to the cache by selecting a stale-if-error response. +// ;;; +// ;;; A guest that is obligated to insert/update the cache may not be able to produce an acceptable +// ;;; response (e.g. unreachable backend, 5xx response). If the cache contains a response in the +// ;;; stale-if-error period, the guest may prefer to use that response rather than returning an error. +// ;;; +// ;;; `transaction_choose_stale` is an alternative to `transaction_update_and_return_fresh` or +// ;;; `transaction_insert_and_stream_back`. Like those methods, it completes a request collapse, +// ;;; providing the stale response to all collapsed transactions; and, after calling +// ;;; `transaction_choose_stale`, the cache handle provides the (stale) response to send to the client. +// ;;; +// ;;; However, `transaction_choose_stale` does not change the cached state. The next lookup will again +// ;;; collapse and/or get an obligation to revalidate. +// (@interface func (export "transaction_choose_stale") +// (param $handle $http_cache_handle) +// (result $err (expected (error $fastly_status))) +// ) +// +//go:wasmimport fastly_http_cache transaction_choose_stale +//go:noescape +func fastlyHTTPCacheTransactionChooseStale( + h httpCacheHandle, +) FastlyStatus + +func HTTPCacheTransactionChooseStale(h *HTTPCacheHandle) error { + if err := fastlyHTTPCacheTransactionChooseStale( + h.h, + ).toError(); err != nil { + return err + } + + return nil +} + // witx: // // ;;; Disable request collapsing and response caching for this cache entry. @@ -868,6 +914,35 @@ func HTTPCacheGetStaleWhileRevalidateNs(h *HTTPCacheHandle) (httpCacheDurationNs return d, nil } +// witx: +// +// ;;; Get the configured stale-if-error period of the found response in nanoseconds, +// ;;; returning the `$none` error if there was no response found. +// (@interface func (export "get_stale_if_error_ns") +// (param $handle $http_cache_handle) +// (result $err (expected $cache_duration_ns (error $fastly_status))) +// ) +// +//go:wasmimport fastly_http_cache get_stale_if_error_ns +//go:noescape +func fastlyHTTPCacheGetStaleIfErrorNs( + h httpCacheHandle, + d prim.Pointer[httpCacheDurationNs], +) FastlyStatus + +func HTTPCacheGetStaleIfErrorNs(h *HTTPCacheHandle) (httpCacheDurationNs, error) { + var d httpCacheDurationNs + + if err := fastlyHTTPCacheGetStaleIfErrorNs( + h.h, + prim.ToPointer(&d), + ).toError(); err != nil { + return 0, err + } + + return d, nil +} + // witx: // // ;;; Get the age of the found response in nanoseconds, returning the `$none` error if there was diff --git a/internal/abi/fastly/types.go b/internal/abi/fastly/types.go index e30d54b..5753d85 100644 --- a/internal/abi/fastly/types.go +++ b/internal/abi/fastly/types.go @@ -992,6 +992,8 @@ const ( CacheLookupStateUsable CacheLookupState = 0b0000_0010 // $usable CacheLookupStateStale CacheLookupState = 0b0000_0100 // $stale CacheLookupStateMustInsertOrUpdate CacheLookupState = 0b0000_1000 // $must_insert_or_update + CacheLookupStateUsableIfError CacheLookupState = 0b0001_0000 // $usable_if_error + CacheLookupStateCollapseError CacheLookupState = 0b0010_0000 // $collapse_error ) // witx: @@ -1883,6 +1885,12 @@ type httpCacheWriteOptions struct { // body have enough information to synthesize a `content-length` even before the complete // body is inserted to the cache. length httpCacheObjectLength + + // The maximum duration after `max_age` during which the response may be delivered stale + // if synchronous revalidation produces an error. + // + // If this field is not set, the default value is zero. + staleIfErrorNs httpCacheDurationNs } type httpCacheWriteOptionsMask prim.U32 @@ -1895,6 +1903,7 @@ const ( httpCacheWriteOptionsFlagSurrogateKeys httpCacheWriteOptionsMask = 1 << 4 httpCacheWriteOptionsFlagLength httpCacheWriteOptionsMask = 1 << 5 httpCacheWriteOptionsFlagSensitiveData httpCacheWriteOptionsMask = 1 << 6 + httpCacheWriteOptionsFlagStaleIfError httpCacheWriteOptionsMask = 1 << 7 ) // shielding.witx From 6937241c6441949a2a3f011a98a617dbb795d2c7 Mon Sep 17 00:00:00 2001 From: Damian Gryski Date: Fri, 27 Feb 2026 14:06:31 -0800 Subject: [PATCH 2/8] fsthttp: add stale-if-error logic --- fsthttp/cache.go | 35 +++++++++++++++-------------------- fsthttp/request.go | 32 ++++++++++++++++++++++++++------ fsthttp/response.go | 10 ++++++++++ fsthttp/senderror.go | 10 +++++++++- internal/abi/fastly/types.go | 9 ++++++++- 5 files changed, 68 insertions(+), 28 deletions(-) diff --git a/fsthttp/cache.go b/fsthttp/cache.go index 6f221ef..3453e0a 100644 --- a/fsthttp/cache.go +++ b/fsthttp/cache.go @@ -159,24 +159,15 @@ const ( cacheStorageActionInvalid = 0xffff ) -func httpCacheWait(c *fastly.HTTPCacheHandle) error { - _, err := fastly.HTTPCacheGetState(c) - if err != nil { - return fmt.Errorf("get state: %w", err) - } - return nil -} - -func httpCacheMustInsertOrUpdate(c *fastly.HTTPCacheHandle) (bool, error) { +func httpCacheWait(c *fastly.HTTPCacheHandle) (fastly.CacheLookupState, error) { state, err := fastly.HTTPCacheGetState(c) if err != nil { - return false, fmt.Errorf("get state: %w", err) - + return 0, fmt.Errorf("get state: %w", err) } - return state&fastly.CacheLookupStateMustInsertOrUpdate == fastly.CacheLookupStateMustInsertOrUpdate, nil + return state, nil } -func httpCacheGetFoundResponse(c *fastly.HTTPCacheHandle, req *Request, backend string, transformForClient bool) (*Response, error) { +func httpCacheGetFoundResponse(c *fastly.HTTPCacheHandle, req *Request, backend string, transformForClient bool, wasHit bool) (*Response, error) { abiResp, abiBody, err := fastly.HTTPCacheGetFoundResponse(c, transformForClient) if err != nil { if status, ok := fastly.IsFastlyError(err); ok && status == fastly.FastlyStatusNone { @@ -185,9 +176,13 @@ func httpCacheGetFoundResponse(c *fastly.HTTPCacheHandle, req *Request, backend return nil, fmt.Errorf("get found response: %w", err) } - hits, err := fastly.HTTPCacheGetHits(c) - if err != nil { - return nil, fmt.Errorf("get hits: %w", err) + var hits uint64 + if wasHit { + h, err := fastly.HTTPCacheGetHits(c) + if err != nil { + return nil, fmt.Errorf("get hits: %w", err) + } + hits = uint64(h) } var opts cacheWriteOptions @@ -204,7 +199,7 @@ func httpCacheGetFoundResponse(c *fastly.HTTPCacheHandle, req *Request, backend resp.cacheResponse = cacheResponse{ cacheWriteOptions: opts, storageAction: cacheStorageActionInvalid, - hits: uint64(hits), + hits: hits, } return resp, nil } @@ -426,7 +421,7 @@ func (candidateResponse *CandidateResponse) StaleWhileRevalidate() (uint32, erro return opts.staleRevalidate, nil } -// SetStaleWhileRevalidate sets the time in seconds for which a cached item can be delivered stale if synchronous revalidation produces an error. +// SetStaleIfError sets the time in seconds for which a cached item can be delivered stale if synchronous revalidation produces an error. func (candidateResponse *CandidateResponse) SetStaleIfError(sie uint32) { candidateResponse.overrideStaleIfError = sie candidateResponse.useSIE = true @@ -710,7 +705,7 @@ func (candidateResponse *CandidateResponse) applyAndStreamBack(req *Request) (*R } body.Close() - resp, err = httpCacheGetFoundResponse(readback, req, "", false) + resp, err = httpCacheGetFoundResponse(readback, req, "", false, true) if err != nil { return nil, fmt.Errorf("cache get found response: %w", err) } @@ -722,7 +717,7 @@ func (candidateResponse *CandidateResponse) applyAndStreamBack(req *Request) (*R } defer fastly.HTTPCacheTransactionClose(newch) - resp, err = httpCacheGetFoundResponse(newch, req, "", true) + resp, err = httpCacheGetFoundResponse(newch, req, "", true, true) if err != nil { return nil, fmt.Errorf("cache get found response: %w", err) } diff --git a/fsthttp/request.go b/fsthttp/request.go index dc2101c..438615d 100644 --- a/fsthttp/request.go +++ b/fsthttp/request.go @@ -635,12 +635,13 @@ func (req *Request) sendWithGuestCache(ctx context.Context, backend string) (*Re fastly.HTTPCacheTransactionClose(cacheHandle) } }() - if err := httpCacheWait(cacheHandle); err != nil { + state, err := httpCacheWait(cacheHandle) + if err != nil { return nil, err } // is there a "usable" cached response (i.e. fresh or within SWR period) - resp, err := httpCacheGetFoundResponse(cacheHandle, req, backend, true) + resp, err := httpCacheGetFoundResponse(cacheHandle, req, backend, true, true) if err != nil { return nil, err } @@ -650,7 +651,7 @@ func (req *Request) sendWithGuestCache(ctx context.Context, backend string) (*Re // if this is during SWR, we may be the "lucky winner" who is // tasked with performing a background revalidation - if ok, _ := httpCacheMustInsertOrUpdate(cacheHandle); ok { + if state.Has(fastly.CacheLookupStateMustInsertOrUpdate) { pending, err := req.sendAsyncForCaching(ctx, cacheHandle, backend) if err != nil { return nil, err @@ -672,7 +673,14 @@ func (req *Request) sendWithGuestCache(ctx context.Context, backend string) (*Re cacheHandle = nil } - // Meanwhile, whether fresh or in SWR, we can immediately return + if state.Has(fastly.CacheLookupStateUsableIfError) { + // This is a stale-if-error response that is also USABLE, implying the request + // collapse has already happened. + // Mark the response's masked error as "error in request collapse leader". + resp.maskedError = ErrRequestCollapse + } + + // Meanwhile, whether fresh or in SWR/SIE, we can immediately return // the cached response: resp.updateFastlyCacheHeaders(req) return resp, nil @@ -680,8 +688,7 @@ func (req *Request) sendWithGuestCache(ctx context.Context, backend string) (*Re // no cached response - if ok, _ := httpCacheMustInsertOrUpdate(cacheHandle); ok { - + if state.Has(fastly.CacheLookupStateMustInsertOrUpdate) { pending, err := req.sendAsyncForCaching(ctx, cacheHandle, backend) if err != nil { return nil, err @@ -689,6 +696,19 @@ func (req *Request) sendWithGuestCache(ctx context.Context, backend string) (*Re candidateResp, err := newCandidateFromPendingBackendCaching(pending) if err != nil { + if state.Has(fastly.CacheLookupStateUsableIfError) { + // Substitute stale-if-error response; let anyone else in the collapse know as + // well. + fastly.HTTPCacheTransactionChooseStale(cacheHandle) + resp, foundErr := httpCacheGetFoundResponse(cacheHandle, req, backend, true, false) + if foundErr != nil { + return nil, foundErr + } + resp.maskedError = err + resp.updateFastlyCacheHeaders(req) + return resp, nil + } + return nil, err } diff --git a/fsthttp/response.go b/fsthttp/response.go index 9087770..7959bd7 100644 --- a/fsthttp/response.go +++ b/fsthttp/response.go @@ -36,6 +36,10 @@ type Response struct { trailers Header + // If this response was served from the cache *and* the response was stale-if-error, + // this is the error from the revalidation attempt. + maskedError error + cacheResponse cacheResponse abi struct { @@ -113,6 +117,12 @@ func (resp *Response) Trailers() (Header, error) { return resp.trailers, nil } +// If this response was served from the cache *and* the response was stale-if-error, +// this is the error from the revalidation attempt. +func (r *Response) MaskedError() error { + return r.maskedError +} + type netaddr struct { ip net.IP port uint16 diff --git a/fsthttp/senderror.go b/fsthttp/senderror.go index f46a3e3..226c151 100644 --- a/fsthttp/senderror.go +++ b/fsthttp/senderror.go @@ -2,7 +2,11 @@ package fsthttp -import "github.com/fastly/compute-sdk-go/internal/abi/fastly" +import ( + "errors" + + "github.com/fastly/compute-sdk-go/internal/abi/fastly" +) // SendError provides detailed information about backend request failures. // @@ -131,3 +135,7 @@ const ( // error. SendErrorInternalError = fastly.SendErrorDetailTagInternalError ) + +var ( + ErrRequestCollapse = errors.New("error during request collapse") +) diff --git a/internal/abi/fastly/types.go b/internal/abi/fastly/types.go index 5753d85..85489b1 100644 --- a/internal/abi/fastly/types.go +++ b/internal/abi/fastly/types.go @@ -996,6 +996,10 @@ const ( CacheLookupStateCollapseError CacheLookupState = 0b0010_0000 // $collapse_error ) +func (c CacheLookupState) Has(m CacheLookupState) bool { + return (c & m) == m +} + // witx: // // (typename $purge_options_mask @@ -1383,6 +1387,7 @@ const ( SendErrorDetailTagInternalError SendErrorDetailTag = 22 SendErrorDetailTagTLSAlertReceived SendErrorDetailTag = 23 SendErrorDetailTagTLSProtocolError SendErrorDetailTag = 24 + SendErrorDetailTagH2Error SendErrorDetailTag = 25 ) // witx: @@ -1406,6 +1411,7 @@ const ( sendErrorDetailMaskDNSErrorRCode = 1 << 1 // $dns_error_rcode sendErrorDetailMaskDNSErrorInfo = 1 << 2 // $dns_error_info_code sendErrorDetailMaskTLSAlertID = 1 << 3 // $tls_alert_id + sendErrorDetailMaskH2Error = 1 << 4 // $h2_error ) // witx: @@ -1544,7 +1550,8 @@ func (d SendErrorDetail) String() string { return fmt.Sprintf("TLS alert received (%s)", tlsAlertString(d.tlsAlertID)) case SendErrorDetailTagTLSProtocolError: return "TLS protocol error" - + case SendErrorDetailTagH2Error: + return "HTTP/2 error" case SendErrorDetailTagUninitialized: panic("should not be reached: SendErrorDetailTagUninitialized") case SendErrorDetailTagOK: From 45fd824a5a839d2afd04edfa12b7445c44b70038 Mon Sep 17 00:00:00 2001 From: Damian Gryski Date: Tue, 3 Mar 2026 11:09:58 -0800 Subject: [PATCH 3/8] internal: add h2 error details --- internal/abi/fastly/types.go | 12 ++++++++++++ 1 file changed, 12 insertions(+) diff --git a/internal/abi/fastly/types.go b/internal/abi/fastly/types.go index 85489b1..29a41b2 100644 --- a/internal/abi/fastly/types.go +++ b/internal/abi/fastly/types.go @@ -1423,6 +1423,8 @@ const ( // (field $dns_error_rcode u16) // (field $dns_error_info_code u16) // (field $tls_alert_id u8) +// (field $h2_error_frame u8) +// (field $h2_error_code u32) // )) // SendErrorDetail contains detailed error information from backend send operations. @@ -1432,6 +1434,8 @@ type SendErrorDetail struct { dnsErrorRCode prim.U16 dnsErrorInfoCode prim.U16 tlsAlertID prim.U8 + h2ErrorFrame prim.U8 + h2ErrorCode prim.U32 } func newSendErrorDetail() SendErrorDetail { @@ -1493,6 +1497,14 @@ func (d SendErrorDetail) TLSAlertID() uint8 { return uint8(d.tlsAlertID) } +func (d SendErrorDetail) H2ErrorFrame() uint8 { + return uint8(d.h2ErrorFrame) +} + +func (d SendErrorDetail) H2ErrorCode() uint32 { + return uint32(d.h2ErrorCode) +} + // TLSAlertDescription returns a human-readable description of the TLS alert. func (d SendErrorDetail) TLSAlertDescription() string { return tlsAlertString(d.tlsAlertID) From 69108c86e3566cc7cc754013c0c9d242fda0fa0d Mon Sep 17 00:00:00 2001 From: Damian Gryski Date: Wed, 8 Apr 2026 13:28:30 -0700 Subject: [PATCH 4/8] fsthttp: document unit for CacheOptions.StaleIfError --- fsthttp/request.go | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/fsthttp/request.go b/fsthttp/request.go index 438615d..0f6882d 100644 --- a/fsthttp/request.go +++ b/fsthttp/request.go @@ -1018,8 +1018,8 @@ type CacheOptions struct { // bypass the cache. StaleWhileRevalidate uint32 - // The maximum duration after `max_age` during which the response may be delivered stale - // if synchronous revalidation produces an error. + // The maximum duration in seconds after `max_age` during which the response + // may be delivered stale if synchronous revalidation produces an error. // // If this field is not set, the default value is zero. StaleIfError uint32 From aa717c3e44305f0e28cfc98dbe86fae8bd91aa3d Mon Sep 17 00:00:00 2001 From: Damian Gryski Date: Wed, 8 Apr 2026 13:29:09 -0700 Subject: [PATCH 5/8] fshttp: use state.Has() where needed --- fsthttp/cache.go | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/fsthttp/cache.go b/fsthttp/cache.go index 3453e0a..cadbd81 100644 --- a/fsthttp/cache.go +++ b/fsthttp/cache.go @@ -366,7 +366,7 @@ func (candidateResponse *CandidateResponse) IsStale() (bool, error) { if err != nil { return false, fmt.Errorf("get state: %w", err) } - return state&fastly.CacheLookupStateStale == fastly.CacheLookupStateStale, nil + return state.Has(fastly.CacheLookupStateStale), nil } // Age returns current age in seconds of the cached item, relative to the originating backend. @@ -451,7 +451,7 @@ func (candidateResponse *CandidateResponse) StaleIfError() (uint32, error) { // return the stale-if-error response. func (candidateResponse *CandidateResponse) StaleIfErrorAvailable() bool { state, _ := fastly.HTTPCacheGetState(candidateResponse.cacheHandle) - return state&fastly.CacheLookupStateUsableIfError == fastly.CacheLookupStateUsableIfError + return state.Has(fastly.CacheLookupStateUsableIfError) } // SetSensitive sets the caching behavior of this response to enable or disable PCI/HIPAA-compliant From cae31ed1506e4a7b711ae163f60d758215a45a07 Mon Sep 17 00:00:00 2001 From: Damian Gryski Date: Wed, 8 Apr 2026 13:29:30 -0700 Subject: [PATCH 6/8] fsthttp: fix documentation for CandidateResponse.StaleIfError() --- fsthttp/cache.go | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/fsthttp/cache.go b/fsthttp/cache.go index cadbd81..430fcaf 100644 --- a/fsthttp/cache.go +++ b/fsthttp/cache.go @@ -427,7 +427,7 @@ func (candidateResponse *CandidateResponse) SetStaleIfError(sie uint32) { candidateResponse.useSIE = true } -// StaleIfError returns the time in seconds for which a cached item be delivered stale if synchronous revalidation produces an error. +// StaleIfError returns the time in seconds for which a cached item will be delivered stale if synchronous revalidation produces an error. func (candidateResponse *CandidateResponse) StaleIfError() (uint32, error) { if candidateResponse.useSIE { return candidateResponse.overrideStaleIfError, nil From 0f0cf6f9de528417f3995710d477376320e01fe0 Mon Sep 17 00:00:00 2001 From: Damian Gryski Date: Wed, 8 Apr 2026 13:31:21 -0700 Subject: [PATCH 7/8] fshttp: cacheWriteOptions renames: staleRevalidate -> staleWhileRevalidate, staleError -> staleIfError --- fsthttp/cache.go | 40 ++++++++++++++++++++-------------------- fsthttp/response.go | 4 ++-- 2 files changed, 22 insertions(+), 22 deletions(-) diff --git a/fsthttp/cache.go b/fsthttp/cache.go index 430fcaf..38a1fe9 100644 --- a/fsthttp/cache.go +++ b/fsthttp/cache.go @@ -50,16 +50,16 @@ type cacheResponse struct { } type cacheWriteOptions struct { - maxAge uint32 // seconds - vary string - useVary bool - age uint32 // seconds - staleRevalidate uint32 // seconds - surrogate string - length uint64 - useLength bool - sensitive bool - staleError uint32 + maxAge uint32 // seconds + vary string + useVary bool + age uint32 // seconds + staleWhileRevalidate uint32 // seconds + surrogate string + length uint64 + useLength bool + sensitive bool + staleIfError uint32 // seconds abiOpts fastly.HTTPCacheWriteOptions } @@ -73,10 +73,10 @@ func (opts *cacheWriteOptions) flushToABI() { opts.abiOpts.SetMaxAgeNs(u32sTou64ns(opts.maxAge)) opts.abiOpts.SetVaryRule(opts.vary) opts.abiOpts.SetInitialAgeNs(u32sTou64ns(opts.age)) - opts.abiOpts.SetStaleWhileRevalidateNs(u32sTou64ns(opts.staleRevalidate)) + opts.abiOpts.SetStaleWhileRevalidateNs(u32sTou64ns(opts.staleWhileRevalidate)) opts.abiOpts.SetSurrogateKeys(opts.surrogate) opts.abiOpts.SetSensitiveData(opts.sensitive) - opts.abiOpts.SetStaleIfErrorNs(u32sTou64ns(opts.staleError)) + opts.abiOpts.SetStaleIfErrorNs(u32sTou64ns(opts.staleIfError)) } func (opts *cacheWriteOptions) loadFromABI() { @@ -86,14 +86,14 @@ func (opts *cacheWriteOptions) loadFromABI() { opts.age = u64nsTou32s(ns) } if ns, ok := opts.abiOpts.StaleWhileRevalidateNs(); ok { - opts.staleRevalidate = u64nsTou32s(ns) + opts.staleWhileRevalidate = u64nsTou32s(ns) } opts.surrogate, _ = opts.abiOpts.SurrogateKeys() opts.length, opts.useLength = opts.abiOpts.Length() opts.sensitive = opts.abiOpts.SensitiveData() if ns, ok := opts.abiOpts.StaleIfErrorNs(); ok { - opts.staleError = u64nsTou32s(ns) + opts.staleIfError = u64nsTou32s(ns) } } @@ -120,7 +120,7 @@ func (opts *cacheWriteOptions) loadFromHandle(c *fastly.HTTPCacheHandle) error { if ns, err := fastly.HTTPCacheGetStaleWhileRevalidateNs(c); err != nil { return fmt.Errorf("get stale while revalidate: %w", err) } else { - opts.staleRevalidate = u64nsTou32s(uint64(ns)) + opts.staleWhileRevalidate = u64nsTou32s(uint64(ns)) } opts.surrogate, err = fastly.HTTPCacheGetSurrogateKeys(c) @@ -148,7 +148,7 @@ func (opts *cacheWriteOptions) loadFromHandle(c *fastly.HTTPCacheHandle) error { if ns, err := fastly.HTTPCacheGetStaleIfErrorNs(c); err != nil { return fmt.Errorf("get stale if error: %w", err) } else { - opts.staleError = u64nsTou32s(uint64(ns)) + opts.staleIfError = u64nsTou32s(uint64(ns)) } return nil @@ -418,7 +418,7 @@ func (candidateResponse *CandidateResponse) StaleWhileRevalidate() (uint32, erro if err != nil { return 0, err } - return opts.staleRevalidate, nil + return opts.staleWhileRevalidate, nil } // SetStaleIfError sets the time in seconds for which a cached item can be delivered stale if synchronous revalidation produces an error. @@ -436,7 +436,7 @@ func (candidateResponse *CandidateResponse) StaleIfError() (uint32, error) { if err != nil { return 0, err } - return opts.staleError, nil + return opts.staleIfError, nil } // Returns whether there is a stale-if-error response available from the cache. @@ -630,9 +630,9 @@ func (candidateResponse *CandidateResponse) finalizeOptions() (fastly.HTTPCacheS opts.age = suggestedCacheWriteOptions.age if candidateResponse.useSWR { - opts.staleRevalidate = candidateResponse.overrideStaleWhileRevalidate + opts.staleWhileRevalidate = candidateResponse.overrideStaleWhileRevalidate } else { - opts.staleRevalidate = suggestedCacheWriteOptions.staleRevalidate + opts.staleWhileRevalidate = suggestedCacheWriteOptions.staleWhileRevalidate } if candidateResponse.useVary { diff --git a/fsthttp/response.go b/fsthttp/response.go index 7959bd7..fe287b0 100644 --- a/fsthttp/response.go +++ b/fsthttp/response.go @@ -246,7 +246,7 @@ func (resp *Response) StaleWhileRevalidate() (uint32, bool) { return 0, false } - return resp.cacheResponse.cacheWriteOptions.staleRevalidate, true + return resp.cacheResponse.cacheWriteOptions.staleWhileRevalidate, true } // StaleIfError returns the time in seconds for which a cached item delivered stale if synchronous revalidation produces an error. @@ -255,7 +255,7 @@ func (resp *Response) StaleIfError() (uint32, bool) { return 0, false } - return resp.cacheResponse.cacheWriteOptions.staleError, true + return resp.cacheResponse.cacheWriteOptions.staleIfError, true } // Vary returns the set of request headers for which the response may vary. From 2815678e0786b8ef7f5739c884c9684c18c60867 Mon Sep 17 00:00:00 2001 From: Damian Gryski Date: Wed, 8 Apr 2026 14:09:15 -0700 Subject: [PATCH 8/8] fsthttp: fix Go names for CandidateResponse.StaleIfErrorAvailable() doc --- fsthttp/cache.go | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/fsthttp/cache.go b/fsthttp/cache.go index 38a1fe9..b4c80d3 100644 --- a/fsthttp/cache.go +++ b/fsthttp/cache.go @@ -446,8 +446,8 @@ func (candidateResponse *CandidateResponse) StaleIfError() (uint32, error) { // if the Backend's response is a 5xx error. // // This method returns true if there is a cached response that is within the stale-if-error -// period. If a stale-if-error response is available, and the after_send hook returns an -// error, the response from the Backend will not be cached, and the [Request::send] call will +// period. If a stale-if-error response is available, and the AfterSend hook returns an +// error, the response from the Backend will not be cached, and the [Request.Send] call will // return the stale-if-error response. func (candidateResponse *CandidateResponse) StaleIfErrorAvailable() bool { state, _ := fastly.HTTPCacheGetState(candidateResponse.cacheHandle)