Skip to content

Commit b8989cc

Browse files
authored
fix(webhooks): stop admission-refusal retry loops and per-retry log writes (#8870)
* fix(execution): tag deterministic admission rejections and throttle blocked-run logs Usage-limit, suspended-account, and missing-billing-account refusals now carry stable codes, so surfaces can tell a refusal that holds until a person acts from a transient one. Unattended surfaces can opt into throttleErrorLogs: each refusal of a workflow by the same gate records at most one execution error row per 15-minute window (Redis SET NX, in-process LRU without Redis, fail-open on Redis errors). A sender resending a refused delivery no longer writes an execution row, trace archive, and workspace_files row per attempt. A caller-supplied logging session is always completed. * fix(webhooks): acknowledge deterministic admission rejections for Telegram and Slack Telegram resends a non-2xx update until it succeeds or 24 hours pass, and Slack disables an app's event subscription once most deliveries fail, so answering a usage-limit refusal with 402 only loops. Providers opt in with acknowledgeAdmissionRejections and get an empty 200 with an ignored outcome; transient refusals (rate limit, concurrency, reservation outage) still fail so the sender retries. Generic webhooks keep the 402. Polling receives the raw refusal with its code. Webhook preprocessing throttles its error rows. * fix(webhooks): skip polls for over-limit payers and back off failing sources The poll orchestrator checks each workspace's payer once per tick and skips its webhooks while the payer is over its usage limit: nothing is fetched, marked seen, or counted as a failure, so items deliver once the payer is back under the limit and triggers are no longer auto-disabled over billing state. A webhook with consecutive poll failures waits 2^(n-1) minutes (capped at an hour) before the next fetch, and a source's Retry-After or FLOOD_WAIT_<n> is persisted and honored. RSS logs a source's 4xx once at warn, and an admission refusal mid-poll leaves the remaining items unseen. * fix(webhooks): stop Slack redelivering to deleted trigger paths and log them once A POST to a path with no webhook keeps its 404 but now carries x-slack-no-retry, sent unconditionally so it reveals nothing the 404 does not. The processor and route lines for an unknown path drop to debug, leaving the route handler's single client-error line. * fix(telegram): verify webhook deliveries with a per-webhook secret token Register a generated secret_token with setWebhook, store it as providerConfig.secretToken, and reject deliveries whose X-Telegram-Bot-Api-Secret-Token header does not match (401). Webhooks registered before this change have no stored secret and stay accepted until their next deploy registers one. A new registration reuses the active deployment's secret for the same bot so deliveries keep verifying during cutover. Drops the stale empty User-Agent warning: the proxy exempts webhook trigger routes from the empty-UA block. * refactor(webhooks): declare the Telegram admission opt-in on its handler * fix(execution): only treat billing and account refusals as deterministic An unreadable usage ledger fails closed as exceeded; it said nothing about the payer, yet it was tagged USAGE_LIMIT_EXCEEDED and acknowledged-and-dropped for Telegram and Slack. It now stays untagged and retryable. Reservation headroom denials clear as in-flight runs settle, so they leave the deterministic set too. The blocked-run log claim drops its in-process fallback: usage refusals only happen on hosted billing deployments, which run Redis, and without Redis every refusal records its row. A Redis failure logs at debug. * fix(webhooks): keep a fan-out target's retryable failure visible past a dropped refusal An acknowledged admission refusal counted as an acknowledgment, so a Slack or path fan-out answered 200 even when another target failed and needed the sender to retry. Dropped targets (block missing, acknowledged refusal) now answer 200 only when no other target failed. * fix(telegram): match the active bot through env-var token references Active rows store the bot token as authored, often a {{VAR}} reference, while subscription calls receive it resolved, so the comparison never matched: every deploy minted a fresh secret (a candidate that never activated left the bot rejected by the active row), and retiring an old version could delete the webhook the active version still used. Stored tokens are now resolved against the background webhook env before comparing. * fix(webhooks): skip polls only after a recorded refusal and back off source failures The per-tick payer pre-check read billing attribution and the usage ledger for every polled workspace, including healthy idle ones. A deterministic admission refusal of a polled event now records the workspace in Redis for five minutes, and the orchestrator skips only those workspaces in one MGET; healthy payers cost no billing reads, and billing-disabled deployments skip the mechanism. Backoff now follows source fetch failures only, tracked in providerConfig and stamped from the failed poll's start, so transient item-processing refusals never back a webhook off. Poll state keys are system-managed so deploy change detection ignores them. * refactor(webhooks): route every poller's source failures through one backoff Every poller's outer catch now records a source failure, so a failing Gmail, Outlook, IMAP, Drive, Sheets, Calendar, or HubSpot source backs off like RSS instead of only RSS. markWebhookSuccess clears the backoff in its existing reset write, the window uses the shared jittered backoff, and the orchestrator asks a boolean isPollBackedOff. Smaller cleanups: one isDroppedDispatch predicate for the Slack and path fan-outs, explicit precedence for a polled refusal's code, a typed RSS refusal error instead of a flag, Telegram resolves only the stored bot token, and PollOutcome lives with the polling types. * chore(webhooks): tighten poll comments and backoff tests Poll outcome docs describe what a skipped poll actually does, source failures keep logging the full error object as the pollers did before, the backoff table pins the clock past the poll start so it proves the window is anchored there, and the RSS rate-limit test drives a Retry-After header. * fix(webhooks): stop every poller's batch on a deterministic admission refusal Only RSS stopped at a refused item; Gmail, Outlook, and IMAP advanced their cursors past refused emails, and every poller counted the refusals toward auto-disable. A shared PollAdmissionRefusedError now leaves the idempotency callback, stops the batch, and returns skipped before any cursor update or failure count; items that already ran replay as idempotent no-ops. Source backoff goes back to RSS only, where the rate-limited feed was: the other pollers' fetch helpers do not carry status or Retry-After, so routing their failures through it would back off on a guess. The block-missing 404 also tells Slack not to redeliver. * fix(webhooks): never replay completed poll work or mask a retryable fan-out failure A poller stops its batch on a deterministic refusal only while nothing in the batch has completed; once an item has run, the refusal is an ordinary item failure, so the poller saves its completed work exactly as before and no completed event can replay after the idempotency window. In a multi-target delivery a missing block's no-retry 404 no longer stands in for a target that failed and needs the sender to retry. A source's Retry-After is counted from its answer rather than the poll's start. The RSS backoff keys are cleared by RSS's own state write, so other pollers' success path is unchanged, and two fields nothing reads are dropped. * fix(execution): drop the unreachable billing-account admission code A workspace without a billing account fails inside payer resolution and takes the retryable attribution-error path; the branch that tagged BILLING_ACCOUNT_REQUIRED only ran for an attribution with no actor, which system attribution never produces. The branch goes back to its staging form and the deterministic set keeps the usage limit and suspended accounts. * chore(webhooks): key blocked-run claims by gate and centralize the polling utils mock Two gates that fail without a code (a ban lookup error and a usage lookup error) no longer share one throttle claim, so neither hides the other's row. The polling utils module gets one central mock in @sim/testing, replacing the partial importOriginal mocks and the hand-rolled factory in the table trigger test. * fix(telegram): match the active bot with the env the caller resolved its token with Subscription creation resolves the incoming bot token with the deployer's env, while cleanup resolves with the background env; the active-row matcher now uses the same env as its caller, so a bot referenced through a personal variable still reuses the active secret and is not deleted from under the active deployment. Tests: the idempotency service gets one central mock in @sim/testing, used by every test that mocked it locally, and the new tests import single factory and mock files instead of the @sim/testing barrel. * chore(testing): stub IdempotencyService.createWebhookIdempotencyKey in the central mock
1 parent b5d3f7b commit b8989cc

40 files changed

Lines changed: 1986 additions & 240 deletions

‎apps/sim/app/api/webhooks/trigger/[path]/route.test.ts‎

Lines changed: 91 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -703,6 +703,97 @@ describe('Webhook Trigger API Route', () => {
703703
})
704704
})
705705

