Skip to content

add middleware for request prioritization #33951

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Merged
merged 18 commits into from
Apr 14, 2025
Merged
Show file tree
Hide file tree
Changes from all 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.

23 changes: 22 additions & 1 deletion custom/conf/app.example.ini
Original file line number Diff line number Diff line change
Expand Up @@ -940,7 +940,29 @@ LEVEL = Info
;;
;; Disable the code explore page.
;DISABLE_CODE_PAGE = false

;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;
;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;
;[qos]
;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;
;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;
;;
;; Enable request quality of service and overload protection.
; ENABLED = false
;;
;; The maximum number of concurrent requests that the server will
;; process before enqueueing new requests. Default is "CpuNum * 4".
; MAX_INFLIGHT =
;;
;; The maximum number of requests that can be enqueued before new
;; requests will be dropped.
; MAX_WAITING = 100
;;
;; Target maximum wait time a request may be enqueued for. Requests
;; that are enqueued for less than this amount of time will not be
;; dropped. When wait times exceed this amount, a portion of requests
;; will be dropped until wait times have decreased below this amount.
; TARGET_WAIT_TIME = 250ms

;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;
;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;
Expand Down Expand Up @@ -1423,7 +1445,6 @@ LEVEL = Info
;; or use comma separated list: inline-dollar, inline-parentheses, block-dollar, block-square-brackets
;; Defaults to "inline-dollar,block-dollar" to follow GitHub's behavior.
;MATH_CODE_BLOCK_DETECTION =
;;

;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;
;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;
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
145 changes: 145 additions & 0 deletions routers/common/qos.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,145 @@
// 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 {
log.Error("QoS error, dropping request of priority %s: %v", priority, err)
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" .}}