-
Notifications
You must be signed in to change notification settings - Fork 12
fix(agent-bff): resolve the environment id on demand, not at boot #1871
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
base: feature/prd-1076-1-extract-build-bff
Are you sure you want to change the base?
Changes from all commits
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| 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'; | ||
| } |
| 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(() => { | ||
|
Member
There was a problem hiding this comment. Choose a reason for hiding this commentThe 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 Mechanism: the resolver has no deadline of its own and inherits
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.
Member
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Fixed both ways in 2718d1b: |
||
| 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; | ||
| } | ||
| }; | ||
| } | ||
There was a problem hiding this comment.
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 sameWarnline, at request rate, and telling them apart means reading thecausefield 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 —
.catchattached so it cannot become an unhandled rejection,Errorlevel on a 4xx,Warnon a network failure. Nothing becomes fatal; the deployment just says once what it found.There was a problem hiding this comment.
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-138asserts the boot makes none. Instead the failure now carries its own verdict —fetchEnvironmentIdthrowsEnvironmentFetchErrormarked permanent on a 4xx other than 429 and on an unparseable body — so a wrongFOREST_ENV_SECRETlogs atErrorand a SaaS blip stays aWarn, which is the signal an operator was missing, and the negative TTL keeps it from repeating at request rate.