Skip to content

fix(agent-bff): resolve the environment id on demand, not at boot - #1871

Open
nbouliol wants to merge 2 commits into
feature/prd-1076-1-extract-build-bfffrom
feature/prd-1076-2-lazy-environment-id
Open

fix(agent-bff): resolve the environment id on demand, not at boot#1871
nbouliol wants to merge 2 commits into
feature/prd-1076-1-extract-build-bfffrom
feature/prd-1076-2-lazy-environment-id

Conversation

@nbouliol

@nbouliol nbouliol commented Sep 1, 2026

Copy link
Copy Markdown
Member

Stacked on #1870.

Why

buildOAuthMiddlewares awaited serverClient.fetchEnvironmentId() while assembling the edge. That made one network call the only fatal boot error of a server whose documented posture is the opposite — "a required var that is absent does not crash the server, it boots and reports the gap through /health".

Worse, the failure mode is silent and permanent: a Forest server unreachable for five seconds at startup leaves OAuth and the AI relay unmounted for the life of the process, with nothing to restart since the process is otherwise healthy.

What

createEnvironmentIdResolver resolves on first use, caches on success, and never caches a failure — concurrent callers share the in-flight fetch. A transient outage now heals itself on the next request.

The three consumers take a resolver instead of a number:

  • /oauth/authorize — a failed resolution is turned into server_error and redirected back to the client, which is what RFC 6749 §4.1.2.1 asks for once the redirect_uri is validated (it is, several lines earlier), rather than a bare 500.
  • the AI relay resolves inside the try that already maps Forest server failures.
  • /agent/v1/context keeps answering and drops the field, with a Warn: the id decorates the payload, it is not what the route is for.

This changes the standalone BFF too, on purpose: one behavior, one code path. createOAuthRoutes is a public export and its options change accordingly.

Tests

81 suites, 1355 tests. Two cli-core tests asserted the old eager fetch and were rewritten to the new intent — the boot makes no network call, and an unreachable Forest server no longer prevents booting. New environment-id suite covers cache-on-success, retry-after-failure and in-flight sharing; oauth-routes covers the server_error redirect.

Fixes PRD-1076

🤖 Generated with Claude Code

Note

Resolve environment id on demand instead of at boot in agent-bff

  • Introduces createEnvironmentIdResolver in environment-id.ts: a lazy, memoized resolver that caches successful fetches, does not cache failures, and deduplicates concurrent in-flight calls
  • Changes buildOAuthMiddlewares from async to sync and removes the eager fetchEnvironmentId() call at boot; all middlewares now receive resolveEnvironmentId (a resolver function) instead of a static environmentId number
  • toleratedEnvironmentId wraps the resolver so AI and context routes log a warning and proceed with undefined when resolution fails, rather than throwing
  • The OAuth /authorize handler resolves the environment id per request and translates failures into OAuth server_error semantics with logging
  • Risk: context and AI routes now return 200 with no environment id when the Forest server is unreachable; previously boot would fail entirely in this case. Reviewers should check buildContext and client.query callers handle environmentId: undefined

Changes since #1871 opened

  • Implemented on-demand environment ID resolution with failure caching and error classification [2718d1b]
  • Refactored AI routes middleware to resolve environment ID before proxying with dedicated error responses [2718d1b]
  • Configured environment ID fetch with dedicated 5-second timeout and typed error responses [2718d1b]
  • Replaced local environment ID tolerance logic in context routes with shared utility [2718d1b]
  • Adjusted environment ID resolution failure logging severity in OAuth authorization flow [2718d1b]
  • Added test coverage for environment ID resolution failure handling and caching behavior [2718d1b]

Macroscope summarized a5fcc1b.

`fetchEnvironmentId()` was awaited while assembling the OAuth edge, so it was
the only fatal boot error of a server whose stated posture is to boot and
report gaps through /health. A Forest server unreachable for five seconds at
startup left OAuth and the AI relay unmounted for the life of the process.

The id is now resolved on the first request that needs it, cached on success
and never on failure, so a transient outage heals itself. On /oauth/authorize
a failed resolution travels back to the client as `server_error`, which is
what RFC 6749 asks for once the redirect_uri is trusted; the context route
keeps answering and just drops the field.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
@linear-code

linear-code Bot commented Sep 1, 2026

Copy link
Copy Markdown

PRD-1076

@qltysh

qltysh Bot commented Sep 1, 2026

Copy link
Copy Markdown

1 new issue

Tool Category Rule Count
qlty Structure High total complexity (count = 56) 1

@qltysh

qltysh Bot commented Sep 1, 2026

Copy link
Copy Markdown

Qlty


Coverage Impact

This PR will not change total coverage.

Modified Files with Diff Coverage (8)

