Skip to content

fix activity webhook returning 200 before chain work finishes#990

Open
mooncitydev wants to merge 1 commit intoexactly:mainfrom
mooncitydev:fix/activity-webhook-await
Open

fix activity webhook returning 200 before chain work finishes#990
mooncitydev wants to merge 1 commit intoexactly:mainfrom
mooncitydev:fix/activity-webhook-await

Conversation

@mooncitydev
Copy link
Copy Markdown

@mooncitydev mooncitydev commented Apr 29, 2026

hey, noticed the alchemy address activity hook was firing Promise.allSettled for deploy/poke work but never awaited it, then returned 200 right away. so the caller thought everything succeeded while txs could still fail in the background and only hit sentry.

now we await that pipeline and return 500 when any account pipeline rejects so alchemy (or whatever sits in front) can retry instead of silently losing the run.

also dropped status 200 on the valibot validator hook for bad payloads so invalid json/signature cases go through the default 400 from validatorHook instead of pretending success.

cheers


Open in Devin Review

Summary by CodeRabbit

  • Bug Fixes
    • Enhanced error handling in activity processing operations to improve system reliability.
    • Improved error tracking and response messages for better troubleshooting when activities fail.

@mooncitydev mooncitydev requested a review from nfmelendez as a code owner April 29, 2026 19:41
@changeset-bot
Copy link
Copy Markdown

changeset-bot Bot commented Apr 29, 2026

⚠️ No Changeset found

Latest commit: 113b92f

Merging this PR will not cause a version bump for any packages. If these changes should not result in a new version, you're good to go. If these changes should result in a version bump, you need to add a changeset.

This PR includes no changesets

When changesets are added to this PR, you'll see the packages that this PR includes changesets for and the associated semver types

Click here to learn what changesets are, and how to add one.

Click here if you're a maintainer who wants to add a changeset to this PR

@coderabbitai
Copy link
Copy Markdown

coderabbitai Bot commented Apr 29, 2026

Walkthrough

Refactors the activity hook's error handling and control flow in server/hooks/activity.ts. Replaces promise chaining with await Promise.allSettled within try/catch blocks, explicitly tracks rejected activities, sets Sentry span status based on overall fulfillment, and introduces separate HTTP 500 error responses for activity failures and uncaught exceptions.

Changes

Cohort / File(s) Summary
Activity Hook Error Handling
server/hooks/activity.ts
Refactors main activity-processing control flow from .then/.catch chaining to await Promise.allSettled with explicit rejection counting. Removes HTTP status from alchemy validation error hook. Adds top-level exception handler returning { code: "activityError" } response. Sets Sentry span status based on activity fulfillment and returns HTTP 500 on any activity rejection.

Estimated code review effort

🎯 3 (Moderate) | ⏱️ ~20 minutes

Possibly related PRs

Suggested reviewers

  • nfmelendez
🚥 Pre-merge checks | ✅ 4
✅ Passed checks (4 passed)
Check name Status Explanation
Description Check ✅ Passed Check skipped - CodeRabbit’s high-level summary is enabled.
Title check ✅ Passed The title directly and accurately describes the main fix: the activity webhook was returning HTTP 200 before awaiting critical chain work, and this PR corrects that behavior.
Linked Issues check ✅ Passed Check skipped because no linked issues were found for this pull request.
Out of Scope Changes check ✅ Passed Check skipped because no linked issues were found for this pull request.

✏️ Tip: You can configure your own custom pre-merge checks in the settings.

✨ Finishing Touches
🧪 Generate unit tests (beta)
  • Create PR with unit tests

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share
Review rate limit: 0/1 reviews remaining, refill in 60 minutes.

Comment @coderabbitai help to get the list of available commands and usage tips.

Copy link
Copy Markdown

@devin-ai-integration devin-ai-integration Bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Devin Review found 2 potential issues.

View 2 additional findings in Devin Review.

Open in Devin Review

