Skip to content

Commit bdedab4

Browse files
committed
fix(balances): limit_reached reports the tightest cap and its filter from one source
1 parent fbb81b7 commit bdedab4

2 files changed

Lines changed: 84 additions & 49 deletions

File tree

server/src/internal/balances/trackWebhooks/checkLimitReached.ts

Lines changed: 74 additions & 36 deletions
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,7 @@ import {
99
fullSubjectToUsageWindowLimits,
1010
getCurrentUsageWindowUsage,
1111
orgToInStatuses,
12+
type UsageLimitFilter,
1213
type UsageLimitWebhookBlock,
1314
usageLimitFilterMatchesProperties,
1415
WebhookEventType,
@@ -17,7 +18,15 @@ import { sendSvixEvent } from "@/external/svix/svixHelpers.js";
1718
import type { AutumnContext } from "@/honoUtils/HonoEnv.js";
1819
import { usageWindowLimitToWebhookBlock } from "@/internal/balances/utils/usageWindows/usageWindowLimitToWebhookBlock.js";
1920

20-
/** The exhausted cap that blocked this event: the first matching limit with no headroom left. */
21+
type BlockingUsageLimit = {
22+
block: UsageLimitWebhookBlock;
23+
filter: UsageLimitFilter | undefined;
24+
};
25+
26+
/**
27+
* The cap that blocked this event. Enforcement stops at the cap with the least
28+
* headroom, so the webhook reports that one, filter included, from one source.
29+
*/
2130
const findBlockingUsageLimit = ({
2231
ctx,
2332
fullSubject,
@@ -30,31 +39,69 @@ const findBlockingUsageLimit = ({
3039
feature: Feature;
3140
eventProperties?: Record<string, unknown> | null;
3241
now: number;
33-
}): UsageLimitWebhookBlock | undefined => {
34-
const limits = fullSubjectToUsageWindowLimits({
42+
}): BlockingUsageLimit | undefined => {
43+
const usageWindows = fullSubject.usage_windows ?? [];
44+
const measured = fullSubjectToUsageWindowLimits({
3545
fullSubject,
3646
featureIds: [feature.id],
3747
features: ctx.features,
3848
now,
3949
inStatuses: orgToInStatuses({ org: ctx.org }),
40-
});
41-
const usageWindows = fullSubject.usage_windows ?? [];
50+
})
51+
.filter((limit) =>
52+
usageLimitFilterMatchesProperties({
53+
filterProperties: limit.filter_properties,
54+
eventProperties,
55+
}),
56+
)
57+
.map((limit) => ({
58+
limit,
59+
usage: getCurrentUsageWindowUsage({ usageWindows, limit, now }),
60+
}))
61+
.sort(
62+
(left, right) =>
63+
left.limit.limit - left.usage - (right.limit.limit - right.usage),
64+
);
4265

43-
for (const limit of limits) {
44-
const appliesToEvent = usageLimitFilterMatchesProperties({
45-
filterProperties: limit.filter_properties,
46-
eventProperties,
47-
});
48-
if (!appliesToEvent) continue;
66+
const tightest = measured[0];
67+
if (!tightest || tightest.usage < tightest.limit.limit) return undefined;
4968

50-
const usage = getCurrentUsageWindowUsage({ usageWindows, limit, now });
51-
if (usage < limit.limit) continue;
69+
const block = usageWindowLimitToWebhookBlock({
70+
limit: tightest.limit,
71+
usage: tightest.usage,
72+
});
73+
if (!block) return undefined;
5274

53-
return usageWindowLimitToWebhookBlock({ limit, usage }) ?? undefined;
54-
}
55-
return undefined;
75+
return {
76+
block,
77+
filter: tightest.limit.filter_properties
78+
? { properties: tightest.limit.filter_properties }
79+
: undefined,
80+
};
5681
};
5782

83+
/** Legacy deductions carry no FullSubject; read the exhausted filtered cap off the evaluated subject. */
84+
const findBlockedFilterOnSubject = ({
85+
subject,
86+
feature,
87+
eventProperties,
88+
}: {
89+
subject: ApiCustomerV5 | ApiEntityV2;
90+
feature: Feature;
91+
eventProperties?: Record<string, unknown> | null;
92+
}): UsageLimitFilter | undefined =>
93+
subject.billing_controls?.usage_limits?.find(
94+
(usageLimit) =>
95+
usageLimit.feature_id === feature.id &&
96+
usageLimit.enabled !== false &&
97+
usageLimit.filter != null &&
98+
usageLimitFilterMatchesProperties({
99+
filterProperties: usageLimit.filter.properties,
100+
eventProperties,
101+
}) &&
102+
(usageLimit.usage ?? 0) >= usageLimit.limit,
103+
)?.filter;
104+
58105
// Subjects must be built via buildEvaluationSubject, or plan-level / percentage
59106
// caps are invisible here and the allowed -> blocked transition never fires.
60107
export const checkLimitReached = async ({
@@ -103,25 +150,7 @@ export const checkLimitReached = async ({
103150
if (!oldResult.allowed || newResult.allowed) return;
104151

105152
const blockedByUsageLimit = newResult.limitType === "usage_limit";
106-
107-
// When the blocking cap is a filtered usage limit, attach its filter so
108-
// the receiver knows WHICH slice (e.g. which API key) hit its cap.
109-
const blockedFilter =
110-
blockedByUsageLimit && eventProperties
111-
? newEvalSubject.billing_controls?.usage_limits?.find(
112-
(usageLimit) =>
113-
usageLimit.feature_id === feature.id &&
114-
usageLimit.enabled !== false &&
115-
usageLimit.filter != null &&
116-
usageLimitFilterMatchesProperties({
117-
filterProperties: usageLimit.filter.properties,
118-
eventProperties,
119-
}) &&
120-
(usageLimit.usage ?? 0) >= usageLimit.limit,
121-
)?.filter
122-
: undefined;
123-
124-
const usageLimitBlock =
153+
const blocking =
125154
blockedByUsageLimit && newFullSubject
126155
? findBlockingUsageLimit({
127156
ctx,
@@ -131,6 +160,15 @@ export const checkLimitReached = async ({
131160
now,
132161
})
133162
: undefined;
163+
const blockedFilter =
164+
blocking?.filter ??
165+
(blockedByUsageLimit && eventProperties
166+
? findBlockedFilterOnSubject({
167+
subject: newEvalSubject,
168+
feature,
169+
eventProperties,
170+
})
171+
: undefined);
134172

135173
const customerId = newFullCus.id || newFullCus.internal_id;
136174
const tags = fullCustomerToTags({ fullCustomer: newFullCus });
@@ -144,7 +182,7 @@ export const checkLimitReached = async ({
144182
limit_type: newResult.limitType ?? "included",
145183
...(entityId && { entity_id: entityId }),
146184
...(blockedFilter && { filter: blockedFilter }),
147-
...(usageLimitBlock && { usage_limit: usageLimitBlock }),
185+
...(blocking && { usage_limit: blocking.block }),
148186
},
149187
tags,
150188
});

server/tests/integration/balances/track/limit-reached/limit-reached-usage-limit-block.test.ts

Lines changed: 10 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -12,12 +12,7 @@
1212
*/
1313

1414
import { afterAll, beforeAll, expect, test } from "bun:test";
15-
import {
16-
ApiVersion,
17-
EntInterval,
18-
getUsageWindowBounds,
19-
ResetInterval,
20-
} from "@autumn/shared";
15+
import { ApiVersion, ResetInterval } from "@autumn/shared";
2116
import {
2217
getTestSvixAppId,
2318
setupWebhookTest,
@@ -33,8 +28,10 @@ import { initScenario, s } from "@tests/utils/testInitUtils/initScenario.js";
3328
import chalk from "chalk";
3429
import { AutumnInt } from "@/external/autumn/autumnCli.js";
3530
import { setCustomerUsageLimit } from "../../utils/usage-limit-utils/customerUsageLimitUtils.js";
31+
import { expectUsageLimitWindowContains } from "../../utils/usage-limit-utils/expectUsageLimitWindowContains.js";
3632

3733
const autumnV2_3 = new AutumnInt({ version: ApiVersion.V2_3 });
34+
const ONE_DAY_MS = 24 * 60 * 60 * 1000;
3835

3936
type LimitReachedPayload = {
4037
type: string;
@@ -108,6 +105,7 @@ test(`${chalk.yellowBright("limit-reached-ul1: a usage_limit block describes the
108105
anchor: "utc",
109106
});
110107

108+
const trackedAt = Date.now();
111109
await autumnV2_3.track({
112110
customer_id: customerId,
113111
feature_id: TestFeature.Messages,
@@ -119,19 +117,18 @@ test(`${chalk.yellowBright("limit-reached-ul1: a usage_limit block describes the
119117
limitType: "usage_limit",
120118
});
121119
expect(result).not.toBeNull();
122-
const bounds = getUsageWindowBounds({
123-
interval: EntInterval.Day,
124-
now: Date.now(),
125-
});
126120
expect(result!.payload.data.filter).toBeUndefined();
127-
expect(result!.payload.data.usage_limit).toEqual({
121+
expect(result!.payload.data.usage_limit).toMatchObject({
128122
limit: 5,
129123
interval: "day",
130124
anchor: "utc",
131125
usage: 5,
132126
remaining: 0,
133-
window_start_at: bounds.windowStartAt,
134-
window_end_at: bounds.windowEndAt,
127+
});
128+
expectUsageLimitWindowContains({
129+
usageLimit: result!.payload.data.usage_limit,
130+
at: trackedAt,
131+
intervalMs: ONE_DAY_MS,
135132
});
136133
});
137134

0 commit comments

Comments
 (0)