Skip to content

Commit 6e18155

Browse files
committed
Refactor fallback URL handling and enhance backend API resilience
- Removed hardcoded fallback API URL configurations from environment files and the GitHub Actions workflow. - Introduced a new method for dynamically resolving fallback URLs based on the primary API URL. - Updated the StackClientInterface to support an ordered list of API URLs for improved request routing and sticky fallback behavior. - Added tests for the new fallback URL parsing and validation logic to ensure robustness. These changes streamline the fallback mechanism and improve the SDK's ability to handle backend failures effectively.
1 parent eb0627a commit 6e18155

19 files changed

Lines changed: 455 additions & 188 deletions

File tree

.github/workflows/e2e-fallback-tests.yaml

Lines changed: 0 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -65,14 +65,6 @@ jobs:
6565
cp examples/supabase/.env.development examples/supabase/.env.test.local
6666
cp examples/convex/.env.development examples/convex/.env.test.local
6767
68-
- name: Configure fallback backend URL
69-
run: |
70-
echo "NEXT_PUBLIC_STACK_FALLBACK_API_URL=http://localhost:8110" >> apps/backend/.env.test.local
71-
echo "NEXT_PUBLIC_STACK_FALLBACK_API_URL=http://localhost:8110" >> apps/dashboard/.env.test.local
72-
echo "NEXT_PUBLIC_STACK_FALLBACK_API_URL=http://localhost:8110" >> apps/e2e/.env.test.local
73-
echo "NEXT_PUBLIC_STACK_FALLBACK_API_URL=http://localhost:8110" >> examples/demo/.env.test.local
74-
echo "STACK_BACKEND_BASE_URL=http://localhost:8110" >> apps/e2e/.env.test.local
75-
7668
- name: Build
7769
run: pnpm build
7870

apps/backend/.env.development