Comment thread server/hooks/activity.ts
}),
}),
validatorHook({ code: "bad alchemy", status: 200, debug }),
validatorHook({ code: "bad alchemy", debug }),
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🔴 Removed status: 200 from webhook validatorHook causes Alchemy to retry on validation failures

The status: 200 parameter was removed from the validatorHook call, so it now defaults to status: 400 (server/utils/validatorHook.ts:17). When Alchemy sends a webhook payload that fails validation, the endpoint now returns 400 instead of 200. Alchemy retries webhooks on non-200 responses, so malformed payloads will be retried repeatedly until the retry limit is exhausted — wasting resources and generating duplicate Sentry errors.

Every other webhook hook in the codebase explicitly passes status: 200 to acknowledge the webhook even when validation fails:

  • server/hooks/bridge.ts:92status: 200
  • server/hooks/block.ts:108status: 200
  • server/hooks/manteca.ts:147status: 200
  • server/hooks/persona.ts:207status: 200
Suggested change
validatorHook({ code: "bad alchemy", debug }),
validatorHook({ code: "bad alchemy", status: 200, debug }),
Open in Devin Review

Was this helpful? React with 👍 or 👎 to provide feedback.

Comment thread server/hooks/activity.ts
Comment on lines +175 to +325
try {
const activityResults = await Promise.allSettled(
[...pokes].map(([account, { publicKey, factory, source, assets }]) =>
continueTrace({ sentryTrace, baggage }, () =>
withScope((scope) =>
startSpan(
{ name: "account activity", op: "exa.activity", attributes: { account }, forceTransaction: true },
async (span) => {
scope.setUser({ id: account });
const isDeployed = !!(await client.getCode({ address: account }));
scope.setTag("exa.new", !isDeployed);
if (!isDeployed) {
try {
await createWalletClient({ chain, transport, account: keeper.account })
.extend((wallet) => extender(wallet, { publicClient: client, traceClient: client }))
.exaSend(
{ name: "create account", op: "exa.account", attributes: { account } },
{
address: factory,
functionName: "createAccount",
args: [0n, [decodePublicKey(publicKey, bytesToBigInt)]],
abi: exaAccountFactoryAbi,
},
chain.id === exaChain.id ? undefined : { fees: "auto" },
);
track({ event: "AccountFunded", userId: account, properties: { source } });
} catch (error: unknown) {
span.setStatus({ code: SPAN_STATUS_ERROR, message: "account_failed" });
throw error;
}
}
}
if (chain.id !== exaChain.id) {
span.setStatus({ code: SPAN_STATUS_OK });
return;
}
if (assets.has(ETH)) assets.delete(WETH);
const results = await Promise.allSettled(
[...assets]
.filter((asset) => marketsByAsset.has(asset) || asset === ETH)
.map(async (asset) =>
withRetry(
() =>
keeper
.exaSend(
{ name: "poke account", op: "exa.poke", attributes: { account, asset } },
{
address: account,
abi: [...exaPluginAbi, ...upgradeableModularAccountAbi, ...auditorAbi, ...marketAbi],
...(asset === ETH
? { functionName: "pokeETH" }
: {
functionName: "poke",
args: [marketsByAsset.get(asset)!], // eslint-disable-line @typescript-eslint/no-non-null-assertion
}),
},
{ ignore: ["NoBalance()"] },
)
.then((receipt) => {
if (receipt) return receipt;
throw new Error("NoBalance()");
}),
{
delay: 2000,
retryCount: 5,
shouldRetry: ({ error }) => {
if (error instanceof Error && error.message === "NoBalance()") return true;
withScope((captureScope) => {
captureScope.setUser({ id: account });
captureException(error, { level: "error", fingerprint: revertFingerprint(error) });
});
return true;
if (chain.id !== exaChain.id) {
span.setStatus({ code: SPAN_STATUS_OK });
return;
}
if (assets.has(ETH)) assets.delete(WETH);
const pokeResults = await Promise.allSettled(
[...assets]
.filter((asset) => marketsByAsset.has(asset) || asset === ETH)
.map(async (asset) =>
withRetry(
() =>
keeper
.exaSend(
{ name: "poke account", op: "exa.poke", attributes: { account, asset } },
{
address: account,
abi: [...exaPluginAbi, ...upgradeableModularAccountAbi, ...auditorAbi, ...marketAbi],
...(asset === ETH
? { functionName: "pokeETH" }
: {
functionName: "poke",
args: [marketsByAsset.get(asset)!], // eslint-disable-line @typescript-eslint/no-non-null-assertion
}),
},
{ ignore: ["NoBalance()"] },
)
.then((receipt) => {
if (receipt) return receipt;
throw new Error("NoBalance()");
}),
{
delay: 2000,
retryCount: 5,
shouldRetry: ({ error }) => {
if (error instanceof Error && error.message === "NoBalance()") return true;
withScope((captureScope) => {
captureScope.setUser({ id: account });
captureException(error, { level: "error", fingerprint: revertFingerprint(error) });
});
return true;
},
},
},
),
),
),
);
for (const result of results) {
if (result.status === "fulfilled") continue;
if (result.reason instanceof Error && result.reason.message === "NoBalance()") {
withScope((captureScope) => {
captureScope.setUser({ id: account });
captureScope.addEventProcessor((event) => {
if (event.exception?.values?.[0]) event.exception.values[0].type = "NoBalance";
return event;
);
for (const result of pokeResults) {
if (result.status === "fulfilled") continue;
if (result.reason instanceof Error && result.reason.message === "NoBalance()") {
withScope((captureScope) => {
captureScope.setUser({ id: account });
captureScope.addEventProcessor((event) => {
if (event.exception?.values?.[0]) event.exception.values[0].type = "NoBalance";
return event;
});
captureException(result.reason, {
level: "warning",
fingerprint: ["{{ default }}", "NoBalance"],
});
});
captureException(result.reason, {
level: "warning",
fingerprint: ["{{ default }}", "NoBalance"],
});
});
continue;
continue;
}
span.setStatus({ code: SPAN_STATUS_ERROR, message: "poke_failed" });
throw result.reason;
}
span.setStatus({ code: SPAN_STATUS_ERROR, message: "poke_failed" });
throw result.reason;
}
autoCredit(account)
.then(async (auto) => {
span.setAttribute("exa.autoCredit", auto);
if (!auto) return;
const credential = await database.query.credentials.findFirst({
where: eq(credentials.account, account),
columns: {},
with: {
cards: {
columns: { id: true, mode: true },
where: inArray(cards.status, ["ACTIVE", "FROZEN"]),
autoCredit(account)
.then(async (auto) => {
span.setAttribute("exa.autoCredit", auto);
if (!auto) return;
const credential = await database.query.credentials.findFirst({
where: eq(credentials.account, account),
columns: {},
with: {
cards: {
columns: { id: true, mode: true },
where: inArray(cards.status, ["ACTIVE", "FROZEN"]),
},
},
},
});
if (!credential || credential.cards.length === 0) return;
const card = credential.cards[0];
span.setAttribute("exa.card", card?.id);
if (card?.mode !== 0) return;
await database.update(cards).set({ mode: 1 }).where(eq(cards.id, card.id));
span.setAttribute("exa.mode", 1);
sendPushNotification({
userId: account,
headings: t("Card mode changed"),
contents: t("Credit mode activated"),
}).catch((error: unknown) => captureException(error));
})
.catch((error: unknown) => captureException(error));
span.setStatus({ code: SPAN_STATUS_OK });
},
});
if (!credential || credential.cards.length === 0) return;
const card = credential.cards[0];
span.setAttribute("exa.card", card?.id);
if (card?.mode !== 0) return;
await database.update(cards).set({ mode: 1 }).where(eq(cards.id, card.id));
span.setAttribute("exa.mode", 1);
sendPushNotification({
userId: account,
headings: t("Card mode changed"),
contents: t("Credit mode activated"),
}).catch((error: unknown) => captureException(error));
})
.catch((error: unknown) => captureException(error));
span.setStatus({ code: SPAN_STATUS_OK });
},
),
),
),
).catch((error: unknown) => {
withScope((scope) => {
scope.setUser({ id: account });
captureException(error, { level: "error", fingerprint: revertFingerprint(error) });
});
throw error;
}),
),
)
.then((results) => {
getActiveSpan()?.setStatus(
results.every((result) => result.status === "fulfilled")
? { code: SPAN_STATUS_OK }
: { code: SPAN_STATUS_ERROR, message: "activity_failed" },
);
})
.catch((error: unknown) => captureException(error));
return c.json({});
).catch((error: unknown) => {
withScope((scope) => {
scope.setUser({ id: account });
captureException(error, { level: "error", fingerprint: revertFingerprint(error) });
});
throw error;
}),
),
);
getActiveSpan()?.setStatus(
activityResults.every((result) => result.status === "fulfilled")
? { code: SPAN_STATUS_OK }
: { code: SPAN_STATUS_ERROR, message: "activity_failed" },
);
const rejected = activityResults.filter(
(result): result is PromiseRejectedResult => result.status === "rejected",
);
if (rejected.length > 0) {
return c.json({ code: "activityFailed", failed: rejected.length }, 500);
}
return c.json({});
} catch (error: unknown) {
captureException(error);
return c.json({ code: "activityError" }, 500);
}
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🚩 Behavioral shift: webhook now blocks on processing and returns 500 on failure

