MiniSearch / server /authorizationSinceLastRestart.ts
github-actions[bot]
Sync from https://github.com/felladrin/MiniSearch
14ab2af
Raw
History Blame Contribute Delete
3.2 kB
/**
* Aggregate counters for the requests that reached token verification.
*
* The rate limiter keys on the client IP, so that is the one thing these must
* not keep: no address, no token, no query, and no timestamp per request. What
* is left says whether the instance is being abused, whether a client is
* broken, and how the shared request budget is spent across the endpoints that
* share it, none of which needs to know who asked.
*/
import { getRejectedTokenCacheHits } from "./rejectedTokens.ts";
import {
RATE_LIMIT_DURATION_SECONDS,
RATE_LIMIT_POINTS,
type RejectionReason,
THUMBNAIL_RATE_LIMIT_DURATION_SECONDS,
THUMBNAIL_RATE_LIMIT_POINTS,
} from "./verifyTokenAndRateLimit.ts";
/**
* Which endpoint family a request was aimed at. `other` is what keeps the
* totals closed when a new endpoint starts using the same verification funnel
* and nobody extends this list.
*/
export type AuthorizationSurface =
| "search"
| "pageContent"
| "thumbnail"
| "inference"
| "other";
interface SurfaceCounts {
authorized: number;
rejected: number;
}
let authorized = 0;
const reasons: Record<RejectionReason, number> = {
rateLimited: 0,
missingToken: 0,
invalidToken: 0,
};
const bySurface: Record<AuthorizationSurface, SurfaceCounts> = {
search: { authorized: 0, rejected: 0 },
pageContent: { authorized: 0, rejected: 0 },
thumbnail: { authorized: 0, rejected: 0 },
inference: { authorized: 0, rejected: 0 },
other: { authorized: 0, rejected: 0 },
};
export function recordAuthorizedRequest(surface: AuthorizationSurface): void {
authorized++;
bySurface[surface].authorized++;
}
export function recordRejectedRequest(
surface: AuthorizationSurface,
reason: RejectionReason,
): void {
reasons[reason]++;
bySurface[surface].rejected++;
}
/**
* `authorized` plus every entry of `reasons` sums to `requests`, and each half
* of `bySurface` sums to its side of that, so a request that goes uncounted
* shows up as a gap rather than being lost silently.
*
* The limiter's own settings ride along because the counts cannot be read
* without them: 40 rate-limited requests means something different at 10
* points per 10 seconds than it would at 100.
*/
export function getAuthorizationStats() {
const rejected = Object.values(reasons).reduce(
(total, count) => total + count,
0,
);
const requests = authorized + rejected;
return {
requests,
authorized,
rejectedRate: Number(((rejected / requests) * 100 || 0).toFixed(1)),
rejectedTokenCacheHits: getRejectedTokenCacheHits(),
reasons: { ...reasons },
// Deep copy, so a caller holding a snapshot for comparison does not watch
// it change under them as later requests arrive.
bySurface: Object.fromEntries(
Object.entries(bySurface).map(([surface, counts]) => [
surface,
{ ...counts },
]),
) as Record<AuthorizationSurface, SurfaceCounts>,
limiter: {
points: RATE_LIMIT_POINTS,
durationSeconds: RATE_LIMIT_DURATION_SECONDS,
thumbnail: {
points: THUMBNAIL_RATE_LIMIT_POINTS,
durationSeconds: THUMBNAIL_RATE_LIMIT_DURATION_SECONDS,
},
},
};
}