Skip to content

🥅 server: bridge credential fallback and retry on timeout#896

Open
mainqueg wants to merge 2 commits intomainfrom
fallback
Open

🥅 server: bridge credential fallback and retry on timeout#896
mainqueg wants to merge 2 commits intomainfrom
fallback

Conversation

@mainqueg
Copy link
Contributor

@mainqueg mainqueg commented Mar 18, 2026

Summary by CodeRabbit

  • New Features

    • Automatic credential fallback by email during customer status changes
    • Onboarding now uses a stable idempotency key and retries on timeouts/5xx
    • Customer records now include an email field
  • Bug Fixes / Data Integrity

    • Bridge identifier is enforced unique across credentials to prevent duplicate pairing
  • Tests

    • Expanded coverage for fallback flows, retries, idempotency, error cases, and event tracking

Open with Devin

@changeset-bot
Copy link

changeset-bot bot commented Mar 18, 2026

🦋 Changeset detected

Latest commit: 22a957e

The changes in this PR will be included in the next version bump.

This PR includes changesets to release 1 package
Name Type
@exactly/server Patch

Not sure what this means? Click here to learn what changesets are.

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

@coderabbitai
Copy link

coderabbitai bot commented Mar 18, 2026

Note

Reviews paused

It looks like this branch is under active development. To avoid overwhelming you with review comments due to an influx of new commits, CodeRabbit has automatically paused this review. You can configure this behavior by changing the reviews.auto_review.auto_pause_after_reviewed_commits setting.

Use the following commands to manage reviews:

  • @coderabbitai resume to resume automatic reviews.
  • @coderabbitai review to trigger a single review.

Use the checkboxes below for quick actions:

  • ▶️ Resume reviews
  • 🔍 Trigger review

Walkthrough

Adds persona email-based credential fallback for bridge webhook handling, idempotent Ramp customer creation with retry-on-timeout/5xx, a Persona accounts search helper, a unique constraint on credentials.bridgeId, and expanded tests covering fallback and retry scenarios.

Changes

Cohort / File(s) Summary
Changeset
.changeset/twco-dcxi-kedl.md
New patch-level changeset documenting bridge credential fallback and retry-on-timeout for @exactly/server.
Bridge Hook
server/hooks/bridge.ts
Adds credential fallback for customer.updated.status_transitioned: calls getCustomer, uses persona.searchAccounts(customer.email) to locate reference-id, attempts conditional credentials update to set bridgeId, emits captureEvent/captureException paths, and handles Drizzle unique-constraint (23505) and "already paired" cases specially.
Persona Utils
server/utils/persona.ts
Adds SearchAccountsResponse schema and exported searchAccounts(email: string) helper that POSTs to /accounts/search and returns parsed account data.
Ramps — Idempotency & Retry
server/utils/ramps/bridge.ts
createCustomer gains optional idempotencyKey; request preserves Idempotency-Key across retries; onboarding generates one idempotency key per run, wraps createCustomer in retry logic for timeouts and 5xx (with Sentry warning captures); CustomerResponse now requires email.
Database Schema
server/database/schema.ts
Changed index("bridge_id_index")uniqueIndex("bridge_id_index") on credentials.bridgeId, enforcing global uniqueness.
Tests — Hooks / Utils / API
server/test/hooks/bridge.test.ts, server/test/utils/bridge.test.ts, server/test/api/ramp.test.ts
Expanded fixtures (emails, additional credentials including conflict case) and added tests for fallback flows (single/multi/no match, already-paired), onboarding retry behavior (timeouts, 5xx, non-retryable 400), idempotency-key consistency, and Sentry captureEvent/captureException assertions.

Sequence Diagram

