fix(foundry): deduplicate OAuth callbacks and cache actor handles to fix production auth

The production proxy chain (Cloudflare -> Fastly -> Railway) retries
OAuth callback requests when they take >10s. The first request succeeds
and deletes the verification record, so the retry fails with
"verification not found" -> ?error=please_restart_the_process.

- Add callback deduplication by OAuth state param in the auth handler.
  Duplicate requests wait for the original and return a cloned response.
- Cache appOrganization() and getUser() actor handles to eliminate
  redundant getOrCreate RPCs during callbacks (was 10+ per sign-in).
- Add diagnostic logging for auth callback timing and adapter operations.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
Nathan Flurry 2026-03-16 22:29:17 -07:00
parent 4ca77e4d83
commit 8ddec6831b
2 changed files with 145 additions and 39 deletions

View file

@ -228,7 +228,55 @@ export async function startBackend(options: BackendStartOptions = {}): Promise<v
return session?.session?.id ?? null;
};
// Deduplicate OAuth callback requests. The production proxy chain
// (Cloudflare -> Fastly -> Railway) retries callback requests when they take
// >10s. The first request deletes the verification record on success, so the
// retry fails with "verification not found" -> ?error=please_restart_the_process.
// This map tracks in-flight callbacks by state param so retries wait for and
// reuse the first request's response.
const inflightCallbacks = new Map<string, Promise<Response>>();
app.all("/v1/auth/*", async (c) => {
const authPath = c.req.path;
const authMethod = c.req.method;
const isCallback = authPath.includes("/callback/");
// Deduplicate callback requests by OAuth state parameter
if (isCallback) {
const url = new URL(c.req.url);
const state = url.searchParams.get("state");
if (state) {
const existing = inflightCallbacks.get(state);
if (existing) {
logger.info({ path: authPath, state: state.slice(0, 8) + "..." }, "auth_callback_dedup");
const original = await existing;
return original.clone();
}
const promise = (async () => {
logger.info({ path: authPath, method: authMethod, state: state.slice(0, 8) + "..." }, "auth_callback_start");
const start = performance.now();
const response = await betterAuth.auth.handler(c.req.raw);
const durationMs = Math.round((performance.now() - start) * 100) / 100;
const location = response.headers.get("location");
logger.info({ path: authPath, status: response.status, durationMs, location: location ?? undefined }, "auth_callback_complete");
if (location && location.includes("error=")) {
logger.error({ path: authPath, status: response.status, durationMs, location }, "auth_callback_error_redirect");
}
return response;
})();
inflightCallbacks.set(state, promise);
try {
const response = await promise;
return response.clone();
} finally {
// Keep entry briefly so late retries still hit the cache
setTimeout(() => inflightCallbacks.delete(state), 30_000);
}
}
}
return await betterAuth.auth.handler(c.req.raw);
});

View file

