Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
35 changes: 33 additions & 2 deletions packages/agent-bff/src/ai/ai-routes-middleware.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
import type AiProxyClient from './ai-proxy-client';
import type { AiProxyResponse } from './ai-proxy-client';
import type { BffAccessTokenPayload } from '../oauth/bff-token';
import type { EnvironmentIdResolver } from '../oauth/environment-id';
import type ForestServerClient from '../oauth/forest-server-client';
import type { SessionStore } from '../oauth/session-store';
import type { Logger } from '../ports/logger-port';
Expand All @@ -10,11 +11,13 @@ import { AiProxyTimeoutError } from './ai-proxy-client';
import { requireRenderingId } from '../auth/auth-mode';
import { unauthorized } from '../http/bff-http-error';
import {
environmentUnresolved,
oauthRequired,
upstreamError,
upstreamTimeout,
upstreamUnreachable,
} from '../http/bff-local-errors';
import { environmentFailureLevel } from '../oauth/environment-fetch-error';
import { OAuthRequestError } from '../oauth/oauth-error';
import ensureFreshServerAccess from '../oauth/session-lifecycle';

Expand All @@ -28,7 +31,7 @@ export interface AiRoutesMiddlewareOptions {
client: AiProxyClient;
sessionStore: SessionStore;
serverClient: ForestServerClient;
environmentId?: number;
resolveEnvironmentId?: EnvironmentIdResolver;
logger: Logger;
}

Expand All @@ -55,6 +58,32 @@ function describeFailure(error: unknown, depthLeft = MAX_CAUSE_DEPTH): string {
return `${error.name}: ${clamp(error.message)}${tail}`;
}

/**
* Resolved outside the `try` that maps AI proxy failures: a Forest server refusing the environment
* read is not the AI proxy being unreachable, and telling the operator otherwise sends them
* debugging a component that was never contacted.
*/
async function resolveEnvironmentIdOrRefuse(
resolveEnvironmentId: EnvironmentIdResolver | undefined,
logger: Logger,
): Promise<number | undefined> {
if (!resolveEnvironmentId) return undefined;

try {
return await resolveEnvironmentId();
} catch (error) {
logger(
environmentFailureLevel(error),
'AI query refused: the Forest environment id could not be resolved',
{ cause: describeFailure(error) },
);

if (error instanceof Error && error.name === 'TimeoutError') throw upstreamTimeout();

throw environmentUnresolved();
}
}