The old code was fire-and-forget: Promise.allSettled(...) was not awaited, the handler immediately returned c.json({}) with a 200 status, and processing happened in the background. The new code awaits Promise.allSettled and returns 500 if any account processing fails (server/hooks/activity.ts:318-319). This means:

  1. The HTTP response is delayed until all account creation, poke retries (up to 5 retries × 2s delay each), and error handling complete.
  2. Alchemy receives 500 on processing failures and will retry the entire webhook, potentially re-triggering account creation and poke operations for accounts that already succeeded.

This may be intentional (the commit message says "await alchemy activity hook work before returning 200"), but the retry implications are worth considering — especially since Promise.allSettled collects both fulfilled and rejected results, so partial failures (e.g., 3/5 accounts succeed, 2 fail) will return 500 and Alchemy will retry all 5.

Open in Devin Review

Was this helpful? React with 👍 or 👎 to provide feedback.

Copy link
Copy Markdown

@gemini-code-assist gemini-code-assist Bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Code Review

This pull request refactors the activity hook to use async/await for improved error handling and response management. The logic now explicitly awaits the results of account activities and returns a 500 status code if any operations fail. Feedback was provided regarding an unawaited promise chain in the autoCredit logic, which could lead to race conditions, and a suggestion to throw errors when expected database records are missing to better reflect data integrity issues.