RatingFile% DiffUncovered Line #s
Coverage rating: A Coverage rating: A
packages/agent-bff/src/oauth/forest-server-client.ts100.0%
Coverage rating: A Coverage rating: A
packages/agent-bff/src/oauth/oauth-routes.ts100.0%
Coverage rating: A Coverage rating: A
packages/agent-bff/src/context/context-routes-middleware.ts100.0%
Coverage rating: A Coverage rating: A
packages/agent-bff/src/ai/ai-routes-middleware.ts100.0%
Coverage rating: A Coverage rating: A
packages/agent-bff/src/build-bff.ts100.0%
Coverage rating: A Coverage rating: A
packages/agent-bff/src/http/bff-local-errors.ts100.0%
New file Coverage rating: A
packages/agent-bff/src/oauth/environment-id.ts100.0%
New file Coverage rating: A
packages/agent-bff/src/oauth/environment-fetch-error.ts100.0%
Total100.0%
🚦 See full report on Qlty Cloud »

🛟 Help
  • Diff Coverage: Coverage for added or modified lines of code (excludes deleted files). Learn more.

  • Total Coverage: Coverage for the whole repository, calculated as the sum of all File Coverage. Learn more.

  • File Coverage: Covered Lines divided by Covered Lines plus Missed Lines. (Excludes non-executable lines including blank lines and comments.)

    • Indirect Changes: Changes to File Coverage for files that were not modified in this PR. Learn more.

@Tonours Tonours left a comment

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.

Spec (PRD-1076): conforms to step 2. The eager await serverClient.fetchEnvironmentId() is gone from every path — buildOAuthMiddlewares is no longer async and fetchEnvironmentId has exactly one caller left — and the resolver is threaded through all three consumers. Two clarifications on things that look like gaps and are not: the ticket's "answers 502 on that request" and this PR's server_error redirect are the same object (serverError() builds an OAuthRequestError(502, 'server_error', …)), so there is no divergence there; and the per-consumer failure policy for the AI and context routes is this PR's own design — the ticket only says those two "already take it optional" and is silent on what they do when resolution fails.

The resolver itself is right, and I checked the three things that usually go wrong in this shape: inFlight ??= is assigned synchronously before any await, so concurrent callers share one fetch; .finally clears the slot, so no rejection is ever cached; and the guard is !== undefined rather than truthiness, which combined with forest-server-client.ts:77 rejecting <= 0 means no falsy id can reach a JWT payload or the authorize URL. No unhandled rejection on any path.

What the findings are about is not the mechanism but its cost under failure, and the fact that the one genuinely new branch has no test.

Six findings inline, one below.


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

Applies to: the PR as a whole.

This PR decides a failure policy that now differs per consumer — boot no longer fails at all, the context route degrades by dropping the id, /oauth/authorize surfaces server_error, and the AI route relays an upstream error. Three policies for one value, decided here, recorded in no ADR: the ADR search returns nothing applicable for this area.

All three gates are met — it is hard to reverse (operators and front clients build on these semantics), surprising without context (nothing in the code explains why the three consumers differ), and a real trade-off that the ticket itself argues explicitly (self-healing against a loud boot failure). Worth a pass of /adr so the next person changing one of the three knows the other two were deliberate.


return resolved;
})
.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.