@ -79,17 +79,33 @@ export function initBetterAuthService(actorClient: any, options: { apiUrl: strin
// getOrCreate is intentional here: the adapter runs during Better Auth callbacks
// which can fire before any explicit create path. The app organization and user
// actors must exist by the time the adapter needs them.
const appOrganization = () =>
actorClient.organization.getOrCreate(organizationKey(APP_SHELL_ORGANIZATION_ID), {
createWithInput: APP_SHELL_ORGANIZATION_ID,
});
//
// Handles are cached to avoid redundant getOrCreate RPCs during a single OAuth
// callback (which calls the adapter 5-10+ times). The RivetKit handle is a
// lightweight proxy; caching it just avoids repeated gateway round-trips.
let cachedAppOrganization: any = null;
const appOrganization = async () => {
if (!cachedAppOrganization) {
cachedAppOrganization = await actorClient.organization.getOrCreate(organizationKey(APP_SHELL_ORGANIZATION_ID), {
createWithInput: APP_SHELL_ORGANIZATION_ID,
});
}
return cachedAppOrganization;
};
// getOrCreate is intentional: Better Auth creates user records during OAuth
// callbacks, so the user actor must be lazily provisioned on first access.
const getUser = async (userId: string) =>
await actorClient.user.getOrCreate(userKey(userId), {
createWithInput: { userId },
});
const userHandleCache = new Map<string, any>();
const getUser = async (userId: string) => {
let handle = userHandleCache.get(userId);
if (!handle) {
handle = await actorClient.user.getOrCreate(userKey(userId), {
createWithInput: { userId },
});
userHandleCache.set(userId, handle);
}
return handle;
};
const adapter = createAdapterFactory({
config: {
@ -167,51 +183,91 @@ export function initBetterAuthService(actorClient: any, options: { apiUrl: strin
create: async ({ model, data }) => {
const transformed = await transformInput(data, model, "create", true);
if (model === "verification") {
const organization = await appOrganization();
return await organization.betterAuthCreateVerification({ data: transformed });
const start = performance.now();
try {
const organization = await appOrganization();
const result = await organization.betterAuthCreateVerification({ data: transformed });
logger.info(
{ model, identifier: transformed.identifier, durationMs: Math.round((performance.now() - start) * 100) / 100 },
"auth_adapter_create_verification",
);
return result;
} catch (error) {
logger.error(
{ model, identifier: transformed.identifier, durationMs: Math.round((performance.now() - start) * 100) / 100, error: String(error) },
"auth_adapter_create_verification_error",
);
throw error;
}
}
const createStart = performance.now();
const userId = await resolveUserIdForQuery(model, undefined, transformed);
if (!userId) {
throw new Error(`Unable to resolve auth actor for create(${model})`);
}
const userActor = await getUser(userId);
const created = await userActor.betterAuthCreateRecord({ model, data: transformed });
const organization = await appOrganization();
try {
const userActor = await getUser(userId);
const created = await userActor.betterAuthCreateRecord({ model, data: transformed });
const organization = await appOrganization();
if (model === "user" && typeof transformed.email === "string" && transformed.email.length > 0) {
await organization.betterAuthUpsertEmailIndex({
email: transformed.email.toLowerCase(),
userId,
});
if (model === "user" && typeof transformed.email === "string" && transformed.email.length > 0) {
await organization.betterAuthUpsertEmailIndex({
email: transformed.email.toLowerCase(),
userId,
});
}
if (model === "session") {
await organization.betterAuthUpsertSessionIndex({
sessionId: String(created.id),
sessionToken: String(created.token),
userId,
});
}
if (model === "account") {
await organization.betterAuthUpsertAccountIndex({
id: String(created.id),
providerId: String(created.providerId),
accountId: String(created.accountId),
userId,
});
}
logger.info({ model, userId, durationMs: Math.round((performance.now() - createStart) * 100) / 100 }, "auth_adapter_create_record");
return (await transformOutput(created, model)) as any;
} catch (error) {
logger.error(
{ model, userId, durationMs: Math.round((performance.now() - createStart) * 100) / 100, error: String(error) },
"auth_adapter_create_record_error",
);
throw error;
}
if (model === "session") {
await organization.betterAuthUpsertSessionIndex({
sessionId: String(created.id),
sessionToken: String(created.token),
userId,
});
}
if (model === "account") {
await organization.betterAuthUpsertAccountIndex({
id: String(created.id),
providerId: String(created.providerId),
accountId: String(created.accountId),
userId,
});
}
return (await transformOutput(created, model)) as any;
},
findOne: async ({ model, where, join }) => {
const transformedWhere = transformWhereClause({ model, where, action: "findOne" });
if (model === "verification") {
const organization = await appOrganization();
return await organization.betterAuthFindOneVerification({ where: transformedWhere, join });
const start = performance.now();
try {
const organization = await appOrganization();
const result = await organization.betterAuthFindOneVerification({ where: transformedWhere, join });
const identifier = transformedWhere?.find((entry: any) => entry.field === "identifier")?.value;
logger.info(
{ model, identifier, found: !!result, durationMs: Math.round((performance.now() - start) * 100) / 100 },
"auth_adapter_find_verification",
);
return result;
} catch (error) {
const identifier = transformedWhere?.find((entry: any) => entry.field === "identifier")?.value;
logger.error(
{ model, identifier, durationMs: Math.round((performance.now() - start) * 100) / 100, error: String(error) },
"auth_adapter_find_verification_error",
);
throw error;
}
}
const userId = await resolveUserIdForQuery(model, transformedWhere);
@ -373,6 +429,8 @@ export function initBetterAuthService(actorClient: any, options: { apiUrl: strin
delete: async ({ model, where }) => {
const transformedWhere = transformWhereClause({ model, where, action: "delete" });
if (model === "verification") {
const identifier = transformedWhere?.find((entry: any) => entry.field === "identifier")?.value;
logger.info({ model, identifier }, "auth_adapter_delete_verification");
const organization = await appOrganization();
await organization.betterAuthDeleteVerification({ where: transformedWhere });
return;