Lines changed: 0 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,4 @@
11
NEXT_PUBLIC_STACK_API_URL=http://localhost:${NEXT_PUBLIC_STACK_PORT_PREFIX:-81}02
2-
NEXT_PUBLIC_STACK_FALLBACK_API_URL=http://localhost:${NEXT_PUBLIC_STACK_PORT_PREFIX:-81}10
32
NEXT_PUBLIC_STACK_DASHBOARD_URL=http://localhost:${NEXT_PUBLIC_STACK_PORT_PREFIX:-81}01
43
NEXT_PUBLIC_STACK_HOSTED_HANDLER_DOMAIN_SUFFIX=.localhost:${NEXT_PUBLIC_STACK_PORT_PREFIX:-81}09
54
NEXT_PUBLIC_STACK_IS_LOCAL_EMULATOR=false
Lines changed: 68 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,68 @@
1+
import { describe, expect, it } from 'vitest';
2+
import { parseAndValidateConfig } from './route';
3+
4+
describe('parseAndValidateConfig', () => {
5+
it('should parse a single entry with probability 1', () => {
6+
const result = parseAndValidateConfig({
7+
"1": ["https://api.stack-auth.com"],
8+
});
9+
expect(result).toEqual([
10+
{ probability: 1, urls: ["https://api.stack-auth.com"] },
11+
]);
12+
});
13+
14+
it('should parse multiple entries', () => {
15+
const result = parseAndValidateConfig({
16+
"0.7": ["https://api.stack-auth.com", "https://api2.stack-auth.com"],
17+
"0.3": ["https://api2.stack-auth.com", "https://api.stack-auth.com"],
18+
});
19+
expect(result).toEqual([
20+
{ probability: 0.7, urls: ["https://api.stack-auth.com", "https://api2.stack-auth.com"] },
21+
{ probability: 0.3, urls: ["https://api2.stack-auth.com", "https://api.stack-auth.com"] },
22+
]);
23+
});
24+
25+
it('should allow probabilities summing to less than 1', () => {
26+
const result = parseAndValidateConfig({
27+
"0.5": ["https://api.stack-auth.com"],
28+
"0.3": ["https://api2.stack-auth.com"],
29+
});
30+
expect(result).toHaveLength(2);
31+
});
32+
33+
it('should reject non-object input', () => {
34+
expect(() => parseAndValidateConfig("string")).toThrow("must be a JSON object");
35+
expect(() => parseAndValidateConfig(null)).toThrow("must be a JSON object");
36+
expect(() => parseAndValidateConfig([])).toThrow("must be a JSON object");
37+
expect(() => parseAndValidateConfig(42)).toThrow("must be a JSON object");
38+
});
39+
40+
it('should reject empty object', () => {
41+
expect(() => parseAndValidateConfig({})).toThrow("at least one entry");
42+
});
43+
44+
it('should reject invalid probability keys', () => {
45+
expect(() => parseAndValidateConfig({ "abc": ["https://a.com"] })).toThrow("must be a number between 0 and 1");
46+
expect(() => parseAndValidateConfig({ "-0.1": ["https://a.com"] })).toThrow("must be a number between 0 and 1");
47+
expect(() => parseAndValidateConfig({ "1.5": ["https://a.com"] })).toThrow("must be a number between 0 and 1");
48+
});
49+
50+
it('should reject probabilities summing to more than 1', () => {
51+
expect(() => parseAndValidateConfig({
52+
"0.6": ["https://api.stack-auth.com"],
53+
"0.5": ["https://api2.stack-auth.com"],
54+
})).toThrow("exceeds 1");
55+
});
56+
57+
it('should reject invalid URL values', () => {
58+
expect(() => parseAndValidateConfig({ "1": ["not-a-url"] })).toThrow();
59+
});
60+
61+
it('should reject empty URL arrays', () => {
62+
expect(() => parseAndValidateConfig({ "1": [] })).toThrow();
63+
});
64+
65+
it('should reject non-array values', () => {
66+
expect(() => parseAndValidateConfig({ "1": "https://api.stack-auth.com" })).toThrow();
67+
});
68+
});
Lines changed: 96 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,96 @@
1+
import { createSmartRouteHandler } from "@/route-handlers/smart-route-handler";
2+
import { urlSchema, yupArray, yupNumber, yupObject, yupString } from "@stackframe/stack-shared/dist/schema-fields";
3+
import { StackAssertionError } from "@stackframe/stack-shared/dist/utils/errors";
4+
import { getEnvVariable } from "@stackframe/stack-shared/dist/utils/env";
5+
import { getDefaultApiUrls } from "@stackframe/stack-shared/dist/utils/urls";
6+
7+
/**
8+
* Env var format: JSON object mapping probability (as string number) to URL arrays.
9+
* Probabilities must sum to <= 1. Remaining probability uses the last entry as fallback.
10+
*
11+
* Example:
12+
* {
13+
* "0.7": ["https://api.stack-auth.com", "https://api2.stack-auth.com"],
14+
* "0.3": ["https://api2.stack-auth.com", "https://api.stack-auth.com"]
15+
* }
16+
*/
17+
18+
const urlsArraySchema = yupArray(urlSchema.defined()).min(1).defined();
19+
20+
export function parseAndValidateConfig(raw: unknown): Array<{ probability: number, urls: string[] }> {
21+
if (typeof raw !== "object" || raw === null || Array.isArray(raw)) {
22+
throw new StackAssertionError("STACK_BACKEND_URLS_CONFIG must be a JSON object mapping probability strings to URL arrays");
23+
}
24+
25+
const entries = Object.entries(raw as Record<string, unknown>).map(([key, value]) => {
26+
const probability = Number(key);
27+
if (isNaN(probability) || probability < 0 || probability > 1) {
28+
throw new StackAssertionError(`Invalid probability key "${key}": must be a number between 0 and 1`);
29+
}
30+
const urls = urlsArraySchema.validateSync(value);
31+
return { probability, urls };
32+
});
33+
34+
if (entries.length === 0) {
35+
throw new StackAssertionError("STACK_BACKEND_URLS_CONFIG must have at least one entry");
36+
}
37+
38+
const sum = entries.reduce((acc, e) => acc + e.probability, 0);
39+
if (sum > 1 + 1e-9) {
40+
throw new StackAssertionError(`Probabilities sum to ${sum}, which exceeds 1`);
41+
}
42+
43+
return entries;
44+
}
45+
46+
let cachedEntries: ReturnType<typeof parseAndValidateConfig> | undefined;
47+
function getCachedConfig() {
48+
if (!cachedEntries) {
49+
const rawEnv = getEnvVariable("STACK_BACKEND_URLS_CONFIG", "");
50+
cachedEntries = rawEnv
51+
? parseAndValidateConfig(JSON.parse(rawEnv))
52+
: [{ probability: 1, urls: getDefaultApiUrls(getEnvVariable("NEXT_PUBLIC_STACK_API_URL")) }];
53+
}
54+
return cachedEntries;
55+
}
56+
57+
export const GET = createSmartRouteHandler({
58+
metadata: {
59+
hidden: true,
60+
summary: "Get backend URLs",
61+
description: "Returns a prioritized list of backend API URLs for client-side failover",
62+
tags: ["Internal"],
63+
},
64+
request: yupObject({
65+
method: yupString().oneOf(["GET"]).defined(),
66+
}),
67+
response: yupObject({
68+
statusCode: yupNumber().oneOf([200]).defined(),
69+
bodyType: yupString().oneOf(["json"]).defined(),
70+
body: yupObject({
71+
urls: yupArray(yupString().defined()).defined(),
72+
}).defined(),
73+
}),
74+
handler: async () => {
75+
const entries = getCachedConfig();
76+
77+
const roll = Math.random();
78+
let cumulative = 0;
79+
for (const entry of entries) {
80+
cumulative += entry.probability;
81+
if (roll < cumulative) {
82+
return {
83+
statusCode: 200,
84+
bodyType: "json",
85+
body: { urls: entry.urls },
86+
} as const;
87+
}
88+
}
89+
90+
return {
91+
statusCode: 200,
92+
bodyType: "json",
93+
body: { urls: entries[entries.length - 1].urls },
94+
} as const;
95+
},
96+
});

