Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
36 changes: 33 additions & 3 deletions src/HTTPCache.ts
Original file line number Diff line number Diff line change
Expand Up @@ -30,6 +30,18 @@ interface SneakyCachePolicy extends CachePolicy {
interface ResponseWithCacheWritePromise {
response: FetcherResponse;
cacheWritePromise?: Promise<void>;
metrics: Metrics;
}

export interface Metrics {
// True if a revalidation request was done.
revalidated: boolean;
// True if the response came from the cache.
fromCache: boolean;
// How long the response will be cached in milliseconds when it didn't come from the cache.
timeToLive?: number;
// How long the response has been cached when it did come from the cache.
age?: number;
}

export class HTTPCache<CO extends CacheOptions = CacheOptions> {
Expand Down Expand Up @@ -62,17 +74,19 @@ export class HTTPCache<CO extends CacheOptions = CacheOptions> {
httpCacheSemanticsCachePolicyOptions?: HttpCacheSemanticsOptions;
},
): Promise<ResponseWithCacheWritePromise> {
const metrics: Metrics = { fromCache: false, revalidated: false, };
const urlString = url.toString();
requestOpts.method = requestOpts.method ?? 'GET';
const cacheKey = cache?.cacheKey ?? urlString;


// Bypass the cache altogether for HEAD requests. Caching them might be fine
// to do, but for now this is just a pragmatic choice for timeliness without
// fully understanding the interplay between GET and HEAD requests (i.e.
// refreshing headers with HEAD requests, responding to HEADs with cached
// and valid GETs, etc.)
if (requestOpts.method === 'HEAD') {
return { response: await this.httpFetch(urlString, requestOpts) };
return { response: await this.httpFetch(urlString, requestOpts), metrics };
}

const entry = await this.keyValueCache.get(cacheKey);
Expand All @@ -93,6 +107,7 @@ export class HTTPCache<CO extends CacheOptions = CacheOptions> {
requestOpts,
policy,
cacheKey,
metrics,
cache?.cacheOptions,
);
}
Expand All @@ -117,13 +132,16 @@ export class HTTPCache<CO extends CacheOptions = CacheOptions> {
// `ttl` returned from `cacheOptionsFor`) and we're within that TTL, or
// the cache entry was not created with an explicit TTL override and the
// header-based cache policy says we can safely use the cached response.
metrics.fromCache = true;
metrics.age = policy.age();
const headers = policy.responseHeaders();
return {
response: new NodeFetchResponse(body, {
url: urlFromPolicy,
status: policy._status,
headers: cachePolicyHeadersToNodeFetchHeadersInit(headers),
}),
metrics,
};
} else {
// We aren't sure that we're allowed to use the cached response, so we are
Expand Down Expand Up @@ -158,6 +176,12 @@ export class HTTPCache<CO extends CacheOptions = CacheOptions> {
policyResponseFrom(revalidationResponse),
) as unknown as { policy: SneakyCachePolicy; modified: boolean };

if (!modified) {
metrics.fromCache = true;
metrics.age = revalidatedPolicy.age();
}
metrics.revalidated = true;

return this.storeResponseAndReturnClone(
urlString,
new NodeFetchResponse(
Expand All @@ -173,6 +197,7 @@ export class HTTPCache<CO extends CacheOptions = CacheOptions> {
requestOpts,
revalidatedPolicy,
cacheKey,
metrics,
cache?.cacheOptions,
);
}
Expand All @@ -184,6 +209,7 @@ export class HTTPCache<CO extends CacheOptions = CacheOptions> {
request: RequestOptions<CO>,
policy: SneakyCachePolicy,
cacheKey: string,
metrics: Omit<Metrics, 'timeToLive'>,
cacheOptions?:
| CO
| ((
Expand All @@ -204,14 +230,14 @@ export class HTTPCache<CO extends CacheOptions = CacheOptions> {
// Without an override, we only cache GET requests and respect standard HTTP cache semantics
!(request.method === 'GET' && policy.storable())
) {
return { response };
return { response, metrics };
}

let ttl =
ttlOverride === undefined
? Math.round(policy.timeToLive() / 1000)
: ttlOverride;
if (ttl <= 0) return { response };
if (ttl <= 0) return { response, metrics };

// If a response can be revalidated, we don't want to remove it from the
// cache right after it expires. (See the comment above the call to
Expand Down Expand Up @@ -247,6 +273,10 @@ export class HTTPCache<CO extends CacheOptions = CacheOptions> {
ttlOverride,
cacheKey,
}),
metrics: {
...metrics,
timeToLive: ttl,
}
};
}

Expand Down
6 changes: 4 additions & 2 deletions src/RESTDataSource.ts
Original file line number Diff line number Diff line change
Expand Up @@ -10,7 +10,7 @@ import { GraphQLError } from 'graphql';
import type { Options as HttpCacheSemanticsOptions } from 'http-cache-semantics';
import cloneDeep from 'lodash.clonedeep';
import isPlainObject from 'lodash.isplainobject';
import { HTTPCache } from './HTTPCache';
import {HTTPCache, Metrics} from './HTTPCache';

export type ValueOrPromise<T> = T | Promise<T>;

Expand Down Expand Up @@ -149,6 +149,7 @@ export interface RequestDeduplicationResult {
export interface HTTPCacheResult {
// This is primarily returned so that tests can be deterministic.
cacheWritePromise: Promise<void> | undefined;
metrics: Metrics;
}
export interface DataSourceFetchResult<TResult> {
parsedBody: TResult;
Expand Down Expand Up @@ -531,7 +532,7 @@ export abstract class RESTDataSource<CO extends CacheOptions = CacheOptions> {
? outgoingRequest.cacheOptions
: this.cacheOptionsFor?.bind(this);
try {
const { response, cacheWritePromise } = await this.httpCache.fetch(
const { response, cacheWritePromise, metrics } = await this.httpCache.fetch(
url,
outgoingRequest,
{
Expand Down Expand Up @@ -560,6 +561,7 @@ export abstract class RESTDataSource<CO extends CacheOptions = CacheOptions> {
response,
httpCache: {
cacheWritePromise,
metrics,
},
};
} catch (error) {
Expand Down