Skip to content
Closed
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
2 changes: 1 addition & 1 deletion apps/api/src/pkg/cache/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -35,7 +35,7 @@ export function initCache(c: Context<HonoEnv>, metrics: Metrics): C<CacheNamespa
cloudflareApiKey: c.env.CLOUDFLARE_API_KEY,
zoneId: c.env.CLOUDFLARE_ZONE_ID,
domain: "cache.unkey.dev",
cacheBuster: "v9",
cacheBuster: "v10",
})
: undefined;

Expand Down
8 changes: 7 additions & 1 deletion apps/api/src/pkg/cache/namespaces.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
import type {
Api,
Credits,
EncryptedKey,
Identity,
Key,
Expand All @@ -17,6 +18,7 @@ export type CacheNamespaces = {
keyById: {
key: Key & { encrypted: EncryptedKey | null; ratelimits: Ratelimit[] };
api: Api;
credits: Credits | null;
permissions: string[];
roles: string[];
identity: CachedIdentity | null;
Expand All @@ -30,11 +32,14 @@ export type CacheNamespaces = {
id: string;
enabled: boolean;
} | null;
credits: Credits | null;
key: Key & { encrypted: EncryptedKey | null; ratelimits: Ratelimit[] };
api: Api;
permissions: string[];
roles: string[];
ratelimits: { [name: string]: Pick<Ratelimit, "name" | "limit" | "duration" | "autoApply"> };
ratelimits: {
[name: string]: Pick<Ratelimit, "name" | "limit" | "duration" | "autoApply">;
};
identity: CachedIdentity | null;
} | null;
apiById: (Api & { keyAuth: KeyAuth | null }) | null;
Expand All @@ -59,6 +64,7 @@ export type CacheNamespaces = {
roles: string[];
identity: CachedIdentity | null;
ratelimits: Ratelimit[];
credits: Credits | null;
}
>;
total: number;
Expand Down
31 changes: 27 additions & 4 deletions apps/api/src/pkg/keys/service.ts
Original file line number Diff line number Diff line change
Expand Up @@ -211,6 +211,7 @@ export class KeyService {
where: (table, { and, eq, isNull }) => and(eq(table.hash, hash), isNull(table.deletedAtM)),
with: {
encrypted: true,
credits: true,
workspace: {
columns: {
id: true,
Expand Down Expand Up @@ -278,7 +279,7 @@ export class KeyService {
}

/**
* Createa a unique set of all permissions, whether they're attached directly or connected
* Create a unique set of all permissions, whether they're attached directly or connected
* through a role.
*/
const permissions = new Set<string>([
Expand All @@ -290,7 +291,7 @@ export class KeyService {

/**
* Merge ratelimits from the identity and the key
* Key limits take pecedence
* Key limits take precedence
*/
const ratelimits: {
[name: string]: Pick<Ratelimit, "name" | "limit" | "duration" | "autoApply">;
Expand All @@ -299,11 +300,13 @@ export class KeyService {
for (const rl of dbRes.identity?.ratelimits ?? []) {
ratelimits[rl.name] = rl;
}

for (const rl of dbRes.ratelimits ?? []) {
ratelimits[rl.name] = rl;
}

return {
credits: dbRes.credits,
workspace: dbRes.workspace,
forWorkspace: dbRes.forWorkspace,
key: dbRes,
Expand All @@ -320,6 +323,7 @@ export class KeyService {
if (cached) {
return cached;
}

const hash = await sha256(key);
this.hashCache.set(key, hash);
return hash;
Expand Down Expand Up @@ -616,11 +620,30 @@ export class KeyService {
}

let remaining: number | undefined = undefined;
if (data.key.remaining !== null) {
if (data.key.remaining !== null || data.credits != null) {
const t0 = performance.now();
const cost = req.remaining?.cost ?? DEFAULT_REMAINING_COST;

// V1 Migration: Priority order for credits
// 1. Key credits (credits table with keyId)
// 2. key.remaining (legacy system)
const keyCredits = data.credits;
const legacyRemaining = data.key.remaining;

let creditId: string | undefined = undefined;
let keyId: string | undefined = undefined;

if (keyCredits != null) {
// Use key-based credits (credits table)
creditId = keyCredits.id;
} else if (legacyRemaining !== null) {
// Use legacy key.remaining
keyId = data.key.id;
}

const limited = await this.usageLimiter.limit({
keyId: data.key.id,
creditId,
keyId,
cost,
});

Expand Down
32 changes: 27 additions & 5 deletions apps/api/src/pkg/usagelimit/client.ts
Original file line number Diff line number Diff line change
Expand Up @@ -36,40 +36,62 @@ export class DurableUsageLimiter implements UsageLimiter {
public async limit(req: LimitRequest): Promise<LimitResponse> {
const start = performance.now();

// Use creditId if present (new system), otherwise fall back to keyId (legacy)
const identifier = req.creditId ?? req.keyId;
if (!identifier) {
this.logger.error("usagelimit called without keyId or creditId");
return { valid: false };
}

try {
const url = `https://${this.domain}/limit`;
const res = await this.getStub(req.keyId)
const res = await this.getStub(identifier)
.fetch(url, {
method: "POST",
headers: { "Content-Type": "application/json", "Unkey-Request-Id": this.requestId },
headers: {
"Content-Type": "application/json",
"Unkey-Request-Id": this.requestId,
},
body: JSON.stringify(req),
})
.catch(async (e) => {
this.logger.warn("calling the usagelimit DO failed, retrying ...", {
keyId: req.keyId,
creditId: req.creditId,
error: (e as Error).message,
});
return await this.getStub(req.keyId).fetch(url, {
return await this.getStub(identifier).fetch(url, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(req),
});
});
return limitResponseSchema.parse(await res.json());
} catch (e) {
this.logger.error("usagelimit failed", { keyId: req.keyId, error: (e as Error).message });
this.logger.error("usagelimit failed", {
keyId: req.keyId,
creditId: req.creditId,
error: (e as Error).message,
});
return { valid: false };
} finally {
this.metrics.emit({
metric: "metric.usagelimit",
latency: performance.now() - start,
keyId: req.keyId,
creditId: req.creditId,
});
}
}

public async revalidate(req: RevalidateRequest): Promise<void> {
const obj = this.namespace.get(this.namespace.idFromName(req.keyId));
const identifier = req.creditId ?? req.keyId;
if (!identifier) {
this.logger.error("revalidate called without keyId or creditId");
return;
}

const obj = this.namespace.get(this.namespace.idFromName(identifier));
const url = `https://${this.domain}/revalidate`;
await obj.fetch(url, {
method: "POST",
Expand Down
Loading