sequenceDiagram
    participant Client as Client/API
    participant Hook as Bridge Hook
    participant DB as Database
    participant Persona as Persona API
    participant Sentry as Sentry/Events

    Client->>Hook: webhook customer.updated.status_transitioned
    Hook->>DB: lookup credential by account/bridgeId
    alt credential found
        Hook-->>Client: proceed with credential
    else credential missing
        Hook->>Hook: getCustomer(bridgeId)
        alt customer found
            Hook->>Persona: searchAccounts(customer.email)
            Persona-->>Hook: accounts list
            alt single match (reference-id)
                Hook->>DB: update credentials where id==reference-id set bridgeId
                DB-->>Hook: update success / constraint error
                alt update success
                    Hook->>Sentry: captureEvent("bridge credential paired")
                    Hook-->>Client: return updated credential
                else unique constraint (already paired)
                    Hook->>Sentry: captureEvent("bridge credential already paired")
                    Hook-->>Client: credential not found
                end
            else multiple matches
                Hook->>Sentry: captureException(multiple matches)
                Hook-->>Client: credential not found
            else no matches
                Hook->>Sentry: captureException(credential not found)
                Hook-->>Client: credential not found
            end
        else customer missing
            Hook->>Sentry: captureException(credential not found)
            Hook-->>Client: credential not found
        end
    end
Loading

Estimated code review effort

🎯 4 (Complex) | ⏱️ ~45 minutes

Possibly related PRs

Suggested reviewers

  • nfmelendez
  • cruzdanilo
  • dieguezguille
🚥 Pre-merge checks | ✅ 2
✅ Passed checks (2 passed)
Check name Status Explanation
Description Check ✅ Passed Check skipped - CodeRabbit’s high-level summary is enabled.
Title check ✅ Passed The title accurately describes the two main changes: bridge credential fallback logic and retry-on-timeout behavior for the server bridge utility.

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

✨ Finishing Touches
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Commit unit tests in branch fallback
📝 Coding Plan
  • Generate coding plan for human review comments

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

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

@gemini-code-assist
Copy link

Summary of Changes

Hello, I'm Gemini Code Assist1! I'm currently reviewing this pull request and will post my feedback shortly. In the meantime, here's a summary to help you and other reviewers quickly get up to speed!

This pull request significantly improves the robustness of the server's Bridge integration. It introduces a fallback mechanism for identifying user credentials when Bridge webhooks are received, leveraging Persona accounts to match users by email if a direct ID lookup fails. Additionally, it adds retry capabilities to Bridge API calls, specifically for customer creation, to handle transient network issues or server errors more gracefully. These changes aim to reduce credential lookup failures and improve the reliability of user onboarding with Bridge.

Highlights

  • Bridge Credential Fallback: Implemented a fallback mechanism in the Bridge webhook handler to search for credentials using Persona accounts via email if a direct bridgeId lookup fails, improving resilience for customer.updated.status_transitioned events.
  • API Call Retry Logic: Introduced retry logic for Bridge API createCustomer calls, specifically for timeouts and 5xx server errors, to enhance reliability during the onboarding process.
  • Sentry Event Capture: Enhanced Sentry integration to capture specific warning events when a Bridge credential is successfully paired via the new fallback mechanism, and fatal errors for ambiguous Persona account matches.
Using Gemini Code Assist

The full guide for Gemini Code Assist can be found on our documentation page, here are some quick tips.

Invoking Gemini

You can request assistance from Gemini at any point by creating a comment using either /gemini <command> or @gemini-code-assist <command>. Below is a summary of the supported commands on the current page.

Feature Command Description
Code Review /gemini review Performs a code review for the current pull request in its current state.
Pull Request Summary /gemini summary Provides a summary of the current pull request in its current state.
Comment @gemini-code-assist Responds in comments when explicitly tagged, both in pull request comments and review comments.
Help /gemini help Displays a list of available commands.

Customization

To customize Gemini Code Assist for GitHub experience, repository maintainers can create a configuration file and/or provide a custom code review style guide (such as PEP-8 for Python) by creating and adding files to a .gemini/ folder in the base of the repository. Detailed instructions can be found here.

Limitations & Feedback

Gemini Code Assist may make mistakes. Please leave feedback on any instances where its feedback is incorrect or counter productive. You can react with 👍 and 👎 on @gemini-code-assist comments. If you're interested in giving your feedback about your experience with Gemini Code Assist for GitHub and other Google products, sign up here.

Footnotes

  1. Review the Privacy Notices, Generative AI Prohibited Use Policy, Terms of Service, and learn how to configure Gemini Code Assist in GitHub here. Gemini can make mistakes, so double check it and use code with caution.

Copy link

@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 introduces a robust fallback mechanism for bridge credentials and enhances the createCustomer process with retry logic and idempotency. The changes include importing necessary utilities, modifying the bridge.ts webhook to attempt to find credentials via Persona email if the initial lookup fails, and updating the bridge.ts utility to use withRetry for customer creation. Comprehensive test cases have been added to cover the new fallback scenarios, ensuring the reliability of the system. The updates improve the resilience and error handling of the bridge integration.

coderabbitai[bot]

This comment was marked as resolved.

@sentry
Copy link

sentry bot commented Mar 18, 2026

✅ All tests passed.

coderabbitai[bot]

This comment was marked as resolved.

coderabbitai[bot]

This comment was marked as resolved.

coderabbitai[bot]

This comment was marked as resolved.

@cruzdanilo cruzdanilo marked this pull request as ready for review March 20, 2026 13:40
Copy link

@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 3 potential issues.

View 4 additional findings in Devin Review.

Open in Devin Review

Comment on lines +124 to +125
.then(([updated]) => {
if (!updated) throw new Error("credential already paired");

Choose a reason for hiding this comment

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

🟡 Nonexistent credential misreported as "already paired" with fatal severity

When the persona searchAccounts fallback returns a reference-id that doesn't correspond to any credential in the database, the UPDATE at server/hooks/bridge.ts:120-122 returns 0 rows because eq(credentials.id, referenceId) matches nothing. The destructured updated is undefined, so line 125 throws "credential already paired". The .catch at line 136 matches this message and logs a captureEvent at level "fatal" with message "bridge credential already paired" — even though no credential was found at all.

This conflates two distinct failure modes: (a) the credential exists but already has a bridgeId (true "already paired"), and (b) no credential with that referenceId exists. In case (b), a fatal-level Sentry event is created with an incorrect message, which will generate false alerts and hamper debugging in production. The test at line 360-384 exercises this exact path without asserting on the misleading captureEvent.

Prompt for agents
In server/hooks/bridge.ts, lines 124-125, the code assumes that 0 rows returned from the UPDATE means the credential is "already paired", but it could also mean the credential with that referenceId doesn't exist at all. To fix this, after getting 0 rows from the UPDATE, perform an additional check (e.g., a SELECT to see if a credential with that referenceId exists). If it doesn't exist, either return undefined silently or log a different, more accurate message (e.g., "credential not found for reference-id"). Only log "bridge credential already paired" at fatal level when you can confirm the credential exists but already has a bridgeId.
Open in Devin Review

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

Comment on lines +106 to +151
if (!credential && payload.event_type === "customer.updated.status_transitioned") {
credential = await getCustomer(bridgeId)
.then((customer) => (customer ? searchAccounts(customer.email) : undefined))
.then((accounts) => {
if (accounts && accounts.length > 1)
captureException(new Error("multiple persona accounts found"), {
level: "fatal",
contexts: { details: { bridgeId, matches: accounts.length } },
});
return accounts?.length === 1 ? accounts[0]?.attributes["reference-id"] : undefined;
})
.then((referenceId) =>
referenceId
? database
.update(credentials)
.set({ bridgeId })
.where(and(eq(credentials.id, referenceId), isNull(credentials.bridgeId)))
.returning({ account: credentials.account, source: credentials.source })
.then(([updated]) => {
if (!updated) throw new Error("credential already paired");
captureEvent({
message: "bridge credential paired",
level: "warning",
contexts: { details: { bridgeId, referenceId } },
});
return updated;
})
.catch((error: unknown): undefined => {
if (
!(
(error instanceof Error && error.message === "credential already paired") ||
(error instanceof DrizzleQueryError &&
error.cause &&
"code" in error.cause &&
error.cause.code === "23505")
)
)
throw error;
captureEvent({
message: "bridge credential already paired",
level: "fatal",
contexts: { details: { bridgeId } },
});
})
: undefined,
);

Choose a reason for hiding this comment

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

🚩 Fallback chain errors propagate to Hono's error handler

The fallback logic in server/hooks/bridge.ts:107-151 calls external services (getCustomer, searchAccounts) that can throw on network errors or API failures. These exceptions are not caught locally and would propagate to Hono's default error handler, returning a 500 to Bridge. This is likely acceptable since Bridge would retry the webhook on non-200 responses, giving a natural retry mechanism for transient failures. However, a persistent Persona API outage would cause repeated 500s and potentially exhaust Bridge's retry budget for the webhook.

Open in Devin Review

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

Copy link
Contributor Author

Choose a reason for hiding this comment

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

not relevant, as it is the intended behavior


const CustomerResponse = object({
id: string(),
email: string(),

Choose a reason for hiding this comment

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

🚩 email field added as required to CustomerResponse schema

The CustomerResponse valibot schema at server/utils/ramps/bridge.ts:701 now includes email: string() as a required field. This means all Bridge API responses parsed through this schema must include an email field, or the parse/safeParse call will throw. This affects getCustomer, getProvider (which calls getCustomer), and indirectly the hook fallback. If Bridge ever returns a customer response without an email field, this would be a runtime failure. Worth verifying against Bridge's API documentation that email is always present.

Open in Devin Review

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

Copy link
Contributor Author

Choose a reason for hiding this comment

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

can you check the api doc?

Copy link

@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: 22a957ed07

ℹ️ 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 on lines +106 to +107
if (!credential && payload.event_type === "customer.updated.status_transitioned") {
credential = await getCustomer(bridgeId)

Choose a reason for hiding this comment

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

P1 Badge Avoid pairing terminal Bridge statuses during fallback

When a customer.updated.status_transitioned webhook arrives for an unpaired Bridge customer, this branch will persist bridgeId before we inspect payload.event_object.status. That is dangerous for terminal statuses like rejected, paused, or offboarded: once the credential is paired, bridge.onboarding() immediately returns ALREADY_ONBOARDED for any non-null customerId (server/utils/ramps/bridge.ts:299-300), and getProvider() maps those same statuses to NOT_AVAILABLE (server/utils/ramps/bridge.ts:184-193). In other words, a rejected/offboarded webhook that used to be ignored now permanently blocks the user from starting a fresh Bridge onboarding flow.

Useful? React with 👍 / 👎.

Copy link
Contributor Author

Choose a reason for hiding this comment

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

this is a fallback for the user creation endpoint, doesn't matter the customer status

Copy link

@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.

Actionable comments posted: 1


ℹ️ Review info
⚙️ Run configuration

Configuration used: Organization UI

Review profile: ASSERTIVE

Plan: Pro

Run ID: 11359394-81ab-417e-9df2-87898b74896d

📥 Commits

Reviewing files that changed from the base of the PR and between ab44200 and 22a957e.

📒 Files selected for processing (8)
  • .changeset/twco-dcxi-kedl.md
  • server/database/schema.ts
  • server/hooks/bridge.ts
  • server/test/api/ramp.test.ts
  • server/test/hooks/bridge.test.ts
  • server/test/utils/bridge.test.ts
  • server/utils/persona.ts
  • server/utils/ramps/bridge.ts

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