async function resolveSessionAccessToken(
principal: BffAccessTokenPayload,
store: SessionStore,
Expand Down Expand Up @@ -104,7 +133,7 @@ export default function createAiRoutesMiddleware({
client,
sessionStore,
serverClient,
environmentId,
resolveEnvironmentId,
logger,
}: AiRoutesMiddlewareOptions): Middleware {
return async function aiRoutesMiddleware(ctx, next) {
Expand All @@ -124,6 +153,8 @@ export default function createAiRoutesMiddleware({
logger,
);

const environmentId = await resolveEnvironmentIdOrRefuse(resolveEnvironmentId, logger);

let response: AiProxyResponse;

try {
Expand Down
30 changes: 20 additions & 10 deletions packages/agent-bff/src/build-bff.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
import type { BFFConfig } from './config/env-config';
import type { EnvironmentIdResolver } from './oauth/environment-id';
import type { SessionStore } from './oauth/session-store';
import type { UnfoldSource } from './openapi/unfolded-document';
import type { Logger } from './ports/logger-port';
Expand Down Expand Up @@ -30,6 +31,7 @@ import BODY_LIMIT, { AI_BODY_LIMIT } from './http/body-limit';
import createErrorMiddleware from './http/error-middleware';
import createHealthRoute from './http/health-route';
import createVersionHeaderMiddleware from './http/version-header-middleware';
import createEnvironmentIdResolver, { tolerateEnvironmentIdFailure } from './oauth/environment-id';
import ForestServerClient from './oauth/forest-server-client';
import createOAuthRoutes from './oauth/oauth-routes';
import createInMemorySessionStore from './oauth/session-store';
Expand Down Expand Up @@ -134,11 +136,11 @@ interface OAuthSession {

interface OAuthEdge {
middlewares: Middleware[];
environmentId?: number;
resolveEnvironmentId?: EnvironmentIdResolver;
session?: OAuthSession;
}

async function buildOAuthMiddlewares(config: BFFConfig, logger: Logger): Promise<OAuthEdge> {
function buildOAuthMiddlewares(config: BFFConfig, logger: Logger): OAuthEdge {
const oauthConfig = resolveOAuthConfig(config);

if (!oauthConfig) {
Expand All @@ -151,7 +153,7 @@ async function buildOAuthMiddlewares(config: BFFConfig, logger: Logger): Promise
oauthConfig;

const serverClient = new ForestServerClient({ forestServerUrl, envSecret: forestEnvSecret });
const environmentId = await serverClient.fetchEnvironmentId();
const resolveEnvironmentId = createEnvironmentIdResolver(serverClient);

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

Claude Opus 5 (claude-opus-5): Should fix

To be clear about what this is not: that boot no longer fails is deliberate and the ticket argues it well, so no objection there. The gap is that nothing replaced the diagnostic. A permanently wrong FOREST_ENV_SECRET (a 401, which will never succeed) and a 30-second SaaS blip produce the same Warn line, at request rate, and telling them apart means reading the cause field of each occurrence. The ticket is silent on these semantics, so this is a choice this PR makes rather than one it inherits.

An operator watching a deployment therefore has no signal that distinguishes "this environment will never resolve" from "the SaaS wobbled".

Proportionate fix that keeps the lazy behaviour intact: fire one eager resolution at boot whose only job is to log — .catch attached so it cannot become an unhandled rejection, Error level on a 4xx, Warn on a network failure. Nothing becomes fatal; the deployment just says once what it found.

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

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

The diagnostic gap is real and fixed in 2718d1b, but not with an eager boot resolution: that reintroduces the boot-time network call this PR exists to remove, and cli-core.test.ts:133-138 asserts the boot makes none. Instead the failure now carries its own verdict — fetchEnvironmentId throws EnvironmentFetchError marked permanent on a 4xx other than 429 and on an unparseable body — so a wrong FOREST_ENV_SECRET logs at Error and a SaaS blip stays a Warn, which is the signal an operator was missing, and the negative TTL keeps it from repeating at request rate.


const sessionStore = createInMemorySessionStore({
cipher: createTokenCipher(tokenEncryptionKey),
Expand All @@ -164,13 +166,13 @@ async function buildOAuthMiddlewares(config: BFFConfig, logger: Logger): Promise
sessionStore,
forestAppUrl,
authSecret: forestAuthSecret,
environmentId,
resolveEnvironmentId,
logger,
});

return {
middlewares: [oauthRoutes],
environmentId,
resolveEnvironmentId,
session: { store: sessionStore, serverClient, forestServerUrl },
};
}
Expand Down Expand Up @@ -318,7 +320,7 @@ function buildAgentRouteMiddlewares(
}

function buildAiMiddlewares(config: BFFConfig, oauth: OAuthEdge, logger: Logger): Middleware[] {
const { session, environmentId } = oauth;
const { session, resolveEnvironmentId } = oauth;

if (!session) {
logger('Warn', 'AI query route disabled: the deployment carries no OAuth session');
Expand All @@ -336,7 +338,7 @@ function buildAiMiddlewares(config: BFFConfig, oauth: OAuthEdge, logger: Logger)
client,
sessionStore: session.store,
serverClient: session.serverClient,
environmentId,
resolveEnvironmentId,
logger,
}),
];
Expand All @@ -349,7 +351,6 @@ function buildAgentMiddlewares(
aiMiddlewares: Middleware[],
): Middleware[] {
const { forestAuthSecret, defaultTimezone } = config;
const { environmentId } = oauth;

if (!forestAuthSecret) {
logger('Warn', 'Agent edge disabled: FOREST_AUTH_SECRET is missing');
Expand All @@ -373,7 +374,16 @@ function buildAgentMiddlewares(
source,
hasAiQueryRoute: aiMiddlewares.length > 0,
}),
...(bundle ? [createContextRoutesMiddleware({ store: bundle.store, environmentId })] : []),
...(bundle
? [
createContextRoutesMiddleware({
store: bundle.store,
resolveEnvironmentId: oauth.resolveEnvironmentId
? tolerateEnvironmentIdFailure(oauth.resolveEnvironmentId, logger)
: undefined,
}),
]
: []),
...aiMiddlewares,
createTimezoneMiddleware({ defaultTimezone }),
...buildAgentRouteMiddlewares(bundle, config, logger),
Expand All @@ -397,7 +407,7 @@ export default async function buildBff({
});
}

const oauth = await buildOAuthMiddlewares(config, logger);
const oauth = buildOAuthMiddlewares(config, logger);
const aiMiddlewares = buildAiMiddlewares(config, oauth, logger);
const agentMiddlewares = buildAgentMiddlewares(config, logger, oauth, aiMiddlewares);
const agentErrorMiddleware =
Expand Down
9 changes: 7 additions & 2 deletions packages/agent-bff/src/context/context-routes-middleware.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,12 +8,16 @@ const CONTEXT_ROUTE = '/agent/v1/context';

export interface ContextRoutesMiddlewareOptions {
store: ReadModelStore;
environmentId?: number;
/**
* Resolves to `undefined` rather than rejecting: the id decorates the payload, so a Forest server
* that cannot answer must not take the whole bootstrap route down with it.
*/
resolveEnvironmentId?: () => Promise<number | undefined>;
}

export default function createContextRoutesMiddleware({
store,
environmentId,
resolveEnvironmentId,
}: ContextRoutesMiddlewareOptions): Middleware {
return async function contextRoutesMiddleware(ctx, next) {
if (ctx.path !== CONTEXT_ROUTE || ctx.method !== 'GET') {
Expand All @@ -23,6 +27,7 @@ export default function createContextRoutesMiddleware({
}

const { collections, readModel, revision } = await resolveSchemaSnapshot(store);
const environmentId = await resolveEnvironmentId?.();

ctx.status = 200;
ctx.body = buildContext(collections, readModel, { schemaRevision: revision, environmentId });
Expand Down
8 changes: 8 additions & 0 deletions packages/agent-bff/src/http/bff-local-errors.ts
Original file line number Diff line number Diff line change
Expand Up @@ -89,3 +89,11 @@ export function actionRequiresApproval(
): BffHttpError {
return new BffHttpError(403, 'action_requires_approval', message, { details });
}

export function environmentUnresolved(): BffHttpError {
return new BffHttpError(
502,
'environment_unresolved',
'The Forest environment could not be resolved',
);
}
38 changes: 38 additions & 0 deletions packages/agent-bff/src/oauth/environment-fetch-error.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,38 @@
/** Rate limiting is the one 4xx that heals on its own. */
const RETRYABLE_CLIENT_STATUS = 429;

function isPermanentStatus(status: number): boolean {
return status >= 400 && status < 500 && status !== RETRYABLE_CLIENT_STATUS;
}

/**
* A failed environment id read, told apart by whether retrying can ever help: a Forest server that
* answers and refuses is a configuration error that will never resolve, anything else is transport.
* Consumers log the first at `Error` and the second at `Warn`, which is the only thing that lets an
* operator distinguish a wrong FOREST_ENV_SECRET from a SaaS blip.
*/
export default class EnvironmentFetchError extends Error {
readonly permanent: boolean;

static fromStatus(status: number, statusText: string): EnvironmentFetchError {
return new EnvironmentFetchError(
`Failed to fetch environment: ${status} ${statusText}`,
isPermanentStatus(status),
);
}

constructor(message: string, permanent: boolean) {
super(message);
this.name = 'EnvironmentFetchError';
this.permanent = permanent;
}
}

/** Anything that is not a refusal from the Forest server — a socket error, a timeout — may heal. */
export function isPermanentEnvironmentFailure(error: unknown): boolean {
return error instanceof EnvironmentFetchError && error.permanent;
}

export function environmentFailureLevel(error: unknown): 'Error' | 'Warn' {
return isPermanentEnvironmentFailure(error) ? 'Error' : 'Warn';
}
85 changes: 85 additions & 0 deletions packages/agent-bff/src/oauth/environment-id.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,85 @@
import type ForestServerClient from './forest-server-client';
import type { Logger } from '../ports/logger-port';

import { environmentFailureLevel } from './environment-fetch-error';

/** Resolves the Forest environment id, on demand rather than at boot. */
export type EnvironmentIdResolver = () => Promise<number>;

/**
* How long a failure is remembered. Short enough that a transient outage still heals on the next
* request — the whole point of resolving lazily — long enough that a burst of requests costs the
* Forest server one round trip instead of one each, and costs the caller one deadline instead of
* one per request.
*/
const FAILURE_TTL_MS = 5_000;

/**
* Fetch the environment id at most once per success, and hold a failure only for `FAILURE_TTL_MS`:
* a Forest server that is briefly unreachable must not disable the routes that need the id for the
* life of the process, which is what resolving it at boot did. Concurrent callers share the
* in-flight fetch.
*
* The three consumers deliberately disagree on what a failure means, because the id is worth a
* different amount to each: `/oauth/authorize` cannot mint a login without it and surfaces
* `server_error`, the AI relay cannot address the proxy without it and refuses the query, and
* `/agent/v1/context` merely decorates its payload with it and drops the field (see
* `tolerateEnvironmentIdFailure`).
*/
export default function createEnvironmentIdResolver(
client: Pick<ForestServerClient, 'fetchEnvironmentId'>,
now: () => number = Date.now,
): EnvironmentIdResolver {
let environmentId: number | undefined;
let inFlight: Promise<number> | null = null;
let failedAt: number | undefined;
let failure: unknown;

return async function resolveEnvironmentId(): Promise<number> {
if (environmentId !== undefined) return environmentId;
if (failedAt !== undefined && now() - failedAt < FAILURE_TTL_MS) throw failure;

inFlight ??= client
.fetchEnvironmentId()
.then(resolved => {
environmentId = resolved;
failedAt = undefined;

return resolved;
})
.catch(error => {
failedAt = now();
failure = error;

throw error;
})
.finally(() => {

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

Claude Opus 5 (claude-opus-5): Must fix

While the Forest server hangs — a black-holed connection, a load balancer that never answers, exactly the "briefly unreachable" case the comment above targets — every GET /agent/v1/context blocks for up to 60 s before answering 200 without the id, every /oauth/authorize blocks 60 s before redirecting, and every AI query burns 60 s before the AI proxy is even contacted. The route this design exists to keep answering takes a minute to answer.

Mechanism: the resolver has no deadline of its own and inherits REQUEST_TIMEOUT_MS = 60_000 (forest-server-client.ts:37). Never caching a failure is the right call against bricking, but with no failure memory the cost is paid per request, and the in-flight sharing only covers concurrent callers — two sequential bootstraps pay 60 s each. Each one also emits another identical Warn, so the log fills at request rate.

ECONNREFUSED fails fast, so this is invisible in the common case; nothing bounds the case that hangs.

Two fixes, either works: give this lookup its own short deadline (it is a configuration read, not a user operation — a few seconds), or add a short negative TTL (5–10 s) so a burst pays one round trip instead of one each. The negative TTL additionally protects the SaaS from one retry per request.

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

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

Fixed both ways in 2718d1b: fetchEnvironmentId now carries its own ENVIRONMENT_TIMEOUT_MS = 5_000 instead of inheriting the 60 s request deadline, and the resolver remembers a failure for FAILURE_TTL_MS = 5_000 so a burst pays one round trip instead of one each while still healing on the next request — the log line stays per refused request on purpose, since a request that was refused deserves a trace.

inFlight = null;
});

return inFlight;
};
}

/**
* The context payload carries the environment id when there is one, and drops it when the Forest
* server cannot be reached — the route itself must keep answering either way. A refusal from the
* Forest server is logged at `Error` because it will never resolve on its own; a transport failure
* stays a `Warn`.
*/
export function tolerateEnvironmentIdFailure(
resolveEnvironmentId: EnvironmentIdResolver,
logger: Logger,
): () => Promise<number | undefined> {
return async function resolveOrForget(): Promise<number | undefined> {
try {
return await resolveEnvironmentId();
} catch (error) {
logger(environmentFailureLevel(error), 'Serving the context without an environment id', {
cause: error instanceof Error ? error.message : String(error),
});

return undefined;
}
};
}
Loading
Loading