apps/backend/src/prisma-client.tsx

Lines changed: 19 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -10,8 +10,6 @@ import { captureError, StackAssertionError } from "@stackframe/stack-shared/dist
1010
import { globalVar } from "@stackframe/stack-shared/dist/utils/globals";
1111
import { deepPlainEquals, filterUndefined, typedFromEntries, typedKeys } from "@stackframe/stack-shared/dist/utils/objects";
1212
import { concatStacktracesIfRejected, ignoreUnhandledRejection, runAsynchronously, wait } from "@stackframe/stack-shared/dist/utils/promises";
13-
import { drainInFlightPromises } from "./utils/background-tasks";
14-
import { shutdownOTel } from "./instrumentation";
1513
import { throwingProxy } from "@stackframe/stack-shared/dist/utils/proxies";
1614
import { Result } from "@stackframe/stack-shared/dist/utils/results";
1715
import { traceSpan } from "@stackframe/stack-shared/dist/utils/telemetry";
@@ -20,9 +18,11 @@ import net from "node:net";
2018
import { Pool } from "pg";
2119
import { isPromise } from "util/types";
2220
import { runMigrationNeeded } from "./auto-migrations";
21+
import { shutdownOTel } from "./instrumentation";
2322
import { registerPgPool } from "./lib/dev-perf-stats";
2423
import { Tenancy } from "./lib/tenancies";
2524
import { ensurePolyfilled } from "./polyfills";
25+
import { drainInFlightPromises } from "./utils/background-tasks";
2626