response = await client.query({
saasAccessToken,
environmentId,
environmentId: await resolveEnvironmentId?.(),

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

With a wrong FOREST_ENV_SECRET, the client is told The Forest server could not be reached and the log line names the AI query — so the operator debugs the AI proxy, which was never contacted. The real failure is Failed to fetch environment: 401 Unauthorized (forest-server-client.ts:65): the Forest server is reachable and is refusing. The message is not imprecise, it is wrong.

Mechanism: this await sits in client.query's argument list, therefore inside the try opened on the line above. A resolver rejection lands in the catch below, which logs AI query failed against the Forest server and throws upstreamUnreachable()502 network_error (bff-local-errors.ts:72). At the merge base environmentId was a plain number and this try covered only client.query.

Secondary, same cause: a resolver timeout is a DOMException named TimeoutError, not an AiProxyTimeoutError, so the instanceof check misses it and a 60 s environment-id stall returns 502 network_error instead of 504 upstream_timeout.

Resolve before the try (or in its own try) and give it its own message, the way the OAuth path already does.

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 in 2718d1b: the resolution moved out of that try into resolveEnvironmentIdOrRefuse, which logs AI query refused: the Forest environment id could not be resolved and throws 502 environment_unresolved, or 504 upstream_timeout when the failure is a TimeoutError — so the AI proxy is no longer blamed for a Forest server refusal.

Comment thread packages/agent-bff/src/build-bff.ts Outdated

return async function resolveOrForget(): Promise<number | undefined> {
try {
return await resolveEnvironmentId();

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): Violates conventionsskills/conventions/testing.md#Cover error and edge paths, not only the happy path

This catch is the only thing standing between a Forest server outage and a broken front bootstrap, and nothing exercises it. git grep 'Serving the context without an environment id' at this SHA hits this definition only.

The branch is reachable: fetchEnvironmentId rejects on an unreachable fetch and on !response.ok, the resolver propagates it with no internal catch, and test/cli-core.test.ts now proves the process boots with global.fetch rejecting — so the context route is mounted and live with a rejecting resolver. The catch fires on the first GET /agent/v1/context in that state.

What no test asserts: that the route answers 200 with meta carrying schemaRevision and no environmentId, and that the Warn carries the cause. test/context/context-routes-middleware.test.ts:36 always wires a resolver that resolves, and cli-core.test.ts stops at expect(server).toBeDefined() without issuing a request. That suite already wires createErrorMiddleware, so the missing case is a few lines — test/oauth/oauth-routes.test.ts:160-176 does exactly this for the OAuth path and is the model.

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 in 2718d1b: the wrapper moved to environment-id.ts as tolerateEnvironmentIdFailure, and context-routes-middleware.test.ts now wires it over a rejecting resolver and asserts the route answers 200 with meta carrying only schemaRevision, the four collections, and the log line with its cause — plus three level-mapping cases in environment-id.test.ts.


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.

app.use(createErrorMiddleware({ logger: () => {} }));
app.use(createContextRoutesMiddleware({ store, environmentId }));
app.use(
createContextRoutesMiddleware({ store, resolveEnvironmentId: async () => environmentId }),

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): Violates conventionsskills/conventions/testing.md#Test name states the exact behavior it asserts

Applies to: packages/agent-bff/test/context/context-routes-middleware.test.ts:107 (not in this diff) — anchored on the wiring this PR changed, which is what makes the name below stale.

Line 107 still reads it('should carry the environment id the deployment resolved at boot', …), in the PR titled not at boot. This line changed its wiring from a boot-time value to a per-request resolver, so "resolved at boot" is the one thing the test no longer proves.

Consequence: the file someone opens to learn how the context payload gets its environment id documents the semantics this PR deletes — and the next person reasoning about caching or refresh reads "at boot" as the contract. Line 115 (when the deployment resolved none) carries the same flavour, less wrongly.

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 in 2718d1b: renamed to should carry the environment id the resolver returns and should omit the environment id when the resolver returns none.

try {
return await options.resolveEnvironmentId();
} catch (error) {
options.logger('Warn', 'Could not resolve the Forest environment id', {

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): Preferential

The redirect itself is right — RFC 6749 §4.1.2.1, and the id is only needed once the redirect_uri is trusted, exactly as the comment says. The consequence worth accepting knowingly: /oauth/authorize then answers 302, so any uptime check or error-rate alert keyed on status codes sees a healthy route while every login is broken, and the only server-side trace is this Warn.

A login that cannot start is not a warning. Error here costs nothing and is the difference between an incident someone notices and one a user reports.

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.

Agreed and changed in 2718d1b: oauth-routes.ts:125 logs at Error whatever the cause, with the comment saying why — the 302 means no status-code alert can fire while every login is broken.

The lazy resolver inherited the client's 60 s request deadline, so a Forest
server that accepts the connection and never answers made every context
request, every authorize redirect and every AI query pay a minute before
answering — the very routes the lazy design exists to keep serving. The read
now has a 5 s deadline of its own, and a failure is remembered for 5 s so a
burst costs one round trip instead of one each, while still healing on the
next request.

The AI relay resolved the id inside the try that maps AI proxy failures, so a
wrong FOREST_ENV_SECRET was reported as "The Forest server could not be
reached" against a proxy that was never contacted, and a resolution timeout
came back as 502 network_error. It now resolves before that try, refuses with
502 environment_unresolved (504 on a timeout) and logs the environment read
by name.

Nothing distinguished a failure that will never resolve from a SaaS blip.
fetchEnvironmentId now throws EnvironmentFetchError carrying that verdict, so a
refusal from the Forest server is logged at Error and a transport failure stays
a Warn. /oauth/authorize logs at Error either way: it still answers 302, so no
status-code alert can fire while every login is broken.

The context route's tolerating wrapper moves next to the resolver, where the
three consumers' deliberately different failure policies are documented in one
place, and where it can be tested: the route answering 200 without the field
had no coverage.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
@nbouliol

nbouliol commented Sep 2, 2026

Copy link
Copy Markdown
Member Author

On the ADR finding: the repository has no ADR practice to add to — find . -iname "*adr*" outside node_modules returns nothing, and there is no docs/adr, so this PR would have to establish the convention rather than follow it. Standing up that infrastructure inside one of eight stacked PRs is out of scope here.

The substance of the finding is addressed in code instead, in 2718d1b: the three failure policies are now documented together in environment-id.ts (the doc comment on createEnvironmentIdResolver) rather than being three unexplained decisions in three files — /oauth/authorize cannot mint a login without the id, the AI relay cannot address the proxy without it, and /agent/v1/context merely decorates its payload with it. That is the file anyone changing one of the three opens first.

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.

2 participants