Comment thread server/hooks/activity.ts
Comment on lines +270 to +296
autoCredit(account)
.then(async (auto) => {
span.setAttribute("exa.autoCredit", auto);
if (!auto) return;
const credential = await database.query.credentials.findFirst({
where: eq(credentials.account, account),
columns: {},
with: {
cards: {
columns: { id: true, mode: true },
where: inArray(cards.status, ["ACTIVE", "FROZEN"]),
},
},
},
});
if (!credential || credential.cards.length === 0) return;
const card = credential.cards[0];
span.setAttribute("exa.card", card?.id);
if (card?.mode !== 0) return;
await database.update(cards).set({ mode: 1 }).where(eq(cards.id, card.id));
span.setAttribute("exa.mode", 1);
sendPushNotification({
userId: account,
headings: t("Card mode changed"),
contents: t("Credit mode activated"),
}).catch((error: unknown) => captureException(error));
})
.catch((error: unknown) => captureException(error));
span.setStatus({ code: SPAN_STATUS_OK });
},
});
if (!credential || credential.cards.length === 0) return;
const card = credential.cards[0];
span.setAttribute("exa.card", card?.id);
if (card?.mode !== 0) return;
await database.update(cards).set({ mode: 1 }).where(eq(cards.id, card.id));
span.setAttribute("exa.mode", 1);
sendPushNotification({
userId: account,
headings: t("Card mode changed"),
contents: t("Credit mode activated"),
}).catch((error: unknown) => captureException(error));
})
.catch((error: unknown) => captureException(error));
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

