Skip to content
Merged
Show file tree
Hide file tree
Changes from 8 commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions assets/go-licenses.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

16 changes: 16 additions & 0 deletions custom/conf/app.example.ini
Original file line number Diff line number Diff line change
Expand Up @@ -926,6 +926,7 @@ LEVEL = Info
;USER_DELETE_WITH_COMMENTS_MAX_TIME = 0
;; Valid site url schemes for user profiles
;VALID_SITE_URL_SCHEMES=http,https
;;

;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;
;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;
Expand All @@ -946,6 +947,21 @@ LEVEL = Info
;DISABLE_CODE_PAGE = false
;;

;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;
;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;
;[qos]
;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;
;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;
;; Enable request quality of service and load shedding.
; ENABLED = false
;; The number of requests that are in flight to service before queuing
;; begins. Default is 4 * number of CPUs
; MAX_INFLIGHT =
;; The maximum number of requests that can be enqueued before they will be dropped.
; MAX_WAITING = 100
;; The target time for a request to be enqueued before it might be dropped.
; TARGET_WAIT_TIME = 250ms

;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;
;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;
;; Other Settings
Expand Down
1 change: 1 addition & 0 deletions go.mod
Original file line number Diff line number Diff line change
Expand Up @@ -32,6 +32,7 @@ require (
github.com/aws/aws-sdk-go-v2/service/codecommit v1.28.1
github.com/blakesmith/ar v0.0.0-20190502131153-809d4375e1fb
github.com/blevesearch/bleve/v2 v2.4.2
github.com/bohde/codel v0.2.0
github.com/buildkite/terminal-to-html/v3 v3.16.8
github.com/caddyserver/certmagic v0.22.0
github.com/charmbracelet/git-lfs-transfer v0.2.0
Expand Down
6 changes: 6 additions & 0 deletions go.sum
Original file line number Diff line number Diff line change
Expand Up @@ -179,6 +179,9 @@ github.com/blevesearch/zapx/v16 v16.1.5 h1:b0sMcarqNFxuXvjoXsF8WtwVahnxyhEvBSRJi
github.com/blevesearch/zapx/v16 v16.1.5/go.mod h1:J4mSF39w1QELc11EWRSBFkPeZuO7r/NPKkHzDCoiaI8=
github.com/bmatcuk/doublestar/v4 v4.8.1 h1:54Bopc5c2cAvhLRAzqOGCYHYyhcDHsFF4wWIR5wKP38=
github.com/bmatcuk/doublestar/v4 v4.8.1/go.mod h1:xBQ8jztBU6kakFMg+8WGxn0c6z1fTSPVIjEY1Wr7jzc=
github.com/bmizerany/perks v0.0.0-20141205001514-d9a9656a3a4b/go.mod h1:ac9efd0D1fsDb3EJvhqgXRbFx7bs2wqZ10HQPeU8U/Q=
github.com/bohde/codel v0.2.0 h1:fzF7ibgKmCfQbOzQCblmQcwzDRmV7WO7VMLm/hDvD3E=
github.com/bohde/codel v0.2.0/go.mod h1:Idb1IRvTdwkRjIjguLIo+FXhIBhcpGl94o7xra6ggWk=
github.com/boombuler/barcode v1.0.1-0.20190219062509-6c824513bacc/go.mod h1:paBWMcWSl3LHKBqUq+rly7CNSldXjb2rDl3JlRe0mD8=
github.com/boombuler/barcode v1.0.2 h1:79yrbttoZrLGkL/oOI8hBrUKucwOL0oOjUgEguGMcJ4=
github.com/boombuler/barcode v1.0.2/go.mod h1:paBWMcWSl3LHKBqUq+rly7CNSldXjb2rDl3JlRe0mD8=
Expand Down Expand Up @@ -881,6 +884,7 @@ golang.org/x/sync v0.0.0-20190423024810-112230192c58/go.mod h1:RxMgew5VJxzue5/jJ
golang.org/x/sync v0.0.0-20190911185100-cd5d95a43a6e/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
golang.org/x/sync v0.0.0-20200625203802-6e8e738ad208/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
golang.org/x/sync v0.0.0-20201020160332-67f06af15bc9/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
golang.org/x/sync v0.0.0-20201207232520-09787c993a3a/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
golang.org/x/sync v0.0.0-20220722155255-886fb9371eb4/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
golang.org/x/sync v0.1.0/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
golang.org/x/sync v0.3.0/go.mod h1:FU7BRWz2tNW+3quACPkgCx/L+uEAv1htQ0V83Z9Rj+Y=
Expand Down Expand Up @@ -1025,6 +1029,8 @@ modernc.org/token v1.0.1 h1:A3qvTqOwexpfZZeyI0FeGPDlSWX5pjZu9hF4lU+EKWg=
modernc.org/token v1.0.1/go.mod h1:UGzOrNV1mAFSEB63lOFHIpNRUVMvYTc6yu1SMY/XTDM=
mvdan.cc/xurls/v2 v2.6.0 h1:3NTZpeTxYVWNSokW3MKeyVkz/j7uYXYiMtXRUfmjbgI=
mvdan.cc/xurls/v2 v2.6.0/go.mod h1:bCvEZ1XvdA6wDnxY7jPPjEmigDtvtvPXAD/Exa9IMSk=
pgregory.net/rapid v0.4.2 h1:lsi9jhvZTYvzVpeG93WWgimPRmiJQfGFRNTEZh1dtY0=
pgregory.net/rapid v0.4.2/go.mod h1:UYpPVyjFHzYBGHIxLFoupi8vwk6rXNzRY9OMvVxFIOU=
strk.kbt.io/projects/go/libravatar v0.0.0-20191008002943-06d1c002b251 h1:mUcz5b3FJbP5Cvdq7Khzn6J9OCUQJaBwgBkCR+MOwSs=
strk.kbt.io/projects/go/libravatar v0.0.0-20191008002943-06d1c002b251/go.mod h1:FJGmPh3vz9jSos1L/F91iAgnC/aejc0wIIrF2ZwJxdY=
xorm.io/builder v0.3.13 h1:a3jmiVVL19psGeXx8GIurTp7p0IIgqeDmwhcR6BAOAo=
Expand Down
17 changes: 17 additions & 0 deletions modules/setting/service.go
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@ package setting

import (
"regexp"
"runtime"
"strings"
"time"

Expand Down Expand Up @@ -98,6 +99,13 @@ var Service = struct {
DisableOrganizationsPage bool `ini:"DISABLE_ORGANIZATIONS_PAGE"`
DisableCodePage bool `ini:"DISABLE_CODE_PAGE"`
} `ini:"service.explore"`

QoS struct {
Enabled bool
MaxInFlightRequests int
MaxWaitingRequests int
TargetWaitTime time.Duration
}
}{
AllowedUserVisibilityModesSlice: []bool{true, true, true},
}
Expand Down Expand Up @@ -255,6 +263,7 @@ func loadServiceFrom(rootCfg ConfigProvider) {
mustMapSetting(rootCfg, "service.explore", &Service.Explore)

loadOpenIDSetting(rootCfg)
loadQosSetting(rootCfg)
}

func loadOpenIDSetting(rootCfg ConfigProvider) {
Expand All @@ -276,3 +285,11 @@ func loadOpenIDSetting(rootCfg ConfigProvider) {
}
}
}

func loadQosSetting(rootCfg ConfigProvider) {
sec := rootCfg.Section("qos")
Service.QoS.Enabled = sec.Key("ENABLED").MustBool(false)
Service.QoS.MaxInFlightRequests = sec.Key("MAX_INFLIGHT").MustInt(4 * runtime.NumCPU())
Service.QoS.MaxWaitingRequests = sec.Key("MAX_WAITING").MustInt(100)
Service.QoS.TargetWaitTime = sec.Key("TARGET_WAIT_TIME").MustDuration(250 * time.Millisecond)
}
1 change: 1 addition & 0 deletions options/locale/locale_en-US.ini
Original file line number Diff line number Diff line change
Expand Up @@ -117,6 +117,7 @@ files = Files

error = Error
error404 = The page you are trying to reach either <strong>does not exist</strong> or <strong>you are not authorized</strong> to view it.
error503 = The server was unable to complete your request. Please try again later.
go_back = Go Back
invalid_data = Invalid data: %v

Expand Down
144 changes: 144 additions & 0 deletions routers/common/qos.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,144 @@
// Copyright 2025 The Gitea Authors. All rights reserved.
// SPDX-License-Identifier: MIT

package common

import (
"context"
"fmt"
"net/http"
"strings"

user_model "code.gitea.io/gitea/models/user"
"code.gitea.io/gitea/modules/log"
"code.gitea.io/gitea/modules/setting"
"code.gitea.io/gitea/modules/templates"
"code.gitea.io/gitea/modules/web/middleware"
giteacontext "code.gitea.io/gitea/services/context"

"github.com/bohde/codel"
"github.com/go-chi/chi/v5"
)

