Skip to content

feat: add TTL cache for API response caching - #5

Closed
benaiad wants to merge 2 commits into
mainfrom
test/thinking-review
Closed

feat: add TTL cache for API response caching#5
benaiad wants to merge 2 commits into
mainfrom
test/thinking-review

Conversation

@benaiad

@benaiad benaiad commented Apr 27, 2026

Copy link
Copy Markdown
Owner

Adds a generic TTL cache with size-based eviction and a Cache-Control header parser.

@github-actions github-actions Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The PR adds a well-structured TTL cache class and Cache-Control header parser, but contains several correctness defects that could cause confusing behavior in production. The most impactful issues are: (1) has() reports expired entries as present while get() returns undefined for the same key — an inconsistent API contract; (2) ttlMs || this.defaultTtlMs uses logical-OR instead of nullish coalescing, so an explicit ttlMs=0 (intended immediate expiry) silently falls back to the default TTL; (3) parseCacheControl propagates NaN when max-age is non-numeric. There is also no test file for the new module. Risk level: medium.

5 finding(s): 0 error, 4 warning, 1 info

[WARNING] correctness

File: src/utils/cache.ts:36

has() delegates directly to Map.has() without checking expiry. This means has(key) returns true for expired entries that haven't been lazy-deleted yet, while get(key) returns undefined for the same key. This breaks the API contract — callers who guard on has() before calling get() will get unexpected undefined.

Suggestion: Reuse the expiry-check logic from get(): const entry = this.store.get(key); if (!entry) return false; if (Date.now() > entry.expiresAt) { this.store.delete(key); return false; } return true;


[WARNING] correctness

File: src/utils/cache.ts:32

ttlMs || this.defaultTtlMs uses logical-OR, which is falsy-coalescing. If a caller explicitly passes ttlMs: 0 (intending immediate or near-immediate expiry), the 0 is falsy and falls through to defaultTtlMs, silently caching for the default duration instead.

Suggestion: Use nullish coalescing: ttlMs ?? this.defaultTtlMs. This only falls back when ttlMs is null or undefined, preserving 0 as a valid value.


[WARNING] correctness

File: src/utils/cache.ts:76

Number(trimmed.split("=")[1]) produces NaN for malformed directives like max-age=abc. This NaN (multiplied by 1000) propagates into result.maxAge, and if used downstream as a TTL, Date.now() + NaN = NaN, making the entry effectively inaccessible via get() (since Date.now() > NaN is false).

Suggestion: Validate the parsed value before assignment: const parsed = Number(trimmed.split("=")[1]); if (Number.isFinite(parsed)) result.maxAge = parsed * 1000;


[WARNING] testing

File: src/utils/cache.ts

No test file accompanies this new utility module. The cache has non-trivial logic (lazy expiry, size-based eviction, header parsing with edge cases) that should be covered.

Suggestion: Add a test file (e.g., src/utils/cache.test.ts) covering: TTL expiry on get/has, eviction at max size, ttlMs=0 behavior, parseCacheControl with valid/invalid inputs, and the no-cache/no-store directives.


[INFO] correctness

File: src/utils/cache.ts:48

The size getter returns this.store.size, which includes expired entries that haven't been lazily cleaned up yet. Callers may see a size that doesn't reflect the number of actually-valid entries.

Suggestion: Document that size includes expired-but-not-yet-reaped entries, or consider filtering expired entries (though that would be O(n)).


Reviewed by pi-relay · View full run

Comment thread src/utils/cache.ts
});
}

has(key: string): boolean {

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[WARNING] correctness

has() delegates directly to Map.has() without checking expiry. This means has(key) returns true for expired entries that haven't been lazy-deleted yet, while get(key) returns undefined for the same key. This breaks the API contract — callers who guard on has() before calling get() will get unexpected undefined.

Suggestion: Reuse the expiry-check logic from get(): const entry = this.store.get(key); if (!entry) return false; if (Date.now() > entry.expiresAt) { this.store.delete(key); return false; } return true;

Comment thread src/utils/cache.ts Outdated
}
this.store.set(key, {
value,
expiresAt: Date.now() + (ttlMs || this.defaultTtlMs),

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[WARNING] correctness

ttlMs || this.defaultTtlMs uses logical-OR, which is falsy-coalescing. If a caller explicitly passes ttlMs: 0 (intending immediate or near-immediate expiry), the 0 is falsy and falls through to defaultTtlMs, silently caching for the default duration instead.

Suggestion: Use nullish coalescing: ttlMs ?? this.defaultTtlMs. This only falls back when ttlMs is null or undefined, preserving 0 as a valid value.

Comment thread src/utils/cache.ts Outdated
result.noCache = true;
}
if (trimmed.startsWith("max-age=")) {
result.maxAge = Number(trimmed.split("=")[1]) * 1000;

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[WARNING] correctness

Number(trimmed.split("=")[1]) produces NaN for malformed directives like max-age=abc. This NaN (multiplied by 1000) propagates into result.maxAge, and if used downstream as a TTL, Date.now() + NaN = NaN, making the entry effectively inaccessible via get() (since Date.now() > NaN is false).

Suggestion: Validate the parsed value before assignment: const parsed = Number(trimmed.split("=")[1]); if (Number.isFinite(parsed)) result.maxAge = parsed * 1000;

Comment thread src/utils/cache.ts
this.store.clear();
}

get size(): number {

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[INFO] correctness

The size getter returns this.store.size, which includes expired entries that haven't been lazily cleaned up yet. Callers may see a size that doesn't reflect the number of actually-valid entries.

Suggestion: Document that size includes expired-but-not-yet-reaped entries, or consider filtering expired entries (though that would be O(n)).

@github-actions

Copy link
Copy Markdown

AI Review: Fixes Applied

Reviewed and found 5 issue(s). All addressed.

Changes Made

Fixed all 4 warning-severity findings in src/utils/cache.ts:

  1. has() now checks expiry before returning true (was delegating to Map.has() which ignores expiry).
  2. set() now uses ttlMs ?? this.defaultTtlMs instead of ttlMs || this.defaultTtlMs to correctly handle ttlMs=0.
  3. parseCacheControl() now validates parsed max-age values with Number.isFinite() before assignment, preventing NaN propagation.
  4. Added test/utils/cache.test.ts with 15 tests covering TTL expiry, eviction, ttlMs=0, parseCacheControl edge cases, and no-cache/no-store directives.

The info-severity finding about size getter including expired entries was not addressed (documented as known behavior per the suggestion). All changes verified with npm run check (tsc + biome) and vitest --run.

Verification passed.


View full run

@github-actions
github-actions Bot dismissed their stale review April 27, 2026 06:40

Superseded by new review.

@benaiad

benaiad commented Apr 27, 2026

Copy link
Copy Markdown
Owner Author

Test PR — verified end-to-end flow with thinking=high. Closing.

@benaiad benaiad closed this Apr 27, 2026
@benaiad
benaiad deleted the test/thinking-review branch April 27, 2026 06:53
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant