From ef20da1e7c23957a9e7d7bfba05a821ae43e2425 Mon Sep 17 00:00:00 2001 From: Rasmus Rask Date: Fri, 16 Oct 2026 10:36:34 +0200 Subject: [PATCH 1/2] fix(server): return 502 instead of passing through upstream 401 --- backend/server/api/router.go | 4 +- backend/server/api/shared/api_output.go | 24 +++++++---- backend/server/api/shared/api_output_test.go | 44 ++++++++++++++++++++ 3 files changed, 62 insertions(+), 10 deletions(-) create mode 100644 backend/server/api/shared/api_output_test.go diff --git a/backend/server/api/router.go b/backend/server/api/router.go index a6d4562468d..c517e349fbe 100644 --- a/backend/server/api/router.go +++ b/backend/server/api/router.go @@ -150,12 +150,12 @@ func handlePluginCall(basicRes context.BasicRes, pluginName string, handler plug if err != nil { if output != nil && output.Body != nil { logruslog.Global.Error(err, "") - shared.ApiOutputSuccess(c, output.Body, err.GetType().GetHttpCode()) + shared.ApiOutputSuccess(c, output.Body, shared.ClientHttpCode(err.GetType().GetHttpCode())) } else { shared.ApiOutputError(c, err) } } else if output != nil { - status := output.Status + status := shared.ClientHttpCode(output.Status) if status < http.StatusContinue { status = http.StatusOK } diff --git a/backend/server/api/shared/api_output.go b/backend/server/api/shared/api_output.go index cf724d8a9f4..dba5a2a8208 100644 --- a/backend/server/api/shared/api_output.go +++ b/backend/server/api/shared/api_output.go @@ -47,9 +47,9 @@ type ResponsePipelines struct { // ApiOutputErrorWithCustomCode writes a JSON error message to the HTTP response body func ApiOutputErrorWithCustomCode(c *gin.Context, code int, err error) { if e, ok := err.(errors.Error); ok { - logruslog.Global.Error(err, "HTTP %d error", e.GetType().GetHttpCode()) + logruslog.Global.Error(err, "HTTP %d error", ClientHttpCode(e.GetType().GetHttpCode())) messages := e.Messages() - c.JSON(e.GetType().GetHttpCode(), &ApiBody{ + c.JSON(ClientHttpCode(e.GetType().GetHttpCode()), &ApiBody{ Success: false, Message: e.Error(), Code: code, @@ -69,9 +69,9 @@ func ApiOutputErrorWithCustomCode(c *gin.Context, code int, err error) { // ApiOutputAdvancedErrorWithCustomCode writes a JSON error message to the HTTP response body func ApiOutputAdvancedErrorWithCustomCode(c *gin.Context, httpStatusCode, customBusinessCode int, err error) { if e, ok := err.(errors.Error); ok { - logruslog.Global.Error(err, "HTTP %d error", e.GetType().GetHttpCode()) + logruslog.Global.Error(err, "HTTP %d error", ClientHttpCode(e.GetType().GetHttpCode())) messages := e.Messages() - c.JSON(e.GetType().GetHttpCode(), &ApiBody{ + c.JSON(ClientHttpCode(e.GetType().GetHttpCode()), &ApiBody{ Success: false, Message: e.Error(), Code: customBusinessCode, @@ -91,9 +91,9 @@ func ApiOutputAdvancedErrorWithCustomCode(c *gin.Context, httpStatusCode, custom // ApiOutputError writes a JSON error message to the HTTP response body func ApiOutputError(c *gin.Context, err error) { if e, ok := err.(errors.Error); ok { - logruslog.Global.Error(err, "HTTP %d error", e.GetType().GetHttpCode()) + logruslog.Global.Error(err, "HTTP %d error", ClientHttpCode(e.GetType().GetHttpCode())) messages := e.Messages() - c.JSON(e.GetType().GetHttpCode(), &ApiBody{ + c.JSON(ClientHttpCode(e.GetType().GetHttpCode()), &ApiBody{ Success: false, Message: e.Error(), Causes: messages.Causes(), @@ -122,10 +122,18 @@ func ApiOutputSuccess(c *gin.Context, body interface{}, status int) { // ApiOutputAbort writes the HTTP response code header and saves the error internally, but doesn't push it to the response func ApiOutputAbort(c *gin.Context, err error) { if e, ok := err.(errors.Error); ok { - logruslog.Global.Error(err, "HTTP %d abort-error", e.GetType().GetHttpCode()) - _ = c.AbortWithError(e.GetType().GetHttpCode(), errors.Default.New(e.Messages().Format())) + logruslog.Global.Error(err, "HTTP %d abort-error", ClientHttpCode(e.GetType().GetHttpCode())) + _ = c.AbortWithError(ClientHttpCode(e.GetType().GetHttpCode()), errors.Default.New(e.Messages().Format())) } else { logruslog.Global.Error(err, "HTTP %d abort-error (native)", http.StatusInternalServerError) _ = c.AbortWithError(http.StatusInternalServerError, err) } } + +// ClientHttpCode maps an upstream 401 to 502, since clients treat 401 as an expired DevLake session +func ClientHttpCode(code int) int { + if code == http.StatusUnauthorized { + return http.StatusBadGateway + } + return code +} diff --git a/backend/server/api/shared/api_output_test.go b/backend/server/api/shared/api_output_test.go new file mode 100644 index 00000000000..ebaf5730f17 --- /dev/null +++ b/backend/server/api/shared/api_output_test.go @@ -0,0 +1,44 @@ +/* +Licensed to the Apache Software Foundation (ASF) under one or more +contributor license agreements. See the NOTICE file distributed with +this work for additional information regarding copyright ownership. +The ASF licenses this file to You under the Apache License, Version 2.0 +(the "License"); you may not use this file except in compliance with +the License. You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +package shared + +import ( + "net/http" + "net/http/httptest" + "testing" + + "github.com/apache/devlake/core/errors" + "github.com/gin-gonic/gin" + "github.com/stretchr/testify/assert" +) + +func TestApiOutputErrorMapsUpstream401(t *testing.T) { + tests := []struct { + upstream int + want int + }{ + {http.StatusUnauthorized, http.StatusBadGateway}, + {http.StatusNotFound, http.StatusNotFound}, + } + for _, tt := range tests { + w := httptest.NewRecorder() + c, _ := gin.CreateTestContext(w) + ApiOutputError(c, errors.Default.Wrap(errors.HttpStatus(tt.upstream).New("upstream"), "wrapped")) + assert.Equal(t, tt.want, w.Code) + } +} From 08e051109751a6763a910f6485aa458c5289525e Mon Sep 17 00:00:00 2001 From: Rasmus Rask Date: Fri, 16 Oct 2026 12:18:51 +0200 Subject: [PATCH 2/2] refactor(config-ui): drop plugin test 401 exemption --- config-ui/src/utils/request.ts | 13 +------------ 1 file changed, 1 insertion(+), 12 deletions(-) diff --git a/config-ui/src/utils/request.ts b/config-ui/src/utils/request.ts index 3badcc0d4b7..e91739f7242 100644 --- a/config-ui/src/utils/request.ts +++ b/config-ui/src/utils/request.ts @@ -41,29 +41,18 @@ export type RequestConfig = { const isLoginRoute = () => window.location.pathname.replace(/\/+$/, '').endsWith('/login'); -// Plugin connection tests often return 401 when the *remote* credential is -// wrong. That is not a DevLake session expiry — do not wipe the form. -const isPluginConnectionTest = (url?: string) => - !!url && /\/plugins\/[^/]+(?:\/connections\/[^/]+)?\/test\/?$/.test(url); - let redirectingToLogin = false; instance.interceptors.response.use( (response) => response, (error) => { const status = error.response?.status; - const requestUrl = error.config?.url as string | undefined; if (status === 428) { window.location.replace('/db-migrate'); } - if ( - status === 401 && - !isLoginRoute() && - !redirectingToLogin && - !isPluginConnectionTest(requestUrl) - ) { + if (status === 401 && !isLoginRoute() && !redirectingToLogin) { redirectingToLogin = true; const returnUrl = encodeURIComponent(window.location.pathname + window.location.search); window.location.replace(`/login?return_url=${returnUrl}`);