Skip to content
Merged
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
42 changes: 33 additions & 9 deletions src/selfhost/redis-response-cache.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,25 +6,40 @@
// NOT rate-limit headers (a cache hit consumed no quota) or content-encoding (the body is decoded).
import type { Redis } from "ioredis";
import type { CachedGitHubResponse, GitHubResponseCache } from "../github/client";
import { incr } from "./metrics";

const REDIS_GITHUB_RESPONSE_CACHE_METRIC = "gittensory_redis_gh_response_cache_total";
const keyFor = (key: string): string => `gh:resp:${key}`;

function isReplayableCachedStatus(status: unknown): status is number {
return status === 200 || status === 403 || status === 404;
}

function recordRedisResponseCacheMetric(result: "hit" | "miss" | "set" | "error"): void {
incr(REDIS_GITHUB_RESPONSE_CACHE_METRIC, { result });
}

export function createRedisResponseCache(
redis: Redis,
ttlSeconds: number,
): GitHubResponseCache {
return {
async get(key: string) {
const raw = await redis.get(keyFor(key));
if (!raw) return null;
let raw: string | null;
try {
raw = await redis.get(keyFor(key));
} catch (error) {
recordRedisResponseCacheMetric("error");
throw error;
}
if (!raw) {
recordRedisResponseCacheMetric("miss");
return null;
}
try {
const value = JSON.parse(raw) as Partial<CachedGitHubResponse>;
const status = value.status;
return isReplayableCachedStatus(status) &&
const cached = isReplayableCachedStatus(status) &&
typeof value.body === "string" &&
typeof value.contentType === "string"
? {
Expand All @@ -36,17 +51,26 @@ export function createRedisResponseCache(
...(typeof value.lastModified === "string" ? { lastModified: value.lastModified } : {}),
}
: null;
recordRedisResponseCacheMetric(cached ? "hit" : "miss");
return cached;
} catch {
recordRedisResponseCacheMetric("miss");
return null;
}
},
async set(key: string, value: CachedGitHubResponse, ttlOverrideSeconds?: number) {
await redis.set(
keyFor(key),
JSON.stringify(value),
"EX",
Math.max(1, ttlOverrideSeconds ?? ttlSeconds),
);
try {
await redis.set(
keyFor(key),
JSON.stringify(value),
"EX",
Math.max(1, ttlOverrideSeconds ?? ttlSeconds),
);
} catch (error) {
recordRedisResponseCacheMetric("error");
throw error;
}
recordRedisResponseCacheMetric("set");
},
};
}
58 changes: 57 additions & 1 deletion test/unit/selfhost-redis-response-cache.test.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
import type { Redis } from "ioredis";
import { describe, expect, it } from "vitest";
import { afterEach, describe, expect, it } from "vitest";
import { renderMetrics, resetMetrics } from "../../src/selfhost/metrics";
import { createRedisResponseCache } from "../../src/selfhost/redis-response-cache";

function fakeRedis(): {
Expand All @@ -24,11 +25,16 @@ function fakeRedis(): {

const URL_A = "https://github.com/@api/repos/o/r/pulls/1";

afterEach(() => resetMetrics());

describe("createRedisResponseCache (#perf GitHub GET cache)", () => {
it("get returns null for a missing url", async () => {
expect(
await createRedisResponseCache(fakeRedis().redis, 20).get(URL_A),
).toBeNull();
expect(await renderMetrics()).toContain(
'gittensory_redis_gh_response_cache_total{result="miss"} 1',
);
});

it("set then get round-trips status/body/content-type with the configured TTL", async () => {
Expand All @@ -51,6 +57,13 @@ describe("createRedisResponseCache (#perf GitHub GET cache)", () => {
etag: '"abc123"',
lastModified: "Mon, 29 Jun 2026 20:00:00 GMT",
});
const metrics = await renderMetrics();
expect(metrics).toContain(
'gittensory_redis_gh_response_cache_total{result="set"} 1',
);
expect(metrics).toContain(
'gittensory_redis_gh_response_cache_total{result="hit"} 1',
);
});

it("replays cached branch-protection permission denials and missing resources", async () => {
Expand Down Expand Up @@ -105,12 +118,18 @@ describe("createRedisResponseCache (#perf GitHub GET cache)", () => {
const f = fakeRedis();
f.store.set("gh:resp:" + URL_A, "{nope");
expect(await createRedisResponseCache(f.redis, 20).get(URL_A)).toBeNull();
expect(await renderMetrics()).toContain(
'gittensory_redis_gh_response_cache_total{result="miss"} 1',
);
});

it("get returns null when the stored shape is wrong", async () => {
const f = fakeRedis();
f.store.set("gh:resp:" + URL_A, JSON.stringify({ status: "200", body: 1 }));
expect(await createRedisResponseCache(f.redis, 20).get(URL_A)).toBeNull();
expect(await renderMetrics()).toContain(
'gittensory_redis_gh_response_cache_total{result="miss"} 1',
);
});

it("get returns null for non-replayable cached responses", async () => {
Expand Down Expand Up @@ -178,5 +197,42 @@ describe("createRedisResponseCache (#perf GitHub GET cache)", () => {
body: "{}",
contentType: "application/json",
});
expect(await renderMetrics()).toContain(
'gittensory_redis_gh_response_cache_total{result="hit"} 1',
);
});

it("records and rethrows Redis read errors", async () => {
const redis = {
async get() {
throw new Error("redis read failed");
},
} as unknown as Redis;

await expect(createRedisResponseCache(redis, 20).get(URL_A)).rejects.toThrow(
"redis read failed",
);
expect(await renderMetrics()).toContain(
'gittensory_redis_gh_response_cache_total{result="error"} 1',
);
});

it("records and rethrows Redis write errors", async () => {
const redis = {
async set() {
throw new Error("redis write failed");
},
} as unknown as Redis;

await expect(
createRedisResponseCache(redis, 20).set(URL_A, {
status: 200,
body: "{}",
contentType: "application/json",
}),
).rejects.toThrow("redis write failed");
expect(await renderMetrics()).toContain(
'gittensory_redis_gh_response_cache_total{result="error"} 1',
);
});
});
Loading