2727
// just ensure we're polyfilled because this file relies on envvars being expanded
2828
ensurePolyfilled();
@@ -108,17 +108,25 @@ function getPostgresPrismaClient(connectionString: string, poolLabel?: string) {
108108
if (!getEnvVariable("VERCEL", "") && !globalVar.__stack_prisma_sigterm_registered) {
109109
globalVar.__stack_prisma_sigterm_registered = true;
110110
process.on("SIGTERM", () => {
111+
// Keep the event loop alive so Node doesn't exit before the drain completes.
112+
// 10s timeout > 8s drain timeout to ensure we have enough time.
113+
const keepAlive = setTimeout(() => {}, 10_000);
114+
111115
runAsynchronously(async () => {
112-
console.log("[SIGTERM] Draining background tasks and database connections...");
113-
await drainInFlightPromises(8000);
114-
await shutdownOTel();
115-
for (const [, entry] of postgresPrismaClientsStore) {
116-
await entry.client.$disconnect();
117-
}
118-
for (const [, client] of prismaClientsStore.neon) {
119-
await client.$disconnect();
116+
try {
117+
console.log("[SIGTERM] Draining background tasks and database connections...");
118+
await drainInFlightPromises(8000);
119+
await shutdownOTel();
120+
for (const [, entry] of postgresPrismaClientsStore) {
121+
await entry.client.$disconnect();
122+
}
123+
for (const [, client] of prismaClientsStore.neon) {
124+
await client.$disconnect();
125+
}
126+
console.log("[SIGTERM] Completed draining background tasks and database connections.");
127+
} finally {
128+
clearTimeout(keepAlive);
120129
}
121-
console.log("[SIGTERM] Completed draining background tasks and database connections.");
122130
});
123131
});
124132
}

apps/dashboard/.env.development

Lines changed: 0 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,4 @@
11
NEXT_PUBLIC_STACK_API_URL=http://localhost:${NEXT_PUBLIC_STACK_PORT_PREFIX:-81}02
2-
NEXT_PUBLIC_STACK_FALLBACK_API_URL=http://localhost:${NEXT_PUBLIC_STACK_PORT_PREFIX:-81}10
32
NEXT_PUBLIC_STACK_DOCS_BASE_URL=http://localhost:${NEXT_PUBLIC_STACK_PORT_PREFIX:-81}04
43
NEXT_PUBLIC_STACK_HOSTED_HANDLER_DOMAIN_SUFFIX=.localhost:${NEXT_PUBLIC_STACK_PORT_PREFIX:-81}09
54
NEXT_PUBLIC_STACK_IS_LOCAL_EMULATOR=false

docker/backend/Dockerfile

Lines changed: 5 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,4 @@
1-
# Backend for Cloud Run / self-hosted deployment.
2-
# Includes migration script for database setup.
1+
# Backend for Cloud Run / self-hosted deployment (fallback backend server).
32
# Connects to the same AWS services (RDS, S3, KMS) as the Vercel deployment.
43
#
54
# Build: docker build -f docker/backend/Dockerfile -t stack-backend .
@@ -57,9 +56,6 @@ ENV NEXT_CONFIG_OUTPUT=standalone
5756
# Build backend only
5857
RUN pnpm turbo run docker-build --filter=@stackframe/backend...
5958

60-
# Build the migration script
61-
RUN cd apps/backend && pnpm build-self-host-migration-script
62-
6359

6460
# Final image
6561
FROM node:${NODE_VERSION}-slim
@@ -71,16 +67,13 @@ RUN apt-get update && \
7167
apt-get install -y --no-install-recommends openssl && \
7268
rm -rf /var/lib/apt/lists/*
7369

74-
# Copy built backend (standalone)
70+
# Copy Next.js standalone output — this includes a traced, minimal copy of
71+
# node_modules/ and packages/ (only the files the server actually imports).
7572
COPY --from=builder --chown=node:node /app/apps/backend/.next/standalone ./
7673
COPY --from=builder --chown=node:node /app/apps/backend/.next/static ./apps/backend/.next/static
77-
COPY --from=builder --chown=node:node /app/apps/backend/prisma ./apps/backend/prisma
78-
COPY --from=builder --chown=node:node /app/apps/backend/dist ./apps/backend/dist
79-
COPY --from=builder --chown=node:node /app/apps/backend/node_modules ./apps/backend/node_modules
8074

81-
# Restore workspace node_modules and packages needed by runtime scripts (e.g. migration script)
82-
COPY --from=builder --chown=node:node /app/node_modules ./node_modules
83-
COPY --from=builder --chown=node:node /app/packages ./packages
75+
# Prisma schema (needed at runtime by Prisma client)
76+
COPY --from=builder --chown=node:node /app/apps/backend/prisma ./apps/backend/prisma
8477

8578
ENV NODE_ENV=production
8679
ENV PORT=8102

examples/demo/.env.development

Lines changed: 0 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,6 @@
11
# Contains the credentials for the internal project of Stack's default development environment setup.
22
# Do not use in a production environment, instead replace it with actual values gathered from https://app.stack-auth.com.
33
NEXT_PUBLIC_STACK_API_URL=http://localhost:${NEXT_PUBLIC_STACK_PORT_PREFIX:-81}02
4-
NEXT_PUBLIC_STACK_FALLBACK_API_URL=http://localhost:${NEXT_PUBLIC_STACK_PORT_PREFIX:-81}10
54
NEXT_PUBLIC_STACK_PROJECT_ID=internal
65
NEXT_PUBLIC_STACK_PUBLISHABLE_CLIENT_KEY=this-publishable-client-key-is-for-local-development-only
76
STACK_SECRET_SERVER_KEY=this-secret-server-key-is-for-local-development-only

examples/demo/src/app/fallback-test/client.tsx

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
"use client";
22

33
import { useStackApp, useUser } from "@stackframe/stack";
4+
import { runAsynchronously } from "@stackframe/stack-shared/dist/utils/promises";
45
import Link from "next/link";
56
import { usePathname } from "next/navigation";
67
import { useCallback, useEffect, useRef, useState } from "react";
@@ -62,7 +63,7 @@ export function FallbackTestClient() {
6263
}, [app, user, addLog]);
6364

6465
useEffect(() => {
65-
void runTests();
66+
runAsynchronously(runTests());
6667
}, []); // eslint-disable-line react-hooks/exhaustive-deps
6768

6869
return (

packages/stack-shared/src/helpers/vault/server-side.ts

Lines changed: 9 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -31,11 +31,15 @@ async function getAwsCredentials() {
3131
if (gcpWifRoleArn) {
3232
const { fromWebToken } = await import("@aws-sdk/credential-provider-web-identity");
3333
const audience = getEnvVariable("STACK_AWS_GCP_WIF_AUDIENCE", "sts.amazonaws.com");
34-
return fromWebToken({
35-
roleArn: gcpWifRoleArn,
36-
roleSessionName: "stack-backend-cloudrun",
37-
webIdentityToken: await fetchGcpIdToken(audience),
38-
});
34+
// Return a provider that fetches a fresh GCP ID token on each invocation.
35+
// GCP metadata tokens expire after ~1h, so we can't bake a single token into the closure.
36+
return async () => {
37+
return await fromWebToken({
38+
roleArn: gcpWifRoleArn,
39+
roleSessionName: "stack-backend-cloudrun",
40+
webIdentityToken: await fetchGcpIdToken(audience),
41+
})();
42+
};
3943
}
4044

4145
// 3. Static credentials: fallback for self-hosted / local development

0 commit comments

Comments
 (0)