medium

The autoCredit promise chain is not awaited, which means the parent function can resolve before the logic finishes. Additionally, when an expected database record (like a card) is not found, the code should throw an error rather than returning silently. This indicates a system or data integrity issue, making a 5xx-level error more appropriate.

                  await autoCredit(account)
                    .then(async (auto) => {
                      span.setAttribute("exa.autoCredit", auto);
                      if (!auto) return;
                      const credential = await database.query.credentials.findFirst({
                        where: eq(credentials.account, account),
                        columns: {},
                        with: {
                          cards: {
                            columns: { id: true, mode: true },
                            where: inArray(cards.status, ["ACTIVE", "FROZEN"]),
                          },
                        },
                      });
                      if (!credential || credential.cards.length === 0) {
                        throw new Error("Expected card record not found for account");
                      }
                      const card = credential.cards[0];
                      span.setAttribute("exa.card", card.id);
                      if (card.mode !== 0) return;
                      await database.update(cards).set({ mode: 1 }).where(eq(cards.id, card.id));
                      span.setAttribute("exa.mode", 1);
                      sendPushNotification({
                        userId: account,
                        headings: t("Card mode changed"),
                        contents: t("Credit mode activated"),
                      }).catch((error: unknown) => captureException(error));
                    })
                    .catch((error: unknown) => captureException(error));
References
  1. Throw an error when an expected database record (like a card) is not found. This indicates a system or data integrity issue, not a client-side error that can be fixed by the user, so a 5xx-level error is more appropriate than a 4xx.

Copy link
Copy Markdown

@coderabbitai coderabbitai Bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Caution

Some comments are outside the diff and can’t be posted inline due to platform limitations.

⚠️ Outside diff range comments (1)
server/hooks/activity.ts (1)

141-159: ⚠️ Potential issue | 🔴 Critical

Add idempotency guards before enabling full-payload retries

Line [318] now returns 500 for any rejected account, which will make upstream retry the entire webhook payload. Because side effects happen earlier (Line [141] push notification, Line [200] analytics tracking), successful accounts can receive duplicate side effects on retry. This is a non-idempotent retry hazard and should be guarded (e.g., durable dedupe key per activity/account before side effects).

Also applies to: 200-200, 318-320


ℹ️ Review info
⚙️ Run configuration

Configuration used: Organization UI

Review profile: ASSERTIVE

Plan: Pro

Run ID: ee86c277-4d49-45a9-9d65-aa8ccff47679

📥 Commits

Reviewing files that changed from the base of the PR and between dfbd4de and 113b92f.

📒 Files selected for processing (1)
  • server/hooks/activity.ts

Copy link
Copy Markdown

@chatgpt-codex-connector chatgpt-codex-connector Bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: 113b92f208

ℹ️ About Codex in GitHub

Codex has been enabled to automatically review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

When you sign up for Codex through ChatGPT, Codex can also answer questions or update the PR, like "@codex address that feedback".

Comment thread server/hooks/activity.ts
Comment on lines +318 to +320
if (rejected.length > 0) {
return c.json({ code: "activityFailed", failed: rejected.length }, 500);
}
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 Badge Avoid retrying whole activity batch after partial success

Returning 500 when any account pipeline rejects causes Alchemy to retry the entire webhook payload, not just the failed account. In this handler, side effects (e.g., sendPushNotification in the transfer loop and successful exaSend calls for other accounts) can already be committed before this response is decided, and there is no webhook-event idempotency check, so a single failing account can repeatedly duplicate notifications/transactions for accounts that already succeeded.

Useful? React with 👍 / 👎.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant