Skip to content

Commit 08954b8

Browse files
committed
fix(scheduling): return typed capacity rejection when filters drain candidates
A filter draining a non-empty candidate set produced an Internal error that the scheduler dropped after logging, so profile handlers reported the failure with fresh untyped errors and the director fell through to its legacy ResourceExhausted fallback: the client got a 429 chosen by accident, with no x-llm-d-request-dropped-reason header. Report the drain with the same vocabulary as a flow control capacity rejection (ResourceExhausted plus rejected-saturated), and retain per-profile errors in Schedule, joining them into the ProcessResults error so the typed code survives to the director's errors.As without touching any profile handler. The director's own pre-scheduling rejections (no endpoint candidates located, screeners eliminating every candidate) carry rejected-no-endpoints on their ServiceUnavailable responses, so every pre-dispatch rejection now reports a machine-readable drop reason. Fixes llm-d#2428 Signed-off-by: Luke Van Drie <lukevandrie@google.com>
1 parent 51278d3 commit 08954b8

6 files changed

Lines changed: 126 additions & 7 deletions

File tree

pkg/common/error/error.go

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -26,7 +26,7 @@ import (
2626
)
2727

2828
// RequestDroppedReasonHeaderKey is the HTTP response header that communicates the specific
29-
// reason a request was dropped by flow control.
29+
// reason the EPP dropped a request.
3030
const RequestDroppedReasonHeaderKey = "x-llm-d-request-dropped-reason"
3131

3232
// RequestDroppedReason is the reason a request was rejected before dispatch or evicted after dispatch.

pkg/epp/requestcontrol/director.go

Lines changed: 6 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -304,17 +304,19 @@ func (d *Director) HandleRequest(ctx context.Context, reqCtx *handlers.RequestCo
304304
endpointCandidates := d.endpointCandidates.Locate(ctx, reqCtx.Request.Metadata)
305305
if len(endpointCandidates) == 0 {
306306
return reqCtx, errcommon.Error{
307-
Code: errcommon.ServiceUnavailable,
308-
Msg: "failed to find endpoint candidates for serving the request",
307+
Code: errcommon.ServiceUnavailable,
308+
Msg: "failed to find endpoint candidates for serving the request",
309+
Headers: map[string]string{errcommon.RequestDroppedReasonHeaderKey: string(errcommon.RequestDroppedReasonNoEndpoints)},
309310
}
310311
}
311312

312313
snapshotOfCandidatePods := d.toSchedulerEndpoints(endpointCandidates)
313314
snapshotOfCandidatePods = d.runScreeners(ctx, reqCtx.SchedulingRequest, snapshotOfCandidatePods)
314315
if len(snapshotOfCandidatePods) == 0 {
315316
return reqCtx, errcommon.Error{
316-
Code: errcommon.ServiceUnavailable,
317-
Msg: "screeners eliminated all endpoint candidates",
317+
Code: errcommon.ServiceUnavailable,
318+
Msg: "screeners eliminated all endpoint candidates",
319+
Headers: map[string]string{errcommon.RequestDroppedReasonHeaderKey: string(errcommon.RequestDroppedReasonNoEndpoints)},
318320
}
319321
}
320322
// Prepare per request data by running DataProducer plugins.

pkg/epp/requestcontrol/director_test.go

Lines changed: 47 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -414,11 +414,13 @@ func TestDirector_HandleRequest(t *testing.T) {
414414
initialTargetModelName string // Initial target model in the reqCtx.
415415
parser fwkrh.Parser
416416
wantErrCode string // Expected errcommon code string
417+
wantDroppedReason string // If non-empty, expected x-llm-d-request-dropped-reason header on the error
417418
wantReqCtx *handlers.RequestContext // Fields to check in the returned RequestContext
418419
targetModelName string // Expected model name after target model resolution
419420
admitRequestDenialError error // Expected denial error from admission plugin
420421
dataProducerPlugin *mockDataProducerPlugin
421422
screener *mockScreener
423+
emptyEndpoints bool // If true, the director locates no endpoint candidates.
422424
preRequestPlugins []*mockPreRequestPlugin
423425
requestHeaderPlugin *mockRequestHeaderPlugin
424426
wantMutatedBody map[string]any
@@ -995,7 +997,21 @@ func TestDirector_HandleRequest(t *testing.T) {
995997
screener: &mockScreener{name: "eliminate-all", screen: func([]fwksched.Endpoint) []fwksched.Endpoint {
996998
return nil
997999
}},
998-
wantErrCode: errcommon.ServiceUnavailable,
1000+
wantErrCode: errcommon.ServiceUnavailable,
1001+
wantDroppedReason: string(errcommon.RequestDroppedReasonNoEndpoints),
1002+
},
1003+
{
1004+
name: "no endpoint candidates located",
1005+
reqBodyMap: map[string]any{
1006+
"model": model,
1007+
"prompt": "critical prompt",
1008+
},
1009+
mockAdmissionController: &mockAdmissionController{admitErr: nil},
1010+
initialTargetModelName: model,
1011+
inferenceObjectiveName: objectiveName,
1012+
emptyEndpoints: true,
1013+
wantErrCode: errcommon.ServiceUnavailable,
1014+
wantDroppedReason: string(errcommon.RequestDroppedReasonNoEndpoints),
9991015
},
10001016
{
10011017
name: "scheduler returns error",
@@ -1010,6 +1026,30 @@ func TestDirector_HandleRequest(t *testing.T) {
10101026
wantErrCode: errcommon.ResourceExhausted,
10111027
inferenceObjectiveName: objectiveName,
10121028
},
1029+
{
1030+
// The typed error inside a joined scheduler error, including its
1031+
// drop-reason header, must reach the caller instead of the
1032+
// untyped-error fallback.
1033+
name: "scheduler returns joined error with typed capacity rejection",
1034+
reqBodyMap: map[string]any{
1035+
"model": model,
1036+
"prompt": "prompt that causes scheduling drain",
1037+
},
1038+
mockAdmissionController: &mockAdmissionController{admitErr: nil},
1039+
schedulerMockSetup: func(m *mockScheduler) {
1040+
m.scheduleErr = errors.Join(
1041+
errors.New("failed to run scheduler profile 'default'"),
1042+
fmt.Errorf("profile %q: %w", "default", errcommon.Error{
1043+
Code: errcommon.ResourceExhausted,
1044+
Msg: "no endpoints available for the given request",
1045+
Headers: map[string]string{errcommon.RequestDroppedReasonHeaderKey: string(errcommon.RequestDroppedReasonSaturated)},
1046+
}),
1047+
)
1048+
},
1049+
wantErrCode: errcommon.ResourceExhausted,
1050+
wantDroppedReason: string(errcommon.RequestDroppedReasonSaturated),
1051+
inferenceObjectiveName: objectiveName,
1052+
},
10131053
{
10141054
name: "scheduler returns nil result and nil error",
10151055
reqBodyMap: map[string]any{
@@ -1090,6 +1130,9 @@ func TestDirector_HandleRequest(t *testing.T) {
10901130

10911131
endpointCandidates := NewCachedEndpointCandidates(context.Background(), NewDatastoreEndpointCandidates(ds), time.Minute)
10921132
director := NewDirectorWithConfig(ds, mockSched, test.mockAdmissionController, endpointCandidates, config)
1133+
if test.emptyEndpoints {
1134+
director.endpointCandidates = &mockEndpointCandidates{}
1135+
}
10931136
if len(test.rewrites) > 0 {
10941137
mockDs := &mockDatastore{
10951138
pods: ds.PodList(datastore.AllPodsPredicate),
@@ -1140,6 +1183,9 @@ func TestDirector_HandleRequest(t *testing.T) {
11401183
var e errcommon.Error
11411184
if assert.ErrorAs(t, err, &e, "Error should be of type errcommon.Error") {
11421185
assert.Equal(t, test.wantErrCode, e.Code, "Error code mismatch")
1186+
if test.wantDroppedReason != "" {
1187+
assert.Equal(t, test.wantDroppedReason, e.Headers[errcommon.RequestDroppedReasonHeaderKey], "drop-reason header mismatch")
1188+
}
11431189
}
11441190
return
11451191
}

pkg/epp/scheduling/scheduler.go

Lines changed: 28 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -19,7 +19,10 @@ package scheduling
1919

2020
import (
2121
"context"
22+
"errors"
2223
"fmt"
24+
"maps"
25+
"slices"
2326
"time"
2427

2528
"go.opentelemetry.io/otel/attribute"
@@ -64,6 +67,11 @@ func (s *Scheduler) Schedule(ctx context.Context, request *fwksched.InferenceReq
6467
}()
6568

6669
profileRunResults := map[string]*fwksched.ProfileRunResult{}
70+
// Keyed like profileRunResults so a profile that fails in one iteration and
71+
// succeeds in a later one leaves no stale error behind. Nil until a profile
72+
// fails: delete and len are no-ops on a nil map, and the happy path skips
73+
// the allocation.
74+
var profileRunErrors map[string]error
6775

6876
for { // get the next set of profiles to run iteratively based on the request and the previous execution results
6977
loggerVerbose.Info("Running profile handler, Pick profiles", "plugin", s.profileHandler.TypedName())
@@ -81,8 +89,13 @@ func (s *Scheduler) Schedule(ctx context.Context, request *fwksched.InferenceReq
8189
profileRunResult, err := runSchedulerProfile(ctx, name, profile, request, candidateEndpoints)
8290
if err != nil {
8391
loggerVerbose.Info("failed to run scheduler profile", "profile", name, "error", err.Error())
92+
if profileRunErrors == nil {
93+
profileRunErrors = map[string]error{}
94+
}
95+
profileRunErrors[name] = fmt.Errorf("profile %q: %w", name, err)
8496
} else {
8597
loggerVerbose.Info("Completed running scheduler profile succuessfully", "profile", name)
98+
delete(profileRunErrors, name)
8699
}
87100

88101
profileRunResults[name] = profileRunResult // if profile failed to run, the run result is nil
@@ -100,6 +113,21 @@ func (s *Scheduler) Schedule(ctx context.Context, request *fwksched.InferenceReq
100113
metrics.RecordPluginProcessingLatency(processProfilesResultsExtensionPoint, s.profileHandler.TypedName().Type, s.profileHandler.TypedName().Name, time.Since(before))
101114
loggerVerbose.Info("Completed running profile handler ProcessResults successfully", "plugin", s.profileHandler.TypedName())
102115

116+
// Profile handlers see failed profiles only as nil results and report them
117+
// with fresh untyped errors. Join the retained profile errors so a typed
118+
// errcommon.Error raised inside a profile run (e.g. filters draining the
119+
// candidate set) stays reachable via errors.As in the caller.
120+
if err != nil && len(profileRunErrors) > 0 {
121+
errs := make([]error, 0, len(profileRunErrors)+1)
122+
errs = append(errs, err)
123+
// Sorted so error composition, and therefore errors.As selection when
124+
// profiles fail with different typed codes, is deterministic.
125+
for _, name := range slices.Sorted(maps.Keys(profileRunErrors)) {
126+
errs = append(errs, profileRunErrors[name])
127+
}
128+
err = errors.Join(errs...)
129+
}
130+
103131
return result, err
104132
}
105133

pkg/epp/scheduling/scheduler_profile.go

Lines changed: 9 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -126,7 +126,15 @@ func (p *SchedulerProfile) String() string {
126126
func (p *SchedulerProfile) Run(ctx context.Context, request *fwksched.InferenceRequest, candidateEndpoints []fwksched.Endpoint) (*fwksched.ProfileRunResult, error) {
127127
endpoints := p.runFilterPlugins(ctx, request, candidateEndpoints)
128128
if len(endpoints) == 0 {
129-
return nil, errcommon.Error{Code: errcommon.Internal, Msg: "no endpoints available for the given request"}
129+
// Filters draining a non-empty candidate set means the pool is busy, not
130+
// broken: an empty pool is rejected in the director before scheduling
131+
// runs. Report it with the same status and drop-reason vocabulary as a
132+
// flow control capacity rejection.
133+
return nil, errcommon.Error{
134+
Code: errcommon.ResourceExhausted,
135+
Msg: "no endpoints available for the given request",
136+
Headers: map[string]string{errcommon.RequestDroppedReasonHeaderKey: string(errcommon.RequestDroppedReasonSaturated)},
137+
}
130138
}
131139
// if we got here, there is at least one endpoint to score
132140
weightedScorePerEndpoint := p.runScorerPlugins(ctx, request, endpoints)

pkg/epp/scheduling/scheduler_test.go

Lines changed: 35 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -18,6 +18,7 @@ package scheduling
1818

1919
import (
2020
"context"
21+
"errors"
2122
"testing"
2223

2324
"github.com/google/go-cmp/cmp"
@@ -26,7 +27,9 @@ import (
2627
"github.com/stretchr/testify/assert"
2728
k8stypes "k8s.io/apimachinery/pkg/types"
2829

30+
errcommon "github.com/llm-d/llm-d-router/pkg/common/error"
2931
fwkdl "github.com/llm-d/llm-d-router/pkg/epp/framework/interface/datalayer"
32+
fwkplugin "github.com/llm-d/llm-d-router/pkg/epp/framework/interface/plugin"
3033
fwksched "github.com/llm-d/llm-d-router/pkg/epp/framework/interface/scheduling"
3134
"github.com/llm-d/llm-d-router/pkg/epp/framework/plugins/scheduling/picker"
3235
"github.com/llm-d/llm-d-router/pkg/epp/framework/plugins/scheduling/picker/maxscore"
@@ -159,3 +162,35 @@ func TestSchedule(t *testing.T) {
159162
})
160163
}
161164
}
165+
166+
// Tests that a filter draining the candidate set surfaces a typed capacity
167+
// rejection from Schedule.
168+
func TestScheduleFilterDrainReturnsTypedError(t *testing.T) {
169+
drainingFilter := &testPlugin{typedName: fwkplugin.TypedName{Type: "drain-filter", Name: "drain-filter"}} // empty FilterRes drops every endpoint
170+
171+
profile := NewSchedulerProfile().
172+
WithFilters(drainingFilter).
173+
WithPicker(maxscore.NewMaxScorePicker(picker.DefaultMaxNumOfEndpoints))
174+
175+
schedulerConfig := NewSchedulerConfig(single.NewSingleProfileHandler(), map[string]fwksched.SchedulerProfile{"default": profile})
176+
scheduler := NewSchedulerWithConfig(schedulerConfig)
177+
178+
req := &fwksched.InferenceRequest{
179+
RequestID: uuid.NewString(),
180+
TargetModel: "any-model",
181+
}
182+
input := []fwksched.Endpoint{
183+
fwksched.NewEndpoint(&fwkdl.EndpointMetadata{ID: k8stypes.NamespacedName{Name: "pod1"}}, &fwkdl.Metrics{}, nil),
184+
}
185+
186+
result, err := scheduler.Schedule(context.Background(), req, input)
187+
assert.Nil(t, result)
188+
assert.Error(t, err)
189+
190+
var typedErr errcommon.Error
191+
if !errors.As(err, &typedErr) {
192+
t.Fatalf("Schedule error is not an errcommon.Error: %v", err)
193+
}
194+
assert.Equal(t, errcommon.ResourceExhausted, typedErr.Code)
195+
assert.Equal(t, string(errcommon.RequestDroppedReasonSaturated), typedErr.Headers[errcommon.RequestDroppedReasonHeaderKey])
196+
}

0 commit comments

Comments
 (0)