4646 "google-gemini requires EMBEDDING_GOOGLE_CLOUD_PROJECT and EMBEDDING_GOOGLE_CLOUD_LOCATION" )
4747)
4848
49- const riverQueueDepthInterval = 15 * time .Second
49+ const (
50+ riverQueueDepthInterval = 15 * time .Second
51+ startupCleanupTimeout = 5 * time .Second
52+ )
5053
5154// embeddingProviderAndModel returns (provider, model) when embeddings are enabled: both EMBEDDING_PROVIDER
5255// and EMBEDDING_MODEL must be set and the provider must be supported. Otherwise returns ("", "") so no
@@ -254,6 +257,8 @@ func NewApp(cfg *config.Config, db *pgxpool.Pool) (*App, error) {
254257 feedbackRecordsService , embeddingsRepo , embeddingMetrics ,
255258 metrics , riverWorkers )
256259 if err != nil {
260+ cleanupNewAppStartupFailure (context .Background (), messageManager , nil , tracerProvider , meterProvider )
261+
257262 if errors .Is (err , service .ErrEmbeddingProviderAPIKey ) {
258263 return nil , fmt .Errorf ("%w: %s" , errEmbeddingProviderAPIKeyRequired , embeddingProviderName )
259264 }
@@ -275,19 +280,7 @@ func NewApp(cfg *config.Config, db *pgxpool.Pool) (*App, error) {
275280 Workers : riverWorkers ,
276281 })
277282 if err != nil {
278- messageManager .Shutdown ()
279-
280- if tracerProvider != nil {
281- if err2 := observability .ShutdownTracerProvider (context .Background (), tracerProvider ); err2 != nil {
282- slog .Error ("shutdown tracer provider after River client error" , "error" , err2 )
283- }
284- }
285-
286- if meterProvider != nil {
287- if err2 := observability .ShutdownMeterProvider (context .Background (), meterProvider ); err2 != nil {
288- slog .Error ("shutdown meter provider after River client error" , "error" , err2 )
289- }
290- }
283+ cleanupNewAppStartupFailure (context .Background (), messageManager , nil , tracerProvider , meterProvider )
291284
292285 return nil , fmt .Errorf ("create River client: %w" , err )
293286 }
@@ -327,8 +320,15 @@ func NewApp(cfg *config.Config, db *pgxpool.Pool) (*App, error) {
327320 feedbackRecordsHandler := handlers .NewFeedbackRecordsHandler (feedbackRecordsService )
328321 healthHandler := handlers .NewHealthHandler ()
329322
323+ openapiHandler , err := handlers .NewOpenAPIHandler (handlers .ResolveOpenAPISpecPath (), cfg .Server .PublicBaseURL )
324+ if err != nil {
325+ cleanupNewAppStartupFailure (context .Background (), messageManager , riverClient , tracerProvider , meterProvider )
326+
327+ return nil , fmt .Errorf ("create openapi handler: %w" , err )
328+ }
329+
330330 server := newHTTPServer (
331- cfg , healthHandler , feedbackRecordsHandler , webhooksHandler , searchHandler ,
331+ cfg , healthHandler , openapiHandler , feedbackRecordsHandler , webhooksHandler , searchHandler ,
332332 meterProvider , tracerProvider ,
333333 )
334334
@@ -344,11 +344,12 @@ func NewApp(cfg *config.Config, db *pgxpool.Pool) (*App, error) {
344344 }, nil
345345}
346346
347- // newHTTPServer builds the HTTP server and muxes (no auth on /health, API key on /v1/).
347+ // newHTTPServer builds the HTTP server and muxes (no auth on /health or /openapi.* , API key on /v1/).
348348// Handler chain: RequestID -> otelhttp(Logging(mux)) so access logs get trace_id/span_id from context.
349349func newHTTPServer (
350350 cfg * config.Config ,
351351 health * handlers.HealthHandler ,
352+ openapi * handlers.OpenAPIHandler ,
352353 feedback * handlers.FeedbackRecordsHandler ,
353354 webhooks * handlers.WebhooksHandler ,
354355 search * handlers.SearchHandler ,
@@ -357,6 +358,8 @@ func newHTTPServer(
357358) * http.Server {
358359 public := http .NewServeMux ()
359360 public .HandleFunc ("GET /health" , health .Check )
361+ public .HandleFunc ("GET /openapi.yaml" , openapi .YAML )
362+ public .HandleFunc ("GET /openapi.json" , openapi .JSON )
360363
361364 protected := http .NewServeMux ()
362365 protected .HandleFunc ("POST /v1/feedback-records" , feedback .Create )
@@ -500,6 +503,31 @@ func shutdownObservability(ctx context.Context, tracer *sdktrace.TracerProvider,
500503 return first
501504}
502505
506+ func cleanupNewAppStartupFailure (
507+ ctx context.Context ,
508+ messageManager * service.MessagePublisherManager ,
509+ riverClient * river.Client [pgx.Tx ],
510+ tracerProvider * sdktrace.TracerProvider ,
511+ meterProvider * sdkmetric.MeterProvider ,
512+ ) {
513+ cleanupCtx , cancel := context .WithTimeout (ctx , startupCleanupTimeout )
514+ defer cancel ()
515+
516+ if messageManager != nil {
517+ messageManager .Shutdown ()
518+ }
519+
520+ if riverClient != nil {
521+ if err := riverClient .Stop (cleanupCtx ); err != nil {
522+ slog .Error ("river stop after startup error" , "error" , err )
523+ }
524+ }
525+
526+ if err := shutdownObservability (cleanupCtx , tracerProvider , meterProvider ); err != nil {
527+ slog .Error ("shutdown observability after startup error" , "error" , err )
528+ }
529+ }
530+
503531// Shutdown stops the server, message publisher, and River in order. Call after Run returns.
504532// Observability is shut down once via defer; its error is returned only when server and River shut down successfully.
505533func (a * App ) Shutdown (ctx context.Context ) (err error ) {
0 commit comments