fix: remove standalone AI Gateway http listener dependency on loading providers - #27303
Conversation
This stack of pull requests is managed by Graphite. Learn more about stacking. |
95a8baa to
f5f8e91
Compare
41c0f78 to
e3686d1
Compare
f5f8e91 to
3fe059b
Compare
e3686d1 to
644873c
Compare
c95d08c to
8633b3c
Compare
a274b42 to
bac89f9
Compare
8633b3c to
a320f49
Compare
8249272 to
27e7f4c
Compare
331f7c9 to
8b7d8c3
Compare
|
/coder-agents-review |
|
Chat: Review posted | View chat Review historydeep-review v0.9.0 | Round 3 | Last posted: Round 3, 23 findings (6 P3, 9 Nit, 8 Note), COMMENT. Review Finding inventoryFinding inventory - PR #27303Findings
Round logRound 3 (cross-check)Churn guard PROCEED. Panel verified all 6 R2 fixes: CRF-16/17/18/19/20 genuinely and completely fixed; the CRF-15 flake and nondeterministic message are cured AT THE ROOT (Komugi/Takumi/Hisoka/Mafu-san/Razor stress-verified -race -count=300..500; daemon.Done() is now the sole deterministic terminal reporter). CRF-18 budget split verified: providerSync 5 + http 300 + daemon 5 + trace 5 = 315 < 330, code const comment and values.yaml agree. CRF-20 TestStandaloneGatewayHealthAndReadiness drives the real ready() to readyz 200 with a connected daemon, not a stub. 2 new + 1 re-raise: CRF-15 re-raise (P3), CRF-22 (P3), CRF-21 (Nit), CRF-23 (Note). Event COMMENT. Reviewed against fe8af0a..c267018. Cross-check notes:
Round 2 (cross-check)Churn guard PROCEED; panel verified all 14 R1 findings genuinely fixed (loadProviders 100% cov, dead code removed, helper reuse restored, renames applied, comments/errors corrected). 6 new findings: 1 P2 (CRF-15), 1 P3 (CRF-20), 2 Note (CRF-17, CRF-18), 2 Nit (CRF-16, CRF-19). Event COMMENT. Reviewed against 27e7f4c..14c4113. Cross-check notes:
Round 1Netero-only gate cleared (P3 and below), panel proceeded. Panel: 16 trigger-matched (Bisky, Hisoka, Mafu-san, Mafuuu, Pariston, Gon, Leorio, Takumi, Meruem, Komugi, Chopper, Killua, Ryosuke, Robin, Zoro, ging-go) + 2 wildcards (Knov, Kite). Law skipped (effective additions 483 < 1000). 3 P3, 5 Note, 6 Nit. Event COMMENT. Reviewed against 27e7f4c..8b7d8c3. Cross-check notes:
Dropped (non-actionable "for the record" notes, no keep-argument): Gon section-label-comment note and retry magic-number note; Komugi nondeterministic-exit-error-text note (both branches set non-nil runErr; only the operator message varies) and RandomPort-race note (repo-wide sanctioned helper, negligible window); Robin test-helper-duplication note (author explicitly said current form works). Process observation (body, not inline): commit subject is 79 chars (project caps at 72) and lowercases "http" where the body writes "HTTP" (Leorio Nit). About deep-reviewCRF = Coder Review Finding (P0-P4, Nit, Note)
|
There was a problem hiding this comment.
This is a clean, well-motivated refactor. It decouples HTTP serving from the initial provider load so /healthz answers as soon as the listener binds, splits liveness (/healthz, process up) from readiness (/readyz, daemon connected plus providers loaded), and enables the Helm startup and liveness probes by default. The shutdown choreography is careful: the daemon is anchored to context.Background() so in-flight requests keep their DRPC connection while httpServer.Shutdown drains, and only then is the daemon closed. The timeout budget (300+5+5=310s) stays under terminationGracePeriodSeconds (330s), and TestStandaloneGatewayRun_ShutdownLifetimes proves the daemon outlives a draining request via channel handshakes rather than sleeps. Multiple reviewers ran the suite under -race with no concurrency findings.
The one theme worth acting on before merge is test coverage of the failure paths. The refactor deleted TestLoadProviders_AIBridgedDoneStopsRetry (and alwaysFailReloader), the only test exercising the daemon-death short-circuit in loadProviders; that branch survived verbatim but now runs at 0% coverage, and it is genuinely reachable in production (a wrong gateway key makes aibridged connect() close Done()). Separately, no test asserts run() ever returns a non-nil error, so the whole errors.Join aggregation and the serve-error path are unwitnessed. The happy paths are proven beautifully; the failure paths are not.
Severity count: 3 P3, 5 Note, 6 Nit. Nothing blocking.
Process note: the commit subject is 79 characters (the project caps subjects at 72) and lowercases "http" where the PR body writes "HTTP". Consider tightening and matching capitalization, e.g. "fix: start AI Gateway HTTP listener before initial provider load".
As Bisky put it: "you pried one stone out of the setting and left the empty prong."
helm/ai-gateway/values.yaml:198
Nit [CRF-11] The terminationGracePeriodSeconds comment names only the 300s HTTP timeout, but the budget now has three components. (Ryosuke)
This cross-file budget has no mechanical enforcement, so the comment is the only link.
The CLI comment now states the sum of shutdownTimeout + daemonShutdownTimeout + traceShutdownTimeout (310s), while this comment still reads "the binary's 300-second shutdown timeout plus termination headroom." Update it to name the daemon and trace timeouts too, so the next bump to any constant does not silently eat the 20s headroom.
🤖
🤖 This review was automatically generated with Coder Agents.
|
/coder-agents-review |
14c4113 to
015eae0
Compare
27e7f4c to
9f4f825
Compare
|
/coder-agents-review |
There was a problem hiding this comment.
Round 2 verified. All 14 round-1 findings are genuinely fixed, not just claimed: the panel ran coverage and stress tests rather than trusting labels. loadProviders is back to 100% coverage with a real regression guard (TestStandaloneGatewayLoadProviders_DaemonDoneStopsRetry closes the daemon inside Reload, deterministically, no timing hack), the unreachable watch-escalation send is gone, both inline shutdowns route through shutdownWithTimeout again, the grace-period comment now itemizes the 300+5+5+20 budget, and the naming/doc fixes all landed. The restructure into runStandaloneGateway + serve reads well and the shutdown-ordering invariant (daemon outlives the HTTP drain) is now proven by TestStandaloneGatewayServe_ShutdownOrder.
One new issue is worth fixing before merge. The CRF-3 fix added an initialProviderLoadErr select arm that duplicates the daemon.Done() arm on the terminal-failure path: on a bad gateway key both arms become ready and Go picks one at random, so the process reports either "AI Gateway daemon exited" or "initialize ai providers". The new TestRunStandaloneGateway_DaemonExited hard-asserts one of the two, which turns that benign nondeterminism into a CI flake. Two reviewers reproduced it independently (Netero naturally at ~2/1000; Komugi deterministically by inserting a scheduling delay). This is the round-1 nondeterministic-exit-text note, dropped then as cosmetic, now given teeth by a test that pins one branch. Fixing only the assertion papers over the fact that the arm is dead machinery (0% coverage, can never deterministically win); the root-cause fix removes the flake, the dead arm, and the nondeterministic message at once.
Severity count (new this round): 1 P2, 1 P3, 2 Note, 2 Nit. Nothing blocking.
Process note: Coder squash-merges the PR title into main's permanent history, so the title matters. It is 79 characters (the project caps subjects at 72) and lowercases "http" where the body writes "HTTP"; consider tightening and matching casing, e.g. "fix(enterprise/cli): decouple AI Gateway HTTP listener from provider load".
As Bisky put it: "this round is lovely ... Real stones."
🤖 This review was automatically generated with Coder Agents.
9f4f825 to
fe8af0a
Compare
015eae0 to
c267018
Compare
|
/coder-agents-review |
There was a problem hiding this comment.
Round 3 verified. All six round-2 findings have genuine fixes, checked against the code rather than the labels. The CRF-15 flake is cured at the root, not re-shadowed: the load goroutine now suppresses the duplicate send, so daemon.Done() is the sole terminal reporter and TestRunStandaloneGateway_DaemonExited is deterministic (multiple reviewers ran it -race at counts of 300-500, clean). The CRF-18 shutdown budget is now split into four sequential timeouts (providerSync 5s + HTTP 300s + daemon 5s + trace 5s = 315s < 330s), and the const comment and values.yaml agree. CRF-20's new TestStandaloneGatewayHealthAndReadiness drives the real newGatewayMux/ready() composition to /readyz 200 with a genuinely connected daemon, then proves readiness drops on disconnect and recovers on reconnect. CRF-16/17/19 landed as claimed.
One round-2 finding is only half-resolved. The CRF-15 fix removed the flake and the nondeterministic message but suppressed the duplicate send instead of removing it, so initialProviderLoadErr, its send, and its serve arm are now provably unreachable dead code (0% coverage, confirmed by static proof: loadProviders only ever returns while daemon.Done() or the reload context is already closed). It is P3, not the original P2, because the CI flake is genuinely gone; the residual is dead machinery plus a latent-reactivation trap. Details on the re-raised thread. Two smaller new items: the central function's doc comment describes connect-then-serve (contradicting the independence this PR delivers), and the provider-reload goroutine is now named with two prefixes.
Severity count (this round): 2 P3, 1 Nit, 1 Note. Nothing blocking.
Process notes. (1) CI is broadly red, but the PR's own diff is clean locally at head: go build ./enterprise/cli/... ./cli/..., gofmt -l, go vet, the AI-gateway Go tests, and the helm ai-gateway golden tests all pass. The changes path-filter job failed first and the red spans checks this diff cannot affect (test-js, Storybook, sqlc-vet, offlinedocs), so the failure appears to originate outside this diff (the rebase/stack/base or CI infra); worth confirming against the job logs before merge. (2) Coder squash-merges the PR title into permanent history; it is 79 characters (the project caps subjects at 72) and lowercases "http" where the body writes "HTTP".
As Hisoka put it: "Round 2 left a corpse on the floor and the author only cleaned up the blood, not the body."
🤖 This review was automatically generated with Coder Agents.
fe8af0a to
12ca765
Compare
f7c4486 to
97feb9d
Compare
97feb9d to
b989fe9
Compare
johnstcn
left a comment
There was a problem hiding this comment.
I don't have any blocking concerns, just a few follow-up suggestions. 👍
| // The sum of daemonShutdownTimeout, httpShutdownTimeout, | ||
| // providerReloadShutdownTimeout, and traceShutdownTimeout must stay below | ||
| // terminationGracePeriodSeconds in helm/ai-gateway/values.yaml so the | ||
| // process can complete graceful shutdown before Kubernetes sends SIGKILL. |
There was a problem hiding this comment.
It may also be worth updating the relevant documentation regarding this.
There was a problem hiding this comment.
ACK, documentation being worked on in separate PR.
| } | ||
|
|
||
| func (s *standaloneGateway) ready() bool { | ||
| return s.daemon.Ready() && s.providersLoaded.Load() |
There was a problem hiding this comment.
What happens when we've completed the initial provider reload but subsequent reloads fail? Would it make sense to store both the last error and the last reload time, and emit those in the readiness endpoint? This could be a useful troubleshooting signal for operators.
There was a problem hiding this comment.
The first loading is special because without it AI Gateway will not serve any providers, have any LLM endpoints mounted, making it essentially useless.
Subsequent reload failing "only" makes providers outdated. The reload happens though DRPC connection which if dropped will make Gateway go into not ready state.
The only remaining edge case is that reload fails while DRPC connection remains active, which should be rare, but I agree that some mechanism that would make Gateway not ready after X failed reloads or X time passed since last successful sync would be nice.
I think WatchProviderReload implementation would make it a bit hard to keep track since it retries internally.
| select { | ||
| case <-dialCtx.Done(): | ||
| case <-testCtx.Done(): | ||
| t.Fatal("daemon dial context was not canceled") | ||
| } | ||
| select { | ||
| case <-daemon.Done(): | ||
| case <-testCtx.Done(): | ||
| t.Fatal("daemon did not stop") | ||
| } |
There was a problem hiding this comment.
Suggestion: testutil.RequireReceive
There was a problem hiding this comment.
Thanks, used testutil.TryReceive.
| require.Eventually(t, func() bool { | ||
| conn, err := net.Dial("tcp", address) | ||
| if err != nil { | ||
| return false | ||
| } | ||
| _ = conn.Close() | ||
| return true | ||
| }, testutil.WaitShort, testutil.IntervalFast) |
There was a problem hiding this comment.
Suggestion: testutil.Eventually
| type controlledShutdownPool struct { | ||
| *aibridged.CachedBridgePool | ||
| err error | ||
| release <-chan struct{} | ||
| started chan<- struct{} | ||
| } |
There was a problem hiding this comment.
Suggestion, non-blocking: We could use github.com/coder/quartz to trap the shutdown process instead. But could be a follow-up.
There was a problem hiding this comment.
Created followup issue
| err error | ||
| after func() | ||
| called chan struct{} | ||
| type failingReloader struct { |
There was a problem hiding this comment.
suggestion, non-blocking: would it make sense to auto-generate a mock for this?
There was a problem hiding this comment.
I don't like auto generated mocks, maybe I need to get used to them but they make code ugly, and long IMO 😅
I agree some more generic mock struct would simplify, eg. failingReloader could simply be failThenSucceedReloader with large failUntil.

Fixes an issue where the standalone AI Gateway waited for the initial provider load before starting its HTTP server.
HTTP serving now starts independently of provider synchronization.
/healthzbecomes available when the HTTP server starts, while/readyzrequires an active DRPC connection and completed initial provider load.Enables the Helm chart's startup and liveness probes by default because liveness no longer depends on provider loading.