const tplStatus503 templates.TplName = "status/503"

type Priority int

func (p Priority) String() string {
switch p {
case HighPriority:
return "high"
case DefaultPriority:
return "default"
case LowPriority:
return "low"
default:
return fmt.Sprintf("%d", p)
}
}

const (
LowPriority = Priority(-10)
DefaultPriority = Priority(0)
HighPriority = Priority(10)
)

// QoS implements quality of service for requests, based upon whether
// or not the user is logged in. All traffic may get dropped, and
// anonymous users are deprioritized.
func QoS() func(next http.Handler) http.Handler {
if !setting.Service.QoS.Enabled {
return nil
}

maxOutstanding := setting.Service.QoS.MaxInFlightRequests
if maxOutstanding <= 0 {
maxOutstanding = 10
}

c := codel.NewPriority(codel.Options{
// The maximum number of waiting requests.
MaxPending: setting.Service.QoS.MaxWaitingRequests,
// The maximum number of in-flight requests.
MaxOutstanding: maxOutstanding,
// The target latency that a blocked request should wait
// for. After this, it might be dropped.
TargetLatency: setting.Service.QoS.TargetWaitTime,
})

return func(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, req *http.Request) {
ctx := req.Context()

priority := requestPriority(ctx)

// Check if the request can begin processing.
err := c.Acquire(ctx, int(priority))
if err != nil {
renderServiceUnavailable(w, req)
return
}

// Release long-polling immediately, so they don't always
// take up an in-flight request
if strings.Contains(req.URL.Path, "/user/events") {
c.Release()
} else {
defer c.Release()
}

next.ServeHTTP(w, req)
})
}
}

// requestPriority assigns a priority value for a request based upon
// whether the user is logged in and how expensive the endpoint is
func requestPriority(ctx context.Context) Priority {
// If the user is logged in, assign high priority.
data := middleware.GetContextData(ctx)
if _, ok := data[middleware.ContextDataKeySignedUser].(*user_model.User); ok {
return HighPriority
}

rctx := chi.RouteContext(ctx)
if rctx == nil {
return DefaultPriority
}

// If we're operating in the context of a repo, assign low priority
routePattern := rctx.RoutePattern()
if strings.HasPrefix(routePattern, "/{username}/{reponame}/") {
return LowPriority
}

return DefaultPriority
}

// renderServiceUnavailable will render an HTTP 503 Service
// Unavailable page, providing HTML if the client accepts it.
func renderServiceUnavailable(w http.ResponseWriter, req *http.Request) {
acceptsHTML := false
for _, part := range req.Header["Accept"] {
if strings.Contains(part, "text/html") {
acceptsHTML = true
break
}
}

// If the client doesn't accept HTML, then render a plain text response
if !acceptsHTML {
http.Error(w, "503 Service Unavailable", http.StatusServiceUnavailable)
return
}

tmplCtx := giteacontext.TemplateContext{}
tmplCtx["Locale"] = middleware.Locale(w, req)
ctxData := middleware.GetContextData(req.Context())
err := templates.HTMLRenderer().HTML(w, http.StatusServiceUnavailable, tplStatus503, ctxData, tmplCtx)
if err != nil {
log.Error("Error occurs again when rendering service unavailable page: %v", err)
w.WriteHeader(http.StatusInternalServerError)
_, _ = w.Write([]byte("Internal server error, please collect error logs and report to Gitea issue tracker"))
}
}
91 changes: 91 additions & 0 deletions routers/common/qos_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,91 @@
// Copyright 2025 The Gitea Authors. All rights reserved.
// SPDX-License-Identifier: MIT

package common

import (
"net/http"
"testing"

user_model "code.gitea.io/gitea/models/user"
"code.gitea.io/gitea/modules/web/middleware"
"code.gitea.io/gitea/services/contexttest"

"github.com/go-chi/chi/v5"
"github.com/stretchr/testify/assert"
)

func TestRequestPriority(t *testing.T) {
type test struct {
Name string
User *user_model.User
RoutePattern string
Expected Priority
}

cases := []test{
{
Name: "Logged In",
User: &user_model.User{},
Expected: HighPriority,
},
{
Name: "Sign In",
RoutePattern: "/user/login",
Expected: DefaultPriority,
},
{
Name: "Repo Home",
RoutePattern: "/{username}/{reponame}",
Expected: DefaultPriority,
},
{
Name: "User Repo",
RoutePattern: "/{username}/{reponame}/src/branch/main",
Expected: LowPriority,
},
}

for _, tc := range cases {
t.Run(tc.Name, func(t *testing.T) {
ctx, _ := contexttest.MockContext(t, "")

if tc.User != nil {
data := middleware.GetContextData(ctx)
data[middleware.ContextDataKeySignedUser] = tc.User
}

rctx := chi.RouteContext(ctx)
rctx.RoutePatterns = []string{tc.RoutePattern}

assert.Exactly(t, tc.Expected, requestPriority(ctx))
})
}
}

func TestRenderServiceUnavailable(t *testing.T) {
t.Run("HTML", func(t *testing.T) {
ctx, resp := contexttest.MockContext(t, "")
ctx.Req.Header.Set("Accept", "text/html")

renderServiceUnavailable(resp, ctx.Req)
assert.Equal(t, http.StatusServiceUnavailable, resp.Code)
assert.Contains(t, resp.Header().Get("Content-Type"), "text/html")

body := resp.Body.String()
assert.Contains(t, body, `lang="en-US"`)
assert.Contains(t, body, "503 Service Unavailable")
})

t.Run("plain", func(t *testing.T) {
ctx, resp := contexttest.MockContext(t, "")
ctx.Req.Header.Set("Accept", "text/plain")

renderServiceUnavailable(resp, ctx.Req)
assert.Equal(t, http.StatusServiceUnavailable, resp.Code)
assert.Contains(t, resp.Header().Get("Content-Type"), "text/plain")

body := resp.Body.String()
assert.Contains(t, body, "503 Service Unavailable")
})
}
2 changes: 1 addition & 1 deletion routers/web/web.go
Original file line number Diff line number Diff line change
Expand Up @@ -285,7 +285,7 @@ func Routes() *web.Router {

webRoutes := web.NewRouter()
webRoutes.Use(mid...)
webRoutes.Group("", func() { registerWebRoutes(webRoutes) }, common.BlockExpensive())
webRoutes.Group("", func() { registerWebRoutes(webRoutes) }, common.BlockExpensive(), common.QoS())
routes.Mount("", webRoutes)
return routes
}
Expand Down
12 changes: 12 additions & 0 deletions templates/status/503.tmpl
Original file line number Diff line number Diff line change
@@ -0,0 +1,12 @@
{{template "base/head" .}}
<div role="main" aria-label="503 Service Unavailable" class="page-content">
<div class="ui container">
<div class="status-page-error">
<div class="status-page-error-title">503 Service Unavailable</div>
<div class="tw-text-center">
<div class="tw-my-4">{{ctx.Locale.Tr "error503"}}</div>
</div>
</div>
</div>
</div>
{{template "base/footer" .}}