@@ -154,7 +154,19 @@ async function resolveRateLimit(
154154 return { config : parsedOverride . data , identifier } satisfies ResolvedRateLimit ;
155155 } ) ;
156156
157- return cacheResult . val ?? { config : defaultLimiter } ;
157+ // Defensive read: the cache is keyed on a shared Redis namespace, so during a
158+ // deploy an entry could have been written by a server running a different
159+ // code version (a different stored shape). Re-validate here so a stale/foreign
160+ // entry can never reach createLimiterFromConfig with an undefined config and
161+ // throw. The cache key is also versioned (see RedisCacheStore keyPrefix), so
162+ // this is belt-and-suspenders.
163+ const cached = cacheResult . val ;
164+ const parsedConfig = RateLimiterConfig . safeParse ( cached ?. config ) ;
165+
166+ return {
167+ config : parsedConfig . success ? parsedConfig . data : defaultLimiter ,
168+ identifier : typeof cached ?. identifier === "string" ? cached . identifier : undefined ,
169+ } ;
158170}
159171
160172/**
@@ -188,7 +200,10 @@ export function authorizationRateLimitMiddleware({
188200 const memory = createLRUMemoryStore ( limiterCache ?. maxItems ?? 1000 ) ;
189201 const redisCacheStore = new RedisCacheStore ( {
190202 connection : {
191- keyPrefix : `cache:${ keyPrefix } :rate-limit-cache:` ,
203+ // Versioned namespace: the cached value shape is part of this key. Bump
204+ // the version whenever ResolvedRateLimit changes so a rolling deploy never
205+ // reads entries written in a previous shape (and vice versa).
206+ keyPrefix : `cache:${ keyPrefix } :rate-limit-cache:v2:` ,
192207 ...redis ,
193208 } ,
194209 } ) ;
0 commit comments