Repository navigation
fix(webhooks): stop admission-refusal retry loops and per-retry log writes #8870
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Merged
Merged
Changes from all commits
Commits
Show all changes
18 commits
Select commit
Hold shift + click to select a range
2fc4095
fix(execution): tag deterministic admission rejections and throttle b…
waleedlatif1 474c73c
fix(webhooks): acknowledge deterministic admission rejections for Tel…
waleedlatif1 a4d0689
fix(webhooks): skip polls for over-limit payers and back off failing …
waleedlatif1 46beb99
fix(webhooks): stop Slack redelivering to deleted trigger paths and l…
waleedlatif1 0de6619
fix(telegram): verify webhook deliveries with a per-webhook secret token
waleedlatif1 1ec25a8
refactor(webhooks): declare the Telegram admission opt-in on its handler
waleedlatif1 36f8a58
fix(execution): only treat billing and account refusals as deterministic
waleedlatif1 2da70cf
fix(webhooks): keep a fan-out target's retryable failure visible past…
waleedlatif1 5a9a90b
fix(telegram): match the active bot through env-var token references
waleedlatif1 46fe011
fix(webhooks): skip polls only after a recorded refusal and back off …
waleedlatif1 a36665f
refactor(webhooks): route every poller's source failures through one …
waleedlatif1 27a04be
chore(webhooks): tighten poll comments and backoff tests
waleedlatif1 011e4bb
fix(webhooks): stop every poller's batch on a deterministic admission…
waleedlatif1 0cedd07
fix(webhooks): never replay completed poll work or mask a retryable f…
waleedlatif1 317463a
fix(execution): drop the unreachable billing-account admission code
waleedlatif1 3a141dd
chore(webhooks): key blocked-run claims by gate and centralize the po…
waleedlatif1 5fc969c
fix(telegram): match the active bot with the env the caller resolved …
waleedlatif1 6080fb0
chore(testing): stub IdempotencyService.createWebhookIdempotencyKey i…
waleedlatif1 File filter
Filter by extension
Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
There are no files selected for viewing
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,27 @@ | ||
| /** | ||
| * Codes for admission refusals that hold until a person changes billing or | ||
| * account state, carried on the preprocessing error next to | ||
| * `WORKFLOW_NOT_DEPLOYED_CODE`. Resending the same delivery cannot succeed, so | ||
| * an unattended sender that retries on a non-2xx only loops. | ||
| * | ||
| * Reservation headroom denials are deliberately absent: they clear as in-flight | ||
| * runs settle, so a retry can succeed. So is a usage ledger that could not be | ||
| * read, which fails closed without saying anything about the payer. | ||
| */ | ||
| export const ADMISSION_REJECTION_CODE = { | ||
| USAGE_LIMIT_EXCEEDED: 'USAGE_LIMIT_EXCEEDED', | ||
| ACCOUNT_SUSPENDED: 'ACCOUNT_SUSPENDED', | ||
| } as const | ||
|
|
||
| const DETERMINISTIC_ADMISSION_REJECTION_CODES: ReadonlySet<string> = new Set( | ||
| Object.values(ADMISSION_REJECTION_CODE) | ||
| ) | ||
|
|
||
| /** The failure's code when it is a deterministic admission rejection, else `undefined`. */ | ||
| export function getDeterministicAdmissionRejectionCode(failure: { | ||
| code?: string | ||
| }): string | undefined { | ||
| return failure.code && DETERMINISTIC_ADMISSION_REJECTION_CODES.has(failure.code) | ||
| ? failure.code | ||
| : undefined | ||
| } |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,92 @@ | ||
| /** | ||
| * The blocked-run log claim against a real Redis: concurrent refusals from several app | ||
| * instances must agree on exactly one row per workflow, gate, and window. Skipped without | ||
| * `TEST_REDIS_URL`. Each test claims a fresh workflow id, so no test sees another's key. | ||
| */ | ||
|
|
||
| import { readTestRedisUrl } from '@sim/db/testing/test-infrastructure' | ||
| import { redisConfigMock, redisConfigMockFns } from '@sim/testing/mocks/redis-config.mock' | ||
| import { generateId } from '@sim/utils/id' | ||
| import Redis from 'ioredis' | ||
| import { afterAll, beforeAll, beforeEach, describe, expect, it, vi } from 'vitest' | ||
|
|
||
| const redisUrl = readTestRedisUrl() | ||
|
|
||
| vi.mock('@/lib/core/config/redis', () => redisConfigMock) | ||
|
|
||
| import { BLOCKED_RUN_LOG_WINDOW_SECONDS, claimBlockedRunLog } from '@/lib/execution/blocked-run-log' | ||
|
|
||
| describe.runIf(Boolean(redisUrl))('blocked-run log claim', () => { | ||
| let redis: Redis | ||
| const workflowIds: string[] = [] | ||
|
|
||
| const freshWorkflowId = () => { | ||
| const workflowId = `workflow-${generateId()}` | ||
| workflowIds.push(workflowId) | ||
| return workflowId | ||
| } | ||
|
|
||
| beforeAll(async () => { | ||
| if (!redisUrl) throw new Error('TEST_REDIS_URL is required for this suite') | ||
| redis = new Redis(redisUrl, { lazyConnect: true, maxRetriesPerRequest: 0 }) | ||
| await redis.connect() | ||
| }) | ||
|
|
||
| beforeEach(() => { | ||
| redisConfigMockFns.mockGetRedisClient.mockReturnValue(redis) | ||
| }) | ||
|
|
||
| afterAll(async () => { | ||
| const keys = await Promise.all( | ||
| workflowIds.map((workflowId) => redis.keys(`blocked-run-log:v1:${workflowId}:*`)) | ||
| ) | ||
| const flat = keys.flat() | ||
| if (flat.length > 0) await redis.del(...flat) | ||
| await redis.quit() | ||
| }) | ||
|
|
||
| it('grants exactly one of many concurrent refusals the row', async () => { | ||
| const workflowId = freshWorkflowId() | ||
|
|
||
| const claims = await Promise.all( | ||
| Array.from({ length: 25 }, () => claimBlockedRunLog(workflowId, 'USAGE_LIMIT_EXCEEDED')) | ||
| ) | ||
|
|
||
| expect(claims.filter(Boolean)).toHaveLength(1) | ||
| }) | ||
|
|
||
| it('grants a different gate its own row in the same window', async () => { | ||
| const workflowId = freshWorkflowId() | ||
|
|
||
| expect(await claimBlockedRunLog(workflowId, 'USAGE_LIMIT_EXCEEDED')).toBe(true) | ||
| expect(await claimBlockedRunLog(workflowId, 'ACCOUNT_SUSPENDED')).toBe(true) | ||
| expect(await claimBlockedRunLog(workflowId, 'USAGE_LIMIT_EXCEEDED')).toBe(false) | ||
| }) | ||
|
|
||
| it('expires the claim at the end of the window', async () => { | ||
| const workflowId = freshWorkflowId() | ||
| await claimBlockedRunLog(workflowId, 'USAGE_LIMIT_EXCEEDED') | ||
|
|
||
| const ttl = await redis.ttl(`blocked-run-log:v1:${workflowId}:USAGE_LIMIT_EXCEEDED`) | ||
| expect(ttl).toBeGreaterThan(BLOCKED_RUN_LOG_WINDOW_SECONDS - 5) | ||
| expect(ttl).toBeLessThanOrEqual(BLOCKED_RUN_LOG_WINDOW_SECONDS) | ||
|
|
||
| await redis.expire(`blocked-run-log:v1:${workflowId}:USAGE_LIMIT_EXCEEDED`, 1) | ||
| await vi.waitFor( | ||
| async () => expect(await claimBlockedRunLog(workflowId, 'USAGE_LIMIT_EXCEEDED')).toBe(true), | ||
| { timeout: 3000, interval: 200 } | ||
| ) | ||
| }) | ||
|
|
||
| it('records the row when Redis fails', async () => { | ||
| const broken = new Redis('redis://127.0.0.1:1', { | ||
| lazyConnect: true, | ||
| maxRetriesPerRequest: 0, | ||
| enableOfflineQueue: false, | ||
| }) | ||
| redisConfigMockFns.mockGetRedisClient.mockReturnValue(broken) | ||
|
|
||
| expect(await claimBlockedRunLog(freshWorkflowId(), 'USAGE_LIMIT_EXCEEDED')).toBe(true) | ||
| broken.disconnect() | ||
| }) | ||
| }) |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,43 @@ | ||
| import { createLogger } from '@sim/logger' | ||
| import { getErrorMessage } from '@sim/utils/errors' | ||
| import { getRedisClient } from '@/lib/core/config/redis' | ||
|
|
||
| const logger = createLogger('BlockedRunLog') | ||
|
|
||
| /** | ||
| * How long one blocked-run log row stands for every later refusal of the same | ||
| * workflow by the same gate. A sender that retries a refused delivery would | ||
| * otherwise write a fresh execution row, trace archive, and file-ownership row | ||
| * per attempt; one row per window still tells the owner their runs are blocked. | ||
| */ | ||
| export const BLOCKED_RUN_LOG_WINDOW_SECONDS = 15 * 60 | ||
|
|
||
| /** | ||
| * Claims the right to record this window's blocked-run log row for a workflow | ||
| * and gate. Returns false when another refusal already recorded one. Without | ||
| * Redis, or when Redis fails, it returns true: a duplicate row is better than | ||
| * hiding that runs are blocked. Usage-limit refusals only occur on hosted | ||
| * billing deployments, which always run Redis. | ||
| */ | ||
| export async function claimBlockedRunLog(workflowId: string, gate: string): Promise<boolean> { | ||
| const redis = getRedisClient() | ||
| if (!redis) return true | ||
|
|
||
| try { | ||
| const claimed = await redis.set( | ||
|
waleedlatif1 marked this conversation as resolved.
|
||
| `blocked-run-log:v1:${workflowId}:${gate}`, | ||
| '1', | ||
| 'EX', | ||
| BLOCKED_RUN_LOG_WINDOW_SECONDS, | ||
| 'NX' | ||
| ) | ||
| return claimed === 'OK' | ||
| } catch (error) { | ||
| logger.debug('Blocked-run log claim failed; recording the row', { | ||
| workflowId, | ||
| gate, | ||
| error: getErrorMessage(error), | ||
| }) | ||
| return true | ||
| } | ||
| } | ||
Oops, something went wrong.
Oops, something went wrong.
Add this suggestion to a batch that can be applied as a single commit.
This suggestion is invalid because no changes were made to the code.
Suggestions cannot be applied while the pull request is closed.
Suggestions cannot be applied while viewing a subset of changes.
Only one suggestion per line can be applied in a batch.
Add this suggestion to a batch that can be applied as a single commit.
Applying suggestions on deleted lines is not supported.
You must change the existing code in this line in order to create a valid suggestion.
Outdated suggestions cannot be applied.
This suggestion has been applied or marked resolved.
Suggestions cannot be applied from pending reviews.
Suggestions cannot be applied on multi-line comments.
Suggestions cannot be applied while the pull request is queued to merge.
Suggestion cannot be applied right now. Please check back later.
Uh oh!
There was an error while loading. Please reload this page.