706+
it('does not let an acknowledged admission refusal mask another target that must retry', async () => {
707+
testData.webhooks.push(
708+
{
709+
id: 'refused-webhook',
710+
provider: 'generic',
711+
path: 'fan-out-path',
712+
isActive: true,
713+
providerConfig: {},
714+
workflowId: 'test-workflow-id',
715+
},
716+
{
717+
id: 'failing-webhook',
718+
provider: 'generic',
719+
path: 'fan-out-path',
720+
isActive: true,
721+
providerConfig: {},
722+
workflowId: 'test-workflow-id',
723+
}
724+
)
725+
dispatchResolvedWebhookTargetMock
726+
.mockResolvedValueOnce({
727+
outcome: 'ignored',
728+
reason: 'admission-rejected',
729+
response: new NextResponse(null, { status: 200 }),
730+
})
731+
.mockResolvedValueOnce({
732+
outcome: 'failed',
733+
reason: 'preprocessing',
734+
response: new NextResponse(null, { status: 503 }),
735+
})
736+
737+
const response = await POST(
738+
createMockRequest('POST', { event: 'x' }),
739+
createRouteContext({ path: 'fan-out-path' })
740+
)
741+
742+
expect(response.status).toBe(503)
743+
})
744+
745+
it('answers with a failing target rather than a missing block so the sender retries', async () => {
746+
testData.webhooks.push(
747+
{
748+
id: 'missing-block-webhook',
749+
provider: 'generic',
750+
path: 'mixed-path',
751+
isActive: true,
752+
providerConfig: {},
753+
workflowId: 'test-workflow-id',
754+
},
755+
{
756+
id: 'failing-webhook',
757+
provider: 'generic',
758+
path: 'mixed-path',
759+
isActive: true,
760+
providerConfig: {},
761+
workflowId: 'test-workflow-id',
762+
}
763+
)
764+
dispatchResolvedWebhookTargetMock
765+
.mockResolvedValueOnce({
766+
outcome: 'ignored',
767+
reason: 'block-missing',
768+
response: new NextResponse('Trigger block not found in deployment', {
769+
status: 404,
770+
headers: { 'x-slack-no-retry': '1' },
771+
}),
772+
})
773+
.mockResolvedValueOnce({
774+
outcome: 'failed',
775+
reason: 'queue-failed',
776+
response: new NextResponse(null, { status: 500 }),
777+
})
778+
779+
const response = await POST(
780+
createMockRequest('POST', { event: 'x' }),
781+
createRouteContext({ path: 'mixed-path' })
782+
)
783+
784+
expect(response.status).toBe(500)
785+
expect(response.headers.get('x-slack-no-retry')).toBeNull()
786+
})
787+
788+
it('tells Slack not to redeliver a POST to a path with no webhook', async () => {
789+
const req = createMockRequest('POST', { type: 'event_callback' })
790+
791+
const response = await POST(req, createRouteContext({ path: 'deleted-path' }))
792+
793+
expect(response.status).toBe(404)
794+
expect(response.headers.get('x-slack-no-retry')).toBe('1')
795+
})
796+
706797
describe('PUT, PATCH and DELETE deliveries', () => {
707798
/**
708799
* Every non-POST rejection is the same 405, whether the path is unknown, holds only

‎apps/sim/app/api/webhooks/trigger/[path]/route.ts‎

Lines changed: 25 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,7 @@ import { parseRequest } from '@/lib/api/server'
1111
import { admissionRejectedResponse, tryAdmit } from '@/lib/core/admission/gate'
1212
import { generateRequestId } from '@/lib/core/utils/request'
1313
import { withRouteHandler } from '@/lib/core/utils/with-route-handler'
14+
import { isDroppedDispatch } from '@/lib/webhooks/dispatch-result'
1415
import {
1516
dispatchResolvedWebhookTarget,
1617
findAllWebhooksForPath,
@@ -126,10 +127,13 @@ function methodNotAllowedResponse(): NextResponse {
126127
* existing callers see no change; anything else answers 405 uniformly, whether the path is
127128
* unknown, holds only non-path triggers, or holds a trigger that has not opted into the method —
128129
* so a probe cannot tell those apart.
130+
*
131+
* Every `POST` 404 carries `x-slack-no-retry`, which tells Slack not to redeliver an event to a
132+
* deleted trigger. It is sent unconditionally, so it reveals nothing the 404 itself does not.
129133
*/
130134
function notDeliverableResponse(method: string): NextResponse {
131135
return method === 'POST'
132-
? new NextResponse('Not Found', { status: 404 })
136+
? new NextResponse('Not Found', { status: 404, headers: { 'x-slack-no-retry': '1' } })
133137
: methodNotAllowedResponse()
134138
}
135139

@@ -201,7 +205,7 @@ async function handleWebhookDelivery(
201205
return verificationResponse
202206
}
203207

204-
logger.warn(`[${requestId}] Webhook or workflow not found for path: ${path}`)
208+
logger.debug(`[${requestId}] Webhook or workflow not found for path: ${path}`)
205209
return notDeliverableResponse(request.method)
206210
}
207211

@@ -261,14 +265,16 @@ async function handleWebhookDelivery(
261265
*/
262266
const responses: NextResponse[] = []
263267
const failures: NextResponse[] = []
264-
let hasPermanentlyIgnoredLegacyTarget = false
268+
/** Kept apart so a missing block's no-retry 404 never stands in for a target that must retry. */
269+
const blockMissingResponses: NextResponse[] = []
270+
let hasDroppedTarget = false
265271
for (const dispatchResult of legacySlackDispatchResults) {
266272
if (dispatchResult.outcome === 'failed') {
267273
failures.push(getSlackDispatchFailureResponse(dispatchResult))
268274
continue
269275
}
270-
if (dispatchResult.reason === 'block-missing') {
271-
hasPermanentlyIgnoredLegacyTarget = true
276+
if (isDroppedDispatch(dispatchResult)) {
277+
hasDroppedTarget = true
272278
continue
273279
}
274280
responses.push(dispatchResult.response)
@@ -333,13 +339,22 @@ async function handleWebhookDelivery(
333339
continue
334340
}
335341

342+
if (dispatchResult.reason === 'admission-rejected') {
343+
hasDroppedTarget = true
344+
continue
345+
}
346+
336347
if (dispatchResult.outcome === 'failed' || dispatchResult.reason === 'block-missing') {
337348
if (dispatchTargetCount > 1) {
338349
logger.warn(
339350
`[${requestId}] Webhook dispatch failed for ${foundWebhook.id}, continuing to next`,
340351
{ reason: dispatchResult.reason, status: dispatchResult.response.status }
341352
)
342-
failures.push(dispatchResult.response)
353+
if (dispatchResult.outcome === 'failed') {
354+
failures.push(dispatchResult.response)
355+
} else {
356+
blockMissingResponses.push(dispatchResult.response)
357+
}
343358
continue
344359
}
345360
return dispatchResult.response
@@ -352,7 +367,10 @@ async function handleWebhookDelivery(
352367
if (failures.length > 0) {
353368
return failures[0]
354369
}
355-
if (hasPermanentlyIgnoredLegacyTarget) {
370+
if (blockMissingResponses.length > 0) {
371+
return blockMissingResponses[0]
372+
}
373+
if (hasDroppedTarget) {
356374
return new NextResponse(null, { status: 200 })
357375
}
358376
return new NextResponse('No webhooks processed successfully', { status: 500 })

‎apps/sim/lib/billing/checkout-admission.test.ts‎

Lines changed: 7 additions & 21 deletions
Original file line numberDiff line numberDiff line change
@@ -1,33 +1,19 @@
1+
import {
2+
idempotencyServiceMock,
3+
idempotencyServiceMockFns,
4+
} from '@sim/testing/mocks/idempotency-service.mock'
15
import { beforeEach, describe, expect, it, vi } from 'vitest'
26

3-
const { mockAtomicallyClaim, mockRelease, mockIdempotencyService } = vi.hoisted(() => ({
4-
mockAtomicallyClaim: vi.fn(),
5-
mockRelease: vi.fn(),
6-
mockIdempotencyService: vi.fn(),
7-
}))
8-
9-
vi.mock('@/lib/core/idempotency/service', () => ({
10-
IdempotencyService: class MockIdempotencyService {
11-
constructor(options: unknown) {
12-
mockIdempotencyService(options)
13-
}
14-
15-
atomicallyClaim(...args: unknown[]) {
16-
return mockAtomicallyClaim(...args)
17-
}
18-
19-
release(...args: unknown[]) {
20-
return mockRelease(...args)
21-
}
22-
},
23-
}))
7+
vi.mock('@/lib/core/idempotency/service', () => idempotencyServiceMock)
248

259
import {
2610
claimCheckoutAdmission,
2711
releaseCheckoutAdmission,
2812
resolveCheckoutReferenceId,
2913
} from '@/lib/billing/checkout-admission'
3014

15+
const { mockAtomicallyClaim, mockRelease } = idempotencyServiceMockFns
16+
3117
describe('checkout admission', () => {
3218
beforeEach(() => {
3319
mockAtomicallyClaim.mockReset()
Lines changed: 27 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,27 @@
1+
/**
2+
* Codes for admission refusals that hold until a person changes billing or
3+
* account state, carried on the preprocessing error next to
4+
* `WORKFLOW_NOT_DEPLOYED_CODE`. Resending the same delivery cannot succeed, so
5+
* an unattended sender that retries on a non-2xx only loops.
6+
*
7+
* Reservation headroom denials are deliberately absent: they clear as in-flight
8+
* runs settle, so a retry can succeed. So is a usage ledger that could not be
9+
* read, which fails closed without saying anything about the payer.
10+
*/
11+
export const ADMISSION_REJECTION_CODE = {
12+
USAGE_LIMIT_EXCEEDED: 'USAGE_LIMIT_EXCEEDED',
13+
ACCOUNT_SUSPENDED: 'ACCOUNT_SUSPENDED',
14+
} as const
15+
16+
const DETERMINISTIC_ADMISSION_REJECTION_CODES: ReadonlySet<string> = new Set(
17+
Object.values(ADMISSION_REJECTION_CODE)
18+
)
19+
20+
/** The failure's code when it is a deterministic admission rejection, else `undefined`. */
21+
export function getDeterministicAdmissionRejectionCode(failure: {
22+
code?: string
23+
}): string | undefined {
24+
return failure.code && DETERMINISTIC_ADMISSION_REJECTION_CODES.has(failure.code)
25+
? failure.code
26+
: undefined
27+
}
Lines changed: 92 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,92 @@
1+
/**
2+
* The blocked-run log claim against a real Redis: concurrent refusals from several app
3+
* instances must agree on exactly one row per workflow, gate, and window. Skipped without
4+
* `TEST_REDIS_URL`. Each test claims a fresh workflow id, so no test sees another's key.
5+
*/
6+
7+
import { readTestRedisUrl } from '@sim/db/testing/test-infrastructure'
8+
import { redisConfigMock, redisConfigMockFns } from '@sim/testing/mocks/redis-config.mock'
9+
import { generateId } from '@sim/utils/id'
10+
import Redis from 'ioredis'
11+
import { afterAll, beforeAll, beforeEach, describe, expect, it, vi } from 'vitest'
12+
13+
const redisUrl = readTestRedisUrl()
14+
15+
vi.mock('@/lib/core/config/redis', () => redisConfigMock)
16+
17+
import { BLOCKED_RUN_LOG_WINDOW_SECONDS, claimBlockedRunLog } from '@/lib/execution/blocked-run-log'
18+
19+
describe.runIf(Boolean(redisUrl))('blocked-run log claim', () => {
20+
let redis: Redis
21+
const workflowIds: string[] = []
22+
23+
const freshWorkflowId = () => {
24+
const workflowId = `workflow-${generateId()}`
25+
workflowIds.push(workflowId)
26+
return workflowId
27+
}
28+
29+
beforeAll(async () => {
30+
if (!redisUrl) throw new Error('TEST_REDIS_URL is required for this suite')
31+
redis = new Redis(redisUrl, { lazyConnect: true, maxRetriesPerRequest: 0 })
32+
await redis.connect()
33+
})
34+
35+
beforeEach(() => {
36+
redisConfigMockFns.mockGetRedisClient.mockReturnValue(redis)
37+
})
38+
39+
afterAll(async () => {
40+
const keys = await Promise.all(
41+
workflowIds.map((workflowId) => redis.keys(`blocked-run-log:v1:${workflowId}:*`))
42+
)
43+
const flat = keys.flat()
44+
if (flat.length > 0) await redis.del(...flat)
45+
await redis.quit()
46+
})
47+
48+
it('grants exactly one of many concurrent refusals the row', async () => {
49+
const workflowId = freshWorkflowId()
50+
51+
const claims = await Promise.all(
52+
Array.from({ length: 25 }, () => claimBlockedRunLog(workflowId, 'USAGE_LIMIT_EXCEEDED'))
53+
)
54+
55+
expect(claims.filter(Boolean)).toHaveLength(1)
56+
})
57+
58+
it('grants a different gate its own row in the same window', async () => {
59+
const workflowId = freshWorkflowId()
60+
61+
expect(await claimBlockedRunLog(workflowId, 'USAGE_LIMIT_EXCEEDED')).toBe(true)
62+
expect(await claimBlockedRunLog(workflowId, 'ACCOUNT_SUSPENDED')).toBe(true)
63+
expect(await claimBlockedRunLog(workflowId, 'USAGE_LIMIT_EXCEEDED')).toBe(false)
64+
})
65+
66+
it('expires the claim at the end of the window', async () => {
67+
const workflowId = freshWorkflowId()
68+
await claimBlockedRunLog(workflowId, 'USAGE_LIMIT_EXCEEDED')
69+
70+
const ttl = await redis.ttl(`blocked-run-log:v1:${workflowId}:USAGE_LIMIT_EXCEEDED`)
71+
expect(ttl).toBeGreaterThan(BLOCKED_RUN_LOG_WINDOW_SECONDS - 5)
72+
expect(ttl).toBeLessThanOrEqual(BLOCKED_RUN_LOG_WINDOW_SECONDS)
73+
74+
await redis.expire(`blocked-run-log:v1:${workflowId}:USAGE_LIMIT_EXCEEDED`, 1)
75+
await vi.waitFor(
76+
async () => expect(await claimBlockedRunLog(workflowId, 'USAGE_LIMIT_EXCEEDED')).toBe(true),
77+
{ timeout: 3000, interval: 200 }
78+
)
79+
})
80+
81+
it('records the row when Redis fails', async () => {
82+
const broken = new Redis('redis://127.0.0.1:1', {
83+
lazyConnect: true,
84+
maxRetriesPerRequest: 0,
85+
enableOfflineQueue: false,
86+
})
87+
redisConfigMockFns.mockGetRedisClient.mockReturnValue(broken)
88+
89+
expect(await claimBlockedRunLog(freshWorkflowId(), 'USAGE_LIMIT_EXCEEDED')).toBe(true)
90+
broken.disconnect()
91+
})
92+
})
Lines changed: 43 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,43 @@
1+
import { createLogger } from '@sim/logger'
2+
import { getErrorMessage } from '@sim/utils/errors'
3+
import { getRedisClient } from '@/lib/core/config/redis'
4+
5+
const logger = createLogger('BlockedRunLog')
6+
7+
/**
8+
* How long one blocked-run log row stands for every later refusal of the same
9+
* workflow by the same gate. A sender that retries a refused delivery would
10+
* otherwise write a fresh execution row, trace archive, and file-ownership row
11+
* per attempt; one row per window still tells the owner their runs are blocked.
12+
*/
13+
export const BLOCKED_RUN_LOG_WINDOW_SECONDS = 15 * 60
14+
15+
/**
16+
* Claims the right to record this window's blocked-run log row for a workflow
17+
* and gate. Returns false when another refusal already recorded one. Without
18+
* Redis, or when Redis fails, it returns true: a duplicate row is better than
19+
* hiding that runs are blocked. Usage-limit refusals only occur on hosted
20+
* billing deployments, which always run Redis.
21+
*/
22+
export async function claimBlockedRunLog(workflowId: string, gate: string): Promise<boolean> {
23+
const redis = getRedisClient()
24+
if (!redis) return true
25+
26+
try {
27+
const claimed = await redis.set(
28+
`blocked-run-log:v1:${workflowId}:${gate}`,
29+
'1',
30+
'EX',
31+
BLOCKED_RUN_LOG_WINDOW_SECONDS,
32+
'NX'
33+
)
34+
return claimed === 'OK'
35+
} catch (error) {
36+
logger.debug('Blocked-run log claim failed; recording the row', {
37+
workflowId,
38+
gate,
39+
error: getErrorMessage(error),
40+
})
41+
return true
42+
}
43+
}

0 commit comments

Comments
 (0)