Skip to content

fix(scheduler): remove a request's queue entry once it stops waiting - #43061

Merged
mateo-berri merged 15 commits into
BerriAI:mainfrom
RachelHuangZW:litellm_scheduler_remove_admitted_requests
Oct 8, 2026
Merged

mateo-berri merged 15 commits into
BerriAI:mainfrom
RachelHuangZW:litellm_scheduler_remove_admitted_requests

Conversation

@RachelHuangZW

@RachelHuangZW RachelHuangZW commented Sep 24, 2026 •

Copy link
Copy Markdown
Contributor

TLDR

Problem this solves:

  • Admitted prioritized requests never leave the scheduler queue
  • Requests cancelled while waiting also leave their entry behind
  • With Redis, about half of prioritized requests fail with a TypeError
  • During cooldown, stale entries block new requests until timeout
  • Two prioritized requests that overlap on a Redis-backed proxy hit the same TypeError even with an otherwise empty queue, since Redis hands the queue back as lists and the new entry is a tuple

How it solves it:

  • One Scheduler.wait_for_turn shared by both scheduling paths
  • It removes the queue entry in a finally block
  • Covers admitted, timed out and cancelled requests alike
  • poll() admits on a healthy deployment before reading the queue
  • The removal runs under asyncio.shield, so a second cancel during cleanup can't cut it short
  • get_queue() validates every entry read from the cache into a (priority, request_id) tuple, so the heap compares like with like whatever Redis handed back
  • During cooldown, poll() puts a request whose queue entry vanished (the key expired, or a concurrent writer erased it) back at its priority, so it keeps its place instead of failing with Incorrectly setup. Queue is invalid
    • Admitting it at once was the first cut and let it jump the queue (Greptile flagged that); the old 500 failed a request whose own state was lost. poll() takes the FlowItem now, since putting the entry back needs the priority

User Flow

Before: an app sending prioritized chat requests through a proxy backed by Redis sees about half of them fail while the model is healthy

  1. The proxy admin sets redis_host and redis_port under router_settings and starts the proxy
  2. The app sends POST http://localhost:4000/v1/chat/completions with "priority": 1, once a second
  3. The first 5 requests return 200 with the model's reply
  4. The next 5 return 500 with '<' not supported between instances of 'tuple' and 'list', then the cycle repeats
  5. The app fires 8 prioritized requests at the same time; most of them return 500 with that same TypeError while the rest return 200
  6. While every deployment of a model is in cooldown, a prioritized request that arrives behind a stale queue entry (left by a worker killed mid-wait) returns 500 with the TypeError at once

After: the same requests all succeed

  1. The proxy admin sets redis_host and redis_port under router_settings and starts the proxy
  2. The app sends POST http://localhost:4000/v1/chat/completions with "priority": 1, once a second
  3. The first 5 requests return 200 with the model's reply
  4. The next 10 also return 200, and so does every request after that
  5. The app fires 8 prioritized requests at the same time; all 8 return 200
  6. While every deployment of a model is in cooldown, the same request keeps its place behind the stale entry until that entry's key expires about 5 s later, then gets the usual 429 cooldown answer

Relevant issues

Fixes #43059

#43060 was closed in favor of this PR. Thanks to @apex-mochen for identifying the cancellation cleanup race and suggesting the shielded cleanup and its regression coverage

Linear ticket

Resolves LIT-9076

Pre-Submission checklist

Please complete all items before asking a LiteLLM maintainer to review your PR

  • I have added meaningful tests
  • The handful of test files covering my change pass locally, e.g. uv run pytest tests/unit/<your_test_file>.py -v. Leave the suites (make test-unit-*, make test-unit) to CI: it finishes in ~15 minutes where a laptop takes an hour or more
  • My PR passes all required CI/CD checks (e.g., lint, schema.d.ts sync check, etc.)
  • My PR's scope is as isolated as possible; it only solves 1 specific problem
  • I have received a Greptile Confidence Score of at least 4/5 before requesting a maintainer review (Greptile reviews automatically once the PR is opened; only comment @greptileai to re-request a review after pushing changes)

Delays in PR merge?

If you're seeing a delay in your PR being merged, ping the LiteLLM Team on Slack (#pr-review).

Screenshots / Proof of Fix

Both legs drive the same two-replica rig: two proxies with 2 uvicorn workers each (--num_workers 2 --timeout_worker_healthcheck 60 --telemetry False), sharing one local Redis and running without a database, each booted from the commit named in its heading. Requests alternate between instance A and instance B so a queue entry written through one worker is read back through another. Every call goes to real OpenAI gpt-5.4-nano (no mock_response), qa-cooldown carries an invalid OpenAI key so its only deployment cools down after allowed_fails: 1, and $LITELLM_MASTER_KEY is exported from the proxy's .env. The 0 at the top of an output is the redis-cli del reply (the key had already expired, see the observations)

proxy_config.yaml, identical on both sides apart from the Redis port:

model_list:
  - model_name: qa-chat
    litellm_params:
      model: openai/gpt-5.4-nano
      api_key: os.environ/OPENAI_API_KEY
  - model_name: qa-cooldown
    litellm_params:
      model: openai/gpt-5.4-nano
      api_key: os.environ/QA_BAD_OPENAI_KEY

router_settings:
  redis_host: 127.0.0.1
  redis_port: <rig port>
  timeout: 30
  num_retries: 0
  allowed_fails: 1
  cooldown_time: 180

general_settings:
  master_key: os.environ/LITELLM_MASTER_KEY

Before (31b90eb)

Instance A on 127.0.0.1:42184, instance B on 127.0.0.1:42403, Redis on 48170. Every curl carries -H "Authorization: Bearer $LITELLM_MASTER_KEY" -H "Content-Type: application/json"

Steady prioritized chat on /v1/chat/completions, 16 requests one per second, alternating A, B

  1. Command as run
PORTS=(42184 42403); NAMES=(A B)
redis-cli -p 48170 del scheduler:queue:qa-chat
for i in $(seq 1 16); do
  idx=$(( (i - 1) % 2 + 1 )); port=${PORTS[$idx]}; name=${NAMES[$idx]}
  out=$(curl -s -m 60 -w '\n%{http_code}' -X POST "http://127.0.0.1:${port}/v1/chat/completions" \
    -H "Authorization: Bearer $LITELLM_MASTER_KEY" -H "Content-Type: application/json" \
    -d '{"model":"qa-chat","messages":[{"role":"user","content":"Reply with the single word pong"}],"priority":1}')
  code=${out##*$'\n'}; body=${out%$'\n'*}
  msg=$(printf '%s' "$body" | jq -r 'if .choices then .choices[0].message.content else (.error.message // .detail // .) end' 2>/dev/null || printf '%s' "$body")
  echo "req=$i inst=$name http=$code | $msg"
  sleep 1
done
  1. Observed output (5 of 16 HTTP 200 with pong, 11 of 16 HTTP 500 with the TypeError, on both instances)
12:05:50 start A
0
req=1 inst=A http=200 | pong
req=2 inst=B http=500 | '<' not supported between instances of 'tuple' and 'list'
req=3 inst=A http=200 | pong
req=4 inst=B http=500 | '<' not supported between instances of 'tuple' and 'list'
req=5 inst=A http=500 | '<' not supported between instances of 'tuple' and 'list'
req=6 inst=B http=200 | pong
req=7 inst=A http=500 | '<' not supported between instances of 'tuple' and 'list'
req=8 inst=B http=500 | '<' not supported between instances of 'tuple' and 'list'
req=9 inst=A http=500 | '<' not supported between instances of 'tuple' and 'list'
req=10 inst=B http=500 | '<' not supported between instances of 'tuple' and 'list'
req=11 inst=A http=200 | pong
req=12 inst=B http=500 | '<' not supported between instances of 'tuple' and 'list'
req=13 inst=A http=200 | pong
req=14 inst=B http=500 | '<' not supported between instances of 'tuple' and 'list'
req=15 inst=A http=500 | '<' not supported between instances of 'tuple' and 'list'
req=16 inst=B http=500 | '<' not supported between instances of 'tuple' and 'list'
12:06:11 end A

Streaming prioritized chat on /v1/chat/completions, 6 requests, alternating A, B

  1. Command as run
PORTS=(42184 42403); NAMES=(A B)
for i in $(seq 1 6); do
  idx=$(( (i - 1) % 2 + 1 )); port=${PORTS[$idx]}; name=${NAMES[$idx]}
  out=$(curl -s -N -m 60 -w '\n%{http_code}' -X POST "http://127.0.0.1:${port}/v1/chat/completions" \
    -H "Authorization: Bearer $LITELLM_MASTER_KEY" -H "Content-Type: application/json" \
    -d '{"model":"qa-chat","messages":[{"role":"user","content":"Reply with the single word pong"}],"priority":1,"stream":true}')
  code=${out##*$'\n'}; body=${out%$'\n'*}
  if [ "$code" = 200 ]; then
    text=$(printf '%s\n' "$body" | sed -n 's/^data: //p' | grep -v '^\[DONE\]' | jq -rj '.choices[0].delta.content // empty' 2>/dev/null)
    echo "req=$i inst=$name http=$code | stream text: ${text}"
  else
    msg=$(printf '%s' "$body" | jq -r '.error.message // .detail // .' 2>/dev/null || printf '%s' "$body")
    echo "req=$i inst=$name http=$code | $msg"
  fi
  sleep 1
done
  1. Observed output (1 of 6 streamed pong, 5 of 6 HTTP 500 with the TypeError as a plain JSON body before any SSE frame)
12:06:31 start A2
req=1 inst=A http=200 | stream text: pong
req=2 inst=B http=500 | '<' not supported between instances of 'tuple' and 'list'
req=3 inst=A http=500 | '<' not supported between instances of 'tuple' and 'list'
req=4 inst=B http=500 | '<' not supported between instances of 'tuple' and 'list'
req=5 inst=A http=500 | '<' not supported between instances of 'tuple' and 'list'
req=6 inst=B http=500 | '<' not supported between instances of 'tuple' and 'list'
12:06:38 end A2

Steady prioritized text completion on /v1/completions, 16 requests one per second, alternating A, B

  1. Command as run
PORTS=(42184 42403); NAMES=(A B)
redis-cli -p 48170 del scheduler:queue:qa-chat
for i in $(seq 1 16); do
  idx=$(( (i - 1) % 2 + 1 )); port=${PORTS[$idx]}; name=${NAMES[$idx]}
  out=$(curl -s -m 60 -w '\n%{http_code}' -X POST "http://127.0.0.1:${port}/v1/completions" \
    -H "Authorization: Bearer $LITELLM_MASTER_KEY" -H "Content-Type: application/json" \
    -d '{"model":"qa-chat","prompt":"Reply with the single word pong","priority":1}')
  code=${out##*$'\n'}; body=${out%$'\n'*}
  msg=$(printf '%s' "$body" | jq -r 'if .choices then .choices[0].text else (.error.message // .detail // .) end' 2>/dev/null || printf '%s' "$body")
  echo "req=$i inst=$name http=$code | $msg"
  sleep 1
done
  1. Observed output (5 of 16 HTTP 200 with choices[0].text = pong, 11 of 16 HTTP 500 with the TypeError)
12:06:49 start B
0
req=1 inst=A http=200 | pong
req=2 inst=B http=500 | '<' not supported between instances of 'tuple' and 'list'
req=3 inst=A http=200 | pong
req=4 inst=B http=500 | '<' not supported between instances of 'tuple' and 'list'
req=5 inst=A http=500 | '<' not supported between instances of 'tuple' and 'list'
req=6 inst=B http=500 | '<' not supported between instances of 'tuple' and 'list'
req=7 inst=A http=500 | '<' not supported between instances of 'tuple' and 'list'
req=8 inst=B http=500 | '<' not supported between instances of 'tuple' and 'list'
req=9 inst=A http=500 | '<' not supported between instances of 'tuple' and 'list'
req=10 inst=B http=500 | '<' not supported between instances of 'tuple' and 'list'
req=11 inst=A http=200 | pong
req=12 inst=B http=500 | '<' not supported between instances of 'tuple' and 'list'
req=13 inst=A http=200 | pong
req=14 inst=B http=500 | '<' not supported between instances of 'tuple' and 'list'
req=15 inst=A http=500 | '<' not supported between instances of 'tuple' and 'list'
req=16 inst=B http=200 | pong
12:07:09 end B

Cooldown: a priority 1 call, then a priority 2 probe on the other instance

  1. Command as run
PORTS=(42184 42403); NAMES=(A B)
redis-cli -p 48170 del scheduler:queue:qa-cooldown
for round in $(seq 1 5); do
  idx=$(( (round - 1) % 2 + 1 )); port=${PORTS[$idx]}; name=${NAMES[$idx]}
  out=$(curl -s -m 90 -w '\n%{http_code} %{time_total}s' -X POST "http://127.0.0.1:${port}/v1/chat/completions" \
    -H "Authorization: Bearer $LITELLM_MASTER_KEY" -H "Content-Type: application/json" \
    -d '{"model":"qa-cooldown","messages":[{"role":"user","content":"ping"}],"priority":1}')
  tail=${out##*$'\n'}; body=${out%$'\n'*}
  msg=$(printf '%s' "$body" | jq -r '.error.message // .detail // .' 2>/dev/null | head -c 300)
  echo "round=$round step=call(priority=1) inst=$name http/time=$tail | $msg"
  pidx=$(( round % 2 + 1 )); pport=${PORTS[$pidx]}; pname=${NAMES[$pidx]}
  pout=$(curl -s --max-time 90 -w '\n%{http_code} %{time_total}s' -X POST "http://127.0.0.1:${pport}/v1/chat/completions" \
    -H "Authorization: Bearer $LITELLM_MASTER_KEY" -H "Content-Type: application/json" \
    -d '{"model":"qa-cooldown","messages":[{"role":"user","content":"ping"}],"priority":2}')
  ptail=${pout##*$'\n'}; pbody=${pout%$'\n'*}
  pmsg=$(printf '%s' "$pbody" | jq -r '.error.message // .detail // .' 2>/dev/null | head -c 300)
  echo "round=$round step=probe(priority=2) inst=$pname http/time=$ptail | $pmsg"
  pcode=${ptail%% *}; psecs=${ptail#* }; psecs=${psecs%s}
  quick=$(python3 -P - "$psecs" <<'PY'
import sys; print("yes" if float(sys.argv[1]) < 5 else "no")
PY
)
  if [ "$pcode" != 401 ] || [ "$quick" != yes ]; then echo "stop: probe was not a quick 401"; break; fi
  sleep 1
done
  1. Observed output (the priority 1 call got OpenAI's 401 in 0.49 s; the probe on the other instance died in 0.057 s with HTTP 500 and the same TypeError, so it never polled)
12:07:27 start C
0
round=1 step=call(priority=1) inst=A http/time=401 0.490204s | litellm.AuthenticationError: AuthenticationError: OpenAIException - Incorrect API key provided: sk-qa-in******************down. You can find your API key at https://platform.openai.com/account/api-keys.

LiteLLM: model group 'qa-cooldown' failed with the error above. No fallback was attempted.
round=1 step=probe(priority=2) inst=B http/time=500 0.057401s | '<' not supported between instances of 'tuple' and 'list'
stop: probe was not a quick 401
12:07:28 end C

8 concurrent prioritized chat requests fired at once, 4 per instance

  1. Command as run
redis-cli -p 48170 del scheduler:queue:qa-chat
for i in $(seq 1 8); do
  if [ $((i % 2)) -eq 1 ]; then inst=A; port=42184; else inst=B; port=42403; fi
  ( resp=$(curl -s -m 90 -w '\n%{http_code} %{time_total}s' -X POST "http://127.0.0.1:${port}/v1/chat/completions" \
      -H "Authorization: Bearer $LITELLM_MASTER_KEY" -H "Content-Type: application/json" \
      -d '{"model":"qa-chat","messages":[{"role":"user","content":"Reply with the single word pong"}],"priority":1}')
    meta=$(printf '%s' "$resp" | tail -n1)
    body=$(printf '%s' "$resp" | sed '$d')
    echo "[$i] $inst:$port HTTP $meta :: $(printf '%s' "$body" | jq -r '.choices[0].message.content // .error.message // .' 2>/dev/null | head -c 300)" ) &
done
wait
  1. Observed output (1 of 8 HTTP 200, 7 of 8 HTTP 500 with the TypeError within 0.34 to 0.37 s)
12:19:12 start D
0
[2] B:42403 HTTP 500 0.336420s :: '<' not supported between instances of 'tuple' and 'list'
[4] B:42403 HTTP 500 0.341522s :: '<' not supported between instances of 'tuple' and 'list'
[5] A:42184 HTTP 500 0.359540s :: '<' not supported between instances of 'tuple' and 'list'
[8] B:42403 HTTP 500 0.364389s :: '<' not supported between instances of 'tuple' and 'list'
[7] A:42184 HTTP 500 0.369858s :: '<' not supported between instances of 'tuple' and 'list'
[6] B:42403 HTTP 500 0.372876s :: '<' not supported between instances of 'tuple' and 'list'
[1] A:42184 HTTP 500 0.372963s :: '<' not supported between instances of 'tuple' and 'list'
[3] A:42184 HTTP 200 1.336413s :: pong
12:19:13 end D

Cooldown: a priority 2 request behind a stale head entry

A worker killed mid-wait leaves its entry at the head of the queue. The seed below writes such an entry the way the scheduler itself writes the key (EX 5), after 6 s of idle so no worker holds a fresh in-memory copy, then sends a prioritized request behind it while qa-cooldown is still in cooldown from the case above

  1. Command as run
for r in 1 2; do
  if [ $r -eq 1 ]; then inst=A; port=42184; else inst=B; port=42403; fi
  sleep 6
  redis-cli -p 48170 set scheduler:queue:qa-cooldown '[[0, "entry-left-by-a-worker-killed-mid-wait"]]' EX 5
  resp=$(curl -s --max-time 90 -w '\n%{http_code} %{time_total}s' -X POST "http://127.0.0.1:${port}/v1/chat/completions" \
    -H "Authorization: Bearer $LITELLM_MASTER_KEY" -H "Content-Type: application/json" \
    -d '{"model":"qa-cooldown","messages":[{"role":"user","content":"ping"}],"priority":2}')
  meta=$(printf '%s' "$resp" | tail -n1)
  body=$(printf '%s' "$resp" | sed '$d')
  echo "[round $r priority=2 behind a stale head entry] $inst:$port HTTP $meta :: $(printf '%s' "$body" | jq -r '.error.message // .choices[0].message.content // .' 2>/dev/null | head -c 300)"
  sleep 1
done
  1. Observed output (both requests die at once with HTTP 500 and the TypeError, since the merge base cannot even enqueue behind a Redis-decoded entry)
OK
[round 1 priority=2 behind a stale head entry] A:42184 HTTP 500 0.069969s :: '<' not supported between instances of 'tuple' and 'list'
OK
[round 2 priority=2 behind a stale head entry] B:42403 HTTP 500 0.112138s :: '<' not supported between instances of 'tuple' and 'list'

After (c835b73)

Instance A on 127.0.0.1:32015, instance B on 127.0.0.1:30458, Redis on 47831. Same headers on every curl

Steady prioritized chat on /v1/chat/completions, 16 requests one per second, alternating A, B

  1. Command as run
redis-cli -p 47831 del scheduler:queue:qa-chat
for i in $(seq 1 16); do
  if [ $((i % 2)) -eq 1 ]; then inst=A; port=32015; else inst=B; port=30458; fi
  resp=$(curl -s -w '\n%{http_code} %{time_total}s' -X POST "http://127.0.0.1:${port}/v1/chat/completions" \
    -H "Authorization: Bearer $LITELLM_MASTER_KEY" -H "Content-Type: application/json" \
    -d '{"model":"qa-chat","messages":[{"role":"user","content":"Reply with the single word pong"}],"priority":1}')
  meta=$(printf '%s' "$resp" | tail -n1)
  body=$(printf '%s' "$resp" | sed '$d')
  echo "[$i] $inst:$port HTTP $meta :: $(printf '%s' "$body" | jq -r '.choices[0].message.content // .error.message // .' 2>/dev/null | head -c 300)"
  sleep 1
done
  1. Observed output (16 of 16 HTTP 200 with pong)
0
[1] A:32015 HTTP 200 1.566474s :: pong
[2] B:30458 HTTP 200 1.057616s :: pong
[3] A:32015 HTTP 200 1.112950s :: pong
[4] B:30458 HTTP 200 0.779169s :: pong
[5] A:32015 HTTP 200 0.545611s :: pong
[6] B:30458 HTTP 200 0.728656s :: pong
[7] A:32015 HTTP 200 0.768760s :: pong
[8] B:30458 HTTP 200 0.849122s :: pong
[9] A:32015 HTTP 200 0.900510s :: pong
[10] B:30458 HTTP 200 0.551307s :: pong
[11] A:32015 HTTP 200 0.745331s :: pong
[12] B:30458 HTTP 200 0.734637s :: pong
[13] A:32015 HTTP 200 0.799105s :: pong
[14] B:30458 HTTP 200 0.919262s :: pong
[15] A:32015 HTTP 200 0.679482s :: pong
[16] B:30458 HTTP 200 0.699549s :: pong

Streaming prioritized chat on /v1/chat/completions, 6 requests, alternating A, B

  1. Command as run
for i in $(seq 1 6); do
  if [ $((i % 2)) -eq 1 ]; then inst=A; port=32015; else inst=B; port=30458; fi
  resp=$(curl -s -N -w '\n%{http_code} %{time_total}s' -X POST "http://127.0.0.1:${port}/v1/chat/completions" \
    -H "Authorization: Bearer $LITELLM_MASTER_KEY" -H "Content-Type: application/json" \
    -d '{"model":"qa-chat","messages":[{"role":"user","content":"Reply with the single word pong"}],"priority":1,"stream":true}')
  meta=$(printf '%s' "$resp" | tail -n1)
  body=$(printf '%s' "$resp" | sed '$d')
  echo "[$i] $inst:$port HTTP $meta :: $(printf '%s\n' "$body" | python3 "$LEG/extract.py" stream | head -c 300)"
  sleep 1
done
  1. Observed output (6 of 6 streamed pong with HTTP 200)
[1] A:32015 HTTP 200 0.871536s :: pong
[2] B:30458 HTTP 200 0.616743s :: pong
[3] A:32015 HTTP 200 0.789527s :: pong
[4] B:30458 HTTP 200 0.777462s :: pong
[5] A:32015 HTTP 200 0.621689s :: pong
[6] B:30458 HTTP 200 0.555312s :: pong

Steady prioritized text completion on /v1/completions, 16 requests one per second, alternating A, B

  1. Command as run
redis-cli -p 47831 del scheduler:queue:qa-chat
for i in $(seq 1 16); do
  if [ $((i % 2)) -eq 1 ]; then inst=A; port=32015; else inst=B; port=30458; fi
  resp=$(curl -s -w '\n%{http_code} %{time_total}s' -X POST "http://127.0.0.1:${port}/v1/completions" \
    -H "Authorization: Bearer $LITELLM_MASTER_KEY" -H "Content-Type: application/json" \
    -d '{"model":"qa-chat","prompt":"Reply with the single word pong","priority":1}')
  meta=$(printf '%s' "$resp" | tail -n1)
  body=$(printf '%s' "$resp" | sed '$d')
  echo "[$i] $inst:$port HTTP $meta :: $(printf '%s' "$body" | jq -r '.choices[0].text // .error.message // .' 2>/dev/null | head -c 300)"
  sleep 1
done
  1. Observed output (16 of 16 HTTP 200 with choices[0].text = pong)
1
[1] A:32015 HTTP 200 0.555667s :: pong
[2] B:30458 HTTP 200 0.542174s :: pong
[3] A:32015 HTTP 200 0.763542s :: pong
[4] B:30458 HTTP 200 0.658151s :: pong
[5] A:32015 HTTP 200 0.531526s :: pong
[6] B:30458 HTTP 200 0.459138s :: pong
[7] A:32015 HTTP 200 0.545551s :: pong
[8] B:30458 HTTP 200 0.658272s :: pong
[9] A:32015 HTTP 200 0.561909s :: pong
[10] B:30458 HTTP 200 0.640160s :: pong
[11] A:32015 HTTP 200 0.597972s :: pong
[12] B:30458 HTTP 200 0.499381s :: pong
[13] A:32015 HTTP 200 0.595305s :: pong
[14] B:30458 HTTP 200 0.581183s :: pong
[15] A:32015 HTTP 200 0.537415s :: pong
[16] B:30458 HTTP 200 0.543869s :: pong

Cooldown: a priority 1 call, then a priority 2 probe on the other instance

  1. Command as run
redis-cli -p 47831 del scheduler:queue:qa-cooldown
for r in $(seq 1 5); do
  if [ $((r % 2)) -eq 1 ]; then inst1=A; port1=32015; inst2=B; port2=30458; else inst1=B; port1=30458; inst2=A; port2=32015; fi
  resp=$(curl -s -w '\n%{http_code} %{time_total}s' -X POST "http://127.0.0.1:${port1}/v1/chat/completions" \
    -H "Authorization: Bearer $LITELLM_MASTER_KEY" -H "Content-Type: application/json" \
    -d '{"model":"qa-cooldown","messages":[{"role":"user","content":"ping"}],"priority":1}')
  meta=$(printf '%s' "$resp" | tail -n1)
  body=$(printf '%s' "$resp" | sed '$d')
  echo "[round $r request priority=1] $inst1:$port1 HTTP $meta :: $(printf '%s' "$body" | jq -r '.error.message // .choices[0].message.content // .' 2>/dev/null | head -c 300)"
  resp=$(curl -s --max-time 90 -w '\n%{http_code} %{time_total}s' -X POST "http://127.0.0.1:${port2}/v1/chat/completions" \
    -H "Authorization: Bearer $LITELLM_MASTER_KEY" -H "Content-Type: application/json" \
    -d '{"model":"qa-cooldown","messages":[{"role":"user","content":"ping"}],"priority":2}')
  meta=$(printf '%s' "$resp" | tail -n1)
  body=$(printf '%s' "$resp" | sed '$d')
  code=${meta%% *}
  echo "[round $r probe priority=2] $inst2:$port2 HTTP $meta :: $(printf '%s' "$body" | jq -r '.error.message // .choices[0].message.content // .' 2>/dev/null | head -c 300)"
  if [ "$code" != "401" ]; then echo "probe is no longer a quick 401 (code=$code); stopping after round $r"; break; fi
  sleep 1
done
  1. Observed output (round 1 is two quick 401s from OpenAI while the deployment is still healthy; round 2 answers HTTP 429 All deployments for selected model are in cooldown in 0.02 s and 0.03 s, no 30 s hang and no poll timeout)
0
[round 1 request priority=1] A:32015 HTTP 401 0.298251s :: litellm.AuthenticationError: AuthenticationError: OpenAIException - Incorrect API key provided: sk-qa-in******************down. You can find your API key at https://platform.openai.com/account/api-keys.

LiteLLM: model group 'qa-cooldown' failed with the error above. No fallback was attempted.
[round 1 probe priority=2] B:30458 HTTP 401 0.165949s :: litellm.AuthenticationError: AuthenticationError: OpenAIException - Incorrect API key provided: sk-qa-in******************down. You can find your API key at https://platform.openai.com/account/api-keys.

LiteLLM: model group 'qa-cooldown' failed with the error above. No fallback was attempted.
[round 2 request priority=1] B:30458 HTTP 429 0.024586s :: No deployments available for selected model, Try again in 180 seconds. All deployments for selected model are in cooldown. Passed model=qa-cooldown. pre-call-checks=False, cooldown_list=['c5d4d02a3af3481f3df2423e2c7e0842332476b07ab6d968570e51612453ee38']
[round 2 probe priority=2] A:32015 HTTP 429 0.026642s :: No deployments available for selected model, Try again in 180 seconds. All deployments for selected model are in cooldown. Passed model=qa-cooldown. pre-call-checks=False, cooldown_list=['c5d4d02a3af3481f3df2423e2c7e0842332476b07ab6d968570e51612453ee38']
probe is no longer a quick 401 (code=429); stopping after round 2

Cooldown: a priority 2 request behind a stale head entry

Same seed as the Before case: an entry a killed worker would leave at the head, written the way the scheduler writes the key (EX 5), after 6 s of idle, with qa-cooldown still in cooldown

  1. Command as run
for r in 1 2; do
  if [ $r -eq 1 ]; then inst=A; port=32015; else inst=B; port=30458; fi
  sleep 6
  redis-cli -p 47831 set scheduler:queue:qa-cooldown '[[0, "entry-left-by-a-worker-killed-mid-wait"]]' EX 5
  resp=$(curl -s --max-time 90 -w '\n%{http_code} %{time_total}s' -X POST "http://127.0.0.1:${port}/v1/chat/completions" \
    -H "Authorization: Bearer $LITELLM_MASTER_KEY" -H "Content-Type: application/json" \
    -d '{"model":"qa-cooldown","messages":[{"role":"user","content":"ping"}],"priority":2}')
  meta=$(printf '%s' "$resp" | tail -n1)
  body=$(printf '%s' "$resp" | sed '$d')
  echo "[round $r priority=2 behind a stale head entry] $inst:$port HTTP $meta :: $(printf '%s' "$body" | jq -r '.error.message // .choices[0].message.content // .' 2>/dev/null | head -c 300)"
  sleep 1
done
  1. Observed output (both requests enqueue behind the stale entry, wait while it lives, and once the key expires each one is put back on its own and admitted: HTTP 429 All deployments for selected model are in cooldown after 5.07 s and 5.06 s, no 500 and no poll timeout)
OK
[round 1 priority=2 behind a stale head entry] A:32015 HTTP 429 5.072729s :: No deployments available for selected model, Try again in 180 seconds. All deployments for selected model are in cooldown. Passed model=qa-cooldown. pre-call-checks=False, cooldown_list=['c5d4d02a3af3481f3df2423e2c7e0842332476b07ab6d968570e51612453ee38']
OK
[round 2 priority=2 behind a stale head entry] B:30458 HTTP 429 5.063311s :: No deployments available for selected model, Try again in 180 seconds. All deployments for selected model are in cooldown. Passed model=qa-cooldown. pre-call-checks=False, cooldown_list=['c5d4d02a3af3481f3df2423e2c7e0842332476b07ab6d968570e51612453ee38']

8 concurrent prioritized chat requests fired at once, 4 per instance

  1. Command as run
redis-cli -p 47831 del scheduler:queue:qa-chat
for i in $(seq 1 8); do
  if [ $((i % 2)) -eq 1 ]; then inst=A; port=32015; else inst=B; port=30458; fi
  ( resp=$(curl -s -w '\n%{http_code} %{time_total}s' -X POST "http://127.0.0.1:${port}/v1/chat/completions" \
      -H "Authorization: Bearer $LITELLM_MASTER_KEY" -H "Content-Type: application/json" \
      -d '{"model":"qa-chat","messages":[{"role":"user","content":"Reply with the single word pong"}],"priority":1}')
    meta=$(printf '%s' "$resp" | tail -n1)
    body=$(printf '%s' "$resp" | sed '$d')
    echo "[$i] $inst:$port HTTP $meta :: $(printf '%s' "$body" | jq -r '.choices[0].message.content // .error.message // .' 2>/dev/null | head -c 300)" ) &
done
wait
  1. Observed output (8 of 8 HTTP 200 with pong, each in 0.60 to 0.93 s)
0
[2] B:30458 HTTP 200 0.596154s :: pong
[6] B:30458 HTTP 200 0.622177s :: pong
[5] A:32015 HTTP 200 0.622725s :: pong
[8] B:30458 HTTP 200 0.723247s :: pong
[4] B:30458 HTTP 200 0.762196s :: pong
[3] A:32015 HTTP 200 0.911164s :: pong
[1] A:32015 HTTP 200 0.926793s :: pong
[7] A:32015 HTTP 200 0.911677s :: pong

Observations from the two legs, each with whether this PR causes it, makes it worse, or leaves it alone:

  • Before: cooldown probe died on the TypeError, never polled; PR fixes
  • Stale head entry: 500 before, 429 after; PR fixes
  • Stale head entry still orders waiters until its key expires; PR leaves alone
  • Concurrent burst went 7/8 then 5/8 500s to 8/8 200; PR fixes
  • Redis queue key expires 5 s after its last save; PR leaves alone
  • Cooldown engaged after two 401s across both instances; PR leaves alone
  • 429 body exposes the deployment hash in cooldown_list; PR leaves alone
  • 401 body echoes OpenAI's masked bad key; PR leaves alone

Last updated: c835b73. QA and /live-pr-risk ran on c835b73, the last commit with a product diff

/live-pr-risk

Rig: two proxies per side, each --num_workers 2, sharing one Redis and one forwarding recorder in front of https://api.openai.com that logs every upstream request with its status, requests alternating between the two instances; a base side at 31b90eb, a head side at c835b73, and a mixed side with instance A at 31b90eb and instance B at c835b73 on one Redis, the shape of a rolling deploy. The same cells ran on every side in the same order: /queue/chat/completions non-stream and stream, /v1/chat/completions with and without priority, streaming with priority, /v1/completions with and without priority, priority as a string and as a list, 8 concurrent prioritized chat requests, three calls that trip qa-cooldown into cooldown, a prioritized call the client cancels after 2 s (non-stream and stream), then the cooled-down model through the chat, queue and text routes, a healthy model with and without priority, fastest_response over the healthy and the cooled-down model with priority, and the cooled-down model once more. Config: openai/gpt-5.4-nano, timeout: 30, num_retries: 0, allowed_fails: 1, cooldown_time: 180, default polling_interval, no database

Breaking

None observed. Every cell that fails on the base and passes on the head fails with one of the two symptoms this PR targets: '<' not supported between instances of 'tuple' and 'list' (500 on /v1/chat/completions and /v1/completions, 400 auth_error on /queue/chat/completions streaming, 7 of the 8 concurrent requests, see the Before proof above) or a leaked entry blocking the cooldown queue (the cancelled request still waiting when the client gives up at 2 s, the next request on that instance answering Incorrectly setup. Queue is invalid. Queue=[] after about 7 s). On the head those cells answer 200, or an immediate 429 All deployments for selected model are in cooldown once the model is cooled down. Every cell that passes on the base passes identically on the head: same status, same x-litellm-* header set with ids, spend and cost normalized, same body shape, and the same request body key set recorded in front of OpenAI

Backward incompatible

Redis traffic per prioritized request changed. The head writes the queue twice per request (the add, then the removal that leaves []) and no longer reads it while a healthy deployment exists; the base wrote once and read on every poll, and never removed anything. In the MONITOR capture below the base did 16 GET and 11 SET on scheduler:queue:* over its run and the head 8 GET and 57 SET while serving every prioritized request the base failed. This is the fix's mechanism (the removal write is what clears the queue), landed in 629b9ef, and it stays listed under Low caveats; c835b73 adds one write only when a poll during cooldown finds its own entry gone

Scheduler.get_queue() now returns validated (priority, request_id) tuples instead of whatever the cache held (lists after a Redis round trip), and Scheduler.poll() returns True without touching the queue while any deployment is healthy. Both are called only from litellm/router.py (_wait_for_scheduler_turn through Scheduler.wait_for_turn) and from this PR's tests; no proxy route, dashboard page or litellm/proxy/client command reads scheduler state. Scheduler.poll() also takes the FlowItem instead of id and model_name, and during cooldown it puts a request whose entry vanished back at its priority instead of raising Incorrectly setup. Queue is invalid, so a caller that relied on that exception as a signal no longer gets it; wait_for_turn is its only caller. Landed in 629b9ef, e6cdc06, 1313444 and c835b73

No HTTP contract change was observed: statuses, error types and the x-litellm-* header set match the base on every cell that passes on both sides

Regression risk

Router.abatch_completion_one_model_multiple_requests with priority is reachable from the SDK only: it fans out N acompletion calls that now each enqueue and remove. The per-call path is the one verified live, the fan-out is not, and exercising it takes an SDK script against Redis

A polling_interval other than the default, and a timeout changed at runtime through /config/update (needs Postgres): the wait loop reads both, and only the defaults ran here

Redis becoming unreachable while a request waits, and a worker SIGTERM while a request waits (the shielded removal runs during cancellation): neither was induced

Pre-existing, identical on the base and the head, and not made worse by this PR: with router_settings.default_priority set, every /v1/chat/completions request fails 500 Router.schedule_acompletion() missing 1 required positional argument: 'priority' (400 on /queue/chat/completions), explicit integer priority included, because schedule_acompletion re-enters acompletion, which re-applies the default; /v1/completions is unaffected. Tracked internally for its own fix. A non-integer priority ("1", [1]) skips the scheduler and is forwarded to OpenAI, which answers 400 Unknown parameter: 'priority'; the recorder shows the priority key in the upstream body 2 times on the base and 2 times on the head

Dependency graph

  • Router.acompletion with an integer priority -> schedule_acompletion -> _wait_for_scheduler_turn -> Scheduler.wait_for_turn: verified live (chat, chat stream, 8 concurrent, cooldown, client cancel) and tested in tests/test_litellm/test_router.py
  • /queue/chat/completions -> router.schedule_acompletion: verified live, non-stream, stream and during cooldown
  • /v1/completions with priority -> atext_completion -> _schedule_factory -> _wait_for_scheduler_turn: verified live
  • fastest_response with priority -> abatch_completion_fastest_response -> acompletion per model: verified live, the healthy model answers while the other is cooled down
  • Scheduler.add_request, poll, remove_request, get_queue, save_queue -> DualCache -> Redis key scheduler:queue:<model> with a 5 s TTL: verified live through MONITOR (head: every add is followed by a [] removal; base: entries are never removed) and tested in tests/unit/test_scheduler.py with a JSON round-trip stub. The vanished-entry path in poll is verified live by the stale head entry case in the proof above (the seeded key expires while the request waits) and tested with a cache the test clears mid-wait
  • Mixed-version replicas on one Redis, A at the base and B at the head: verified live. The head replica answered every cell exactly as in the head-only run, and the base replica failed the way it fails beside another base replica (all 4 of its concurrent requests with the tuple/list error, and its next prioritized request on the cooled-down model Incorrectly setup. Queue is invalid. Queue=[] after 7.5 s). Neither replica made the other worse
  • Router.abatch_completion_one_model_multiple_requests with priority: untested, SDK only
  • Router.timeout and polling_interval as read by the wait loop: defaults only
  • Legacy suites under tests/llm_translation, tests/local_testing and tests/integration: none drives the scheduler or priority, so nothing there asserts the old behavior
  • Dashboard (ui/litellm-dashboard/src) and litellm/proxy/client: no consumer of scheduler state

Not verified

The dependents marked untested or defaults only above: abatch_completion_one_model_multiple_requests with priority, a non-default polling_interval, a runtime timeout change through /config/update, a Redis outage mid-wait, a SIGTERM mid-wait

A merged tree: main had moved 15 commits past the merge base when the rig ran (31b90ebb47...a9b9700790) and none of them touches litellm/scheduler.py, litellm/router.py, litellm/caching/, litellm/router_utils/ or the queue route in litellm/proxy/proxy_server.py, so the rig ran on the PR tip rather than on a merged tree

Service-logger metrics around the scheduler were not read; Redis operations were counted instead. The rig ran without Postgres

Redis MONITOR, scheduler:queue operations per side

Base (31b90eb), instances A and B:

1791402296.729 "GET" "scheduler:queue:qa-chat"
1791402296.730 "SET" "scheduler:queue:qa-chat" "[[1, "42ba4c52-616c-4fb7-b920-442c3991203c"]]" "EX" "5"
1791402298.967 "GET" "scheduler:queue:qa-chat"
1791402301.220 "GET" "scheduler:queue:qa-chat"
1791402301.400 "SET" "scheduler:queue:qa-chat" "[[1, "42ba4c52-616c-4fb7-b920-442c3991203c"], [2, "aa0e2a4c...
1791402305.849 "GET" "scheduler:queue:qa-chat"
1791402305.916 "SET" "scheduler:queue:qa-chat" "[[1, "42ba4c52-616c-4fb7-b920-442c3991203c"], [1, "6608aa3b...
1791402305.985 "GET" "scheduler:queue:qa-chat"
1791402306.107 "GET" "scheduler:queue:qa-chat"
1791402307.390 "GET" "scheduler:queue:qa-cooldown"
1791402307.391 "SET" "scheduler:queue:qa-cooldown" "[[1, "48fa1630-adae-4680-9ab8-03c5b76a8bfd"]]" "EX" "5"
1791402307.962 "GET" "scheduler:queue:qa-cooldown"
1791402308.081 "SET" "scheduler:queue:qa-cooldown" "[[1, "48fa1630-adae-4680-9ab8-03c5b76a8bfd"], [1, "f9ae...
1791402308.531 "SET" "scheduler:queue:qa-cooldown" "[[1, "48fa1630-adae-4680-9ab8-03c5b76a8bfd"], [1, "f9ae...
1791402310.681 "SET" "scheduler:queue:qa-cooldown" "[[1, "48fa1630-adae-4680-9ab8-03c5b76a8bfd"], [1, "5c89...
1791402310.844 "SET" "scheduler:queue:qa-cooldown" "[[1, "48fa1630-adae-4680-9ab8-03c5b76a8bfd"], [1, "f9ae...
1791402312.413 "GET" "scheduler:queue:qa-cooldown"
1791402312.413 "GET" "scheduler:queue:qa-cooldown"
1791402317.416 "GET" "scheduler:queue:qa-cooldown"
1791402317.416 "GET" "scheduler:queue:qa-cooldown"
1791402317.648 "GET" "scheduler:queue:qa-cooldown"
1791402317.649 "SET" "scheduler:queue:qa-cooldown" "[[2, "b89428a8-9fa1-4b71-9db0-b733915f1779"]]" "EX" "5"
1791402317.778 "GET" "scheduler:queue:qa-chat"
1791402317.778 "SET" "scheduler:queue:qa-chat" "[[1, "c81cc3cf-906a-4a47-b1e2-ff60a8b4af54"]]" "EX" "5"
1791402319.676 "SET" "scheduler:queue:qa-chat" "[[1, "c81cc3cf-906a-4a47-b1e2-ff60a8b4af54"], [1, "e65b70f8...
1791402319.676 "GET" "scheduler:queue:qa-cooldown"
1791402320.406 "GET" "scheduler:queue:qa-cooldown"

Head (e6cdc06), instances A and B:

1791402403.926 "GET" "scheduler:queue:qa-chat"
1791402403.927 "SET" "scheduler:queue:qa-chat" "[[1, "13675573-9048-4c5a-a1b1-0095dd8bc25e"]]" "EX" "5"
1791402403.927 "SET" "scheduler:queue:qa-chat" "[]" "EX" "5"
1791402405.236 "GET" "scheduler:queue:qa-chat"
1791402405.237 "SET" "scheduler:queue:qa-chat" "[[1, "a4586624-165a-4e8d-87d3-26a247891642"]]" "EX" "5"
1791402405.239 "SET" "scheduler:queue:qa-chat" "[]" "EX" "5"
1791402407.181 "SET" "scheduler:queue:qa-chat" "[[2, "c1063c7f-9866-4535-9802-fddb362f72d4"]]" "EX" "5"
1791402407.181 "SET" "scheduler:queue:qa-chat" "[]" "EX" "5"
1791402407.865 "SET" "scheduler:queue:qa-chat" "[[2, "c0ee8225-dcc4-4671-9e4a-68dffae5ba10"]]" "EX" "5"
1791402407.866 "SET" "scheduler:queue:qa-chat" "[]" "EX" "5"
1791402408.601 "SET" "scheduler:queue:qa-chat" "[[2, "fca78526-31e1-4e06-96e4-f4d5ed166560"]]" "EX" "5"
1791402408.601 "SET" "scheduler:queue:qa-chat" "[]" "EX" "5"
1791402411.047 "GET" "scheduler:queue:qa-chat"
1791402411.047 "GET" "scheduler:queue:qa-chat"
1791402411.048 "SET" "scheduler:queue:qa-chat" "[[1, "8d5c9b94-5e61-488f-bf5c-ac33dcf88afb"]]" "EX" "5"
1791402411.048 "SET" "scheduler:queue:qa-chat" "[]" "EX" "5"
1791402411.049 "SET" "scheduler:queue:qa-chat" "[[1, "c58a6afc-3ab7-4759-965a-fe7c320a0c6e"]]" "EX" "5"
1791402411.049 "GET" "scheduler:queue:qa-chat"
1791402411.049 "SET" "scheduler:queue:qa-chat" "[]" "EX" "5"
1791402411.050 "SET" "scheduler:queue:qa-chat" "[[1, "b5a27a6d-d572-42d8-8ae9-b6d782494722"], [1, "c58a6afc...
1791402411.050 "SET" "scheduler:queue:qa-chat" "[[1, "c58a6afc-3ab7-4759-965a-fe7c320a0c6e"]]" "EX" "5"
1791402411.055 "GET" "scheduler:queue:qa-chat"
1791402411.057 "SET" "scheduler:queue:qa-chat" "[[1, "6737dee8-4d34-458f-a527-f9f3e39d2455"], [1, "c58a6afc...
1791402411.057 "GET" "scheduler:queue:qa-chat"
1791402411.058 "SET" "scheduler:queue:qa-chat" "[[1, "a5af916f-f666-479e-a713-a978fbc5429a"], [1, "c58a6afc...
1791402411.058 "SET" "scheduler:queue:qa-chat" "[[1, "6737dee8-4d34-458f-a527-f9f3e39d2455"], [1, "c58a6afc...
1791402411.058 "SET" "scheduler:queue:qa-chat" "[[1, "380a7aad-1093-4855-b96c-123a362f3c2b"], [1, "c58a6afc...
1791402411.059 "SET" "scheduler:queue:qa-chat" "[[1, "63b98fda-1fd4-421c-ac70-d3e26fca1556"], [1, "c58a6afc...
1791402411.060 "SET" "scheduler:queue:qa-chat" "[[1, "c58a6afc-3ab7-4759-965a-fe7c320a0c6e"]]" "EX" "5"
1791402411.063 "SET" "scheduler:queue:qa-chat" "[[1, "6737dee8-4d34-458f-a527-f9f3e39d2455"], [1, "c58a6afc...
1791402411.063 "SET" "scheduler:queue:qa-chat" "[[1, "6737dee8-4d34-458f-a527-f9f3e39d2455"], [1, "c58a6afc...
1791402411.224 "GET" "scheduler:queue:qa-chat"
1791402411.225 "SET" "scheduler:queue:qa-chat" "[[1, "6737dee8-4d34-458f-a527-f9f3e39d2455"], [1, "c58a6afc...
1791402411.225 "SET" "scheduler:queue:qa-chat" "[[1, "6737dee8-4d34-458f-a527-f9f3e39d2455"], [1, "c58a6afc...
1791402414.539 "GET" "scheduler:queue:qa-cooldown"
1791402414.540 "SET" "scheduler:queue:qa-cooldown" "[[1, "f741507e-697c-4c7c-a0af-491adcf6913c"]]" "EX" "5"
1791402414.541 "SET" "scheduler:queue:qa-cooldown" "[]" "EX" "5"
1791402415.123 "GET" "scheduler:queue:qa-cooldown"
1791402415.124 "SET" "scheduler:queue:qa-cooldown" "[[1, "7f2d26c9-5392-4b4a-a394-106c8c83ddff"]]" "EX" "5"
1791402415.125 "SET" "scheduler:queue:qa-cooldown" "[]" "EX" "5"
1791402415.498 "SET" "scheduler:queue:qa-cooldown" "[[1, "7b4bceac-a46b-4106-b6d2-97e1a4aaed6c"]]" "EX" "5"
1791402415.499 "SET" "scheduler:queue:qa-cooldown" "[]" "EX" "5"
1791402415.741 "SET" "scheduler:queue:qa-cooldown" "[[2, "435ebeab-01ef-4f89-9d4f-815b8e9f72d5"]]" "EX" "5"
1791402415.741 "SET" "scheduler:queue:qa-cooldown" "[]" "EX" "5"
1791402415.742 "SET" "scheduler:queue:qa-cooldown" "[]" "EX" "5"
1791402415.873 "SET" "scheduler:queue:qa-cooldown" "[[2, "be15dfb3-2ed2-4b50-8694-cc5840e8eeb8"]]" "EX" "5"
1791402415.874 "SET" "scheduler:queue:qa-cooldown" "[]" "EX" "5"
1791402415.874 "SET" "scheduler:queue:qa-cooldown" "[]" "EX" "5"
1791402416.072 "SET" "scheduler:queue:qa-cooldown" "[[2, "bec2d51a-af3f-4ea1-a5d2-0470dfd223e0"]]" "EX" "5"
1791402416.074 "SET" "scheduler:queue:qa-cooldown" "[]" "EX" "5"
1791402416.077 "SET" "scheduler:queue:qa-cooldown" "[]" "EX" "5"
1791402416.289 "SET" "scheduler:queue:qa-cooldown" "[[2, "be42d90d-a878-407b-93ac-2af36efd8592"]]" "EX" "5"
1791402416.290 "SET" "scheduler:queue:qa-cooldown" "[]" "EX" "5"
1791402416.290 "SET" "scheduler:queue:qa-cooldown" "[]" "EX" "5"
1791402416.397 "SET" "scheduler:queue:qa-cooldown" "[[2, "22c5db27-53bb-4b9e-ade4-3ce5b2b26220"]]" "EX" "5"
1791402416.397 "SET" "scheduler:queue:qa-cooldown" "[]" "EX" "5"
1791402416.398 "SET" "scheduler:queue:qa-cooldown" "[]" "EX" "5"
1791402416.575 "GET" "scheduler:queue:qa-chat"
1791402416.577 "SET" "scheduler:queue:qa-chat" "[[1, "5586ef44-e033-489d-9de1-cc78e84bfd2d"]]" "EX" "5"
1791402416.578 "SET" "scheduler:queue:qa-chat" "[]" "EX" "5"
1791402418.509 "GET" "scheduler:queue:qa-chat"
1791402418.509 "SET" "scheduler:queue:qa-cooldown" "[[1, "4cf992ce-5652-4ffc-8155-a08cc9128992"]]" "EX" "5"
1791402418.520 "SET" "scheduler:queue:qa-chat" "[[1, "07fe3a13-96bb-453a-83e0-00669c7b87c0"]]" "EX" "5"
1791402418.532 "SET" "scheduler:queue:qa-cooldown" "[]" "EX" "5"
1791402418.533 "SET" "scheduler:queue:qa-chat" "[]" "EX" "5"
1791402418.534 "SET" "scheduler:queue:qa-cooldown" "[]" "EX" "5"
1791402419.400 "GET" "scheduler:queue:qa-cooldown"
1791402419.400 "SET" "scheduler:queue:qa-cooldown" "[[2, "90dedbcc-c206-4121-bb0a-9c05921d17bd"]]" "EX" "5"
1791402419.401 "SET" "scheduler:queue:qa-cooldown" "[]" "EX" "5"

Mixed (A at 31b90eb, B at e6cdc06):

1791402979.677 "GET" "scheduler:queue:qa-chat"
1791402979.679 "SET" "scheduler:queue:qa-chat" "[[1, "017b6c7b-ac4d-43f4-9f3e-3fbcd2f7314c"]]" "EX" "5"
1791402981.008 "GET" "scheduler:queue:qa-chat"
1791402981.010 "SET" "scheduler:queue:qa-chat" "[[1, "017b6c7b-ac4d-43f4-9f3e-3fbcd2f7314c"], [1, "a64b916f...
1791402981.012 "SET" "scheduler:queue:qa-chat" "[[1, "017b6c7b-ac4d-43f4-9f3e-3fbcd2f7314c"]]" "EX" "5"
1791402983.611 "SET" "scheduler:queue:qa-chat" "[[1, "017b6c7b-ac4d-43f4-9f3e-3fbcd2f7314c"], [2, "376a4066...
1791402983.613 "SET" "scheduler:queue:qa-chat" "[[1, "017b6c7b-ac4d-43f4-9f3e-3fbcd2f7314c"]]" "EX" "5"
1791402984.433 "SET" "scheduler:queue:qa-chat" "[[1, "017b6c7b-ac4d-43f4-9f3e-3fbcd2f7314c"], [2, "730a5c0b...
1791402985.462 "SET" "scheduler:queue:qa-chat" "[[1, "017b6c7b-ac4d-43f4-9f3e-3fbcd2f7314c"], [2, "fd94899a...
1791402985.462 "SET" "scheduler:queue:qa-chat" "[[1, "017b6c7b-ac4d-43f4-9f3e-3fbcd2f7314c"]]" "EX" "5"
1791402988.148 "GET" "scheduler:queue:qa-chat"
1791402988.170 "GET" "scheduler:queue:qa-chat"
1791402988.174 "GET" "scheduler:queue:qa-chat"
1791402988.176 "SET" "scheduler:queue:qa-chat" "[[1, "017b6c7b-ac4d-43f4-9f3e-3fbcd2f7314c"], [1, "e49e2823...
1791402988.178 "SET" "scheduler:queue:qa-chat" "[[1, "017b6c7b-ac4d-43f4-9f3e-3fbcd2f7314c"], [1, "b1d7ccd0...
1791402988.182 "SET" "scheduler:queue:qa-chat" "[[1, "017b6c7b-ac4d-43f4-9f3e-3fbcd2f7314c"], [1, "52f03e34...
1791402988.183 "SET" "scheduler:queue:qa-chat" "[[1, "017b6c7b-ac4d-43f4-9f3e-3fbcd2f7314c"], [1, "e49e2823...
1791402988.183 "SET" "scheduler:queue:qa-chat" "[[1, "017b6c7b-ac4d-43f4-9f3e-3fbcd2f7314c"], [1, "52f03e34...
1791402988.184 "SET" "scheduler:queue:qa-chat" "[[1, "017b6c7b-ac4d-43f4-9f3e-3fbcd2f7314c"], [1, "e0ed7e3f...
1791402988.185 "SET" "scheduler:queue:qa-chat" "[[1, "017b6c7b-ac4d-43f4-9f3e-3fbcd2f7314c"]]" "EX" "5"
1791402988.394 "GET" "scheduler:queue:qa-chat"
1791402988.395 "SET" "scheduler:queue:qa-chat" "[[1, "017b6c7b-ac4d-43f4-9f3e-3fbcd2f7314c"], [1, "7af7f8af...
1791402988.395 "SET" "scheduler:queue:qa-chat" "[[1, "017b6c7b-ac4d-43f4-9f3e-3fbcd2f7314c"]]" "EX" "5"
1791402989.897 "GET" "scheduler:queue:qa-cooldown"
1791402989.898 "SET" "scheduler:queue:qa-cooldown" "[[1, "b3f1cc29-b476-4309-bde3-66b290167bc8"]]" "EX" "5"
1791402989.899 "SET" "scheduler:queue:qa-cooldown" "[]" "EX" "5"
1791402990.336 "GET" "scheduler:queue:qa-cooldown"
1791402990.337 "SET" "scheduler:queue:qa-cooldown" "[[1, "ad21d093-e18c-43d5-aef5-82e85c81bb23"]]" "EX" "5"
1791402990.665 "SET" "scheduler:queue:qa-cooldown" "[[1, "503ee3d9-948a-4dc1-86a2-c3fb38cbf7fe"]]" "EX" "5"
1791402990.667 "SET" "scheduler:queue:qa-cooldown" "[]" "EX" "5"
1791402990.964 "SET" "scheduler:queue:qa-cooldown" "[[1, "ad21d093-e18c-43d5-aef5-82e85c81bb23"], [2, "2fd7...
1791402993.104 "SET" "scheduler:queue:qa-cooldown" "[[2, "a3fc2b67-dd0a-47c4-a803-515f078664fe"]]" "EX" "5"
1791402993.106 "SET" "scheduler:queue:qa-cooldown" "[]" "EX" "5"
1791402993.107 "SET" "scheduler:queue:qa-cooldown" "[]" "EX" "5"
1791402993.270 "SET" "scheduler:queue:qa-cooldown" "[[1, "ad21d093-e18c-43d5-aef5-82e85c81bb23"], [2, "2fd7...
1791402995.364 "GET" "scheduler:queue:qa-cooldown"
1791402995.365 "GET" "scheduler:queue:qa-cooldown"
1791403000.374 "GET" "scheduler:queue:qa-cooldown"
1791403000.374 "GET" "scheduler:queue:qa-cooldown"
1791403000.493 "GET" "scheduler:queue:qa-cooldown"
1791403000.493 "SET" "scheduler:queue:qa-cooldown" "[[2, "4c787f35-9c85-4844-9d9b-dd90600d8038"]]" "EX" "5"
1791403000.495 "SET" "scheduler:queue:qa-cooldown" "[]" "EX" "5"
1791403000.495 "SET" "scheduler:queue:qa-cooldown" "[]" "EX" "5"
1791403000.608 "GET" "scheduler:queue:qa-cooldown"
1791403000.608 "SET" "scheduler:queue:qa-cooldown" "[[2, "c01e4a68-9c37-4a8f-916a-26eb63c2cb51"]]" "EX" "5"
1791403000.609 "SET" "scheduler:queue:qa-cooldown" "[]" "EX" "5"
1791403000.710 "GET" "scheduler:queue:qa-chat"
1791403000.711 "SET" "scheduler:queue:qa-chat" "[[1, "5c024109-bef2-462b-8ea0-217de0f12cf3"]]" "EX" "5"
1791403000.711 "SET" "scheduler:queue:qa-chat" "[]" "EX" "5"
1791403002.634 "SET" "scheduler:queue:qa-cooldown" "[[1, "50c360f4-9821-425f-b654-12e81519d3ab"]]" "EX" "5"
1791403002.634 "SET" "scheduler:queue:qa-chat" "[[1, "bd471e7d-a009-43f3-9c8c-b1b41e48edf4"]]" "EX" "5"
1791403002.634 "SET" "scheduler:queue:qa-cooldown" "[]" "EX" "5"
1791403002.635 "SET" "scheduler:queue:qa-chat" "[]" "EX" "5"
1791403002.635 "SET" "scheduler:queue:qa-cooldown" "[]" "EX" "5"
1791403003.366 "SET" "scheduler:queue:qa-cooldown" "[[2, "11f30759-a8a3-44a1-9ebd-59f9dd5f6909"]]" "EX" "5"
1791403003.366 "SET" "scheduler:queue:qa-cooldown" "[]" "EX" "5"

/audit

Merge base 31b90eb and head c835b73, both two-worker proxies with their own Postgres, Redis and scripted upstream on the shared QA box; the head selection ran twice with --seed 1 --order-seed 1. The 16 rig-proxy rows ran in lanes seq2/base-r3, seq6/head-r6a, seq6/head-r6b, the owned-proxy rows in seq5/base-r5, seq5/head-r5a, seq5/head-r5b and P8 in seq7/base-r7, seq7/head-r7a, seq7/head-r7b, one lane after another. A red on the merge base is the bug this PR fixes, shown by the same cell; P3 and J2 are red or green on the merge base by timing (its leaked entries and the list-of-lists reads race), and every head run is green

Row Scenario Node id Merge base Head run 1 Head run 2 Lanes Last ran at Verdict
A1 rig /v1/chat/completions, OpenAI SDK sync, priority: 1, healthy tests/integration/routing/test_priority_scheduler_queue_cleanup.py::test_sdk_chat_with_priority_is_served_once_and_billed pass pass pass base-r3, head-r6a, head-r6b 0be0db2 PASS
A2 rig chat stream, OpenAI SDK sync, stream fully consumed tests/integration/routing/test_priority_scheduler_queue_cleanup.py::test_sdk_chat_stream_with_priority_is_served_once_and_billed pass pass pass base-r3, head-r6a, head-r6b 0be0db2 PASS
A3 rig chat non-stream, OpenAI SDK async tests/integration/routing/test_priority_scheduler_queue_cleanup.py::test_async_sdk_chat_with_priority_is_served_once pass pass pass base-r3, head-r6a, head-r6b 0be0db2 PASS
A4 rig chat raw httpx, duplicate priority JSON key ("not-an-int" then 1, last wins) tests/integration/routing/test_priority_scheduler_queue_cleanup.py::test_raw_chat_with_duplicate_priority_keys_takes_the_last_one pass pass pass base-r3, head-r6a, head-r6b 0be0db2 PASS
B1 rig /v1/completions, raw httpx, priority: 1 tests/integration/routing/test_priority_scheduler_queue_cleanup.py::test_other_prioritized_endpoints_are_served_once_and_billed[completions] pass pass pass base-r3, head-r6a, head-r6b 0be0db2 PASS
B2 rig /v1/completions stream, consumed tests/integration/routing/test_priority_scheduler_queue_cleanup.py::test_other_prioritized_endpoints_are_served_once_and_billed[completions-stream] pass pass pass base-r3, head-r6a, head-r6b 0be0db2 PASS
C1 rig /queue/chat/completions raw httpx non-stream tests/integration/routing/test_priority_scheduler_queue_cleanup.py::test_other_prioritized_endpoints_are_served_once_and_billed[queue] pass pass pass base-r3, head-r6a, head-r6b 0be0db2 PASS
C2 rig /queue/chat/completions stream, consumed tests/integration/routing/test_priority_scheduler_queue_cleanup.py::test_other_prioritized_endpoints_are_served_once_and_billed[queue-stream] pass pass pass base-r3, head-r6a, head-r6b 0be0db2 PASS
G1 rig chat priority: "1" (string) tests/integration/routing/test_priority_scheduler_queue_cleanup.py::test_non_integer_priority_bypasses_the_scheduler_and_is_forwarded[string] pass pass pass base-r3, head-r6a, head-r6b 0be0db2 PASS
G2 rig chat priority: [1] tests/integration/routing/test_priority_scheduler_queue_cleanup.py::test_non_integer_priority_bypasses_the_scheduler_and_is_forwarded[list] pass pass pass base-r3, head-r6a, head-r6b 0be0db2 PASS
G3 rig chat priority: "" tests/integration/routing/test_priority_scheduler_queue_cleanup.py::test_non_integer_priority_bypasses_the_scheduler_and_is_forwarded[empty] pass pass pass base-r3, head-r6a, head-r6b 0be0db2 PASS
G4 rig chat priority: <5 KB string> tests/integration/routing/test_priority_scheduler_queue_cleanup.py::test_non_integer_priority_bypasses_the_scheduler_and_is_forwarded[5kb] pass pass pass base-r3, head-r6a, head-r6b 0be0db2 PASS
G5 rig chat priority: 0 (falsy, never scheduled: pre-existing quirk, observation only) tests/integration/routing/test_priority_scheduler_queue_cleanup.py::test_non_integer_priority_bypasses_the_scheduler_and_is_forwarded[zero] pass pass pass base-r3, head-r6a, head-r6b 0be0db2 PASS
G6 rig chat priority: 1 unauthenticated tests/integration/routing/test_priority_scheduler_queue_cleanup.py::test_unauthenticated_prioritized_request_never_reaches_the_upstream pass pass pass base-r3, head-r6a, head-r6b 0be0db2 PASS
H1 rig /v1/messages with priority: 1 (no scheduler on this path) tests/integration/routing/test_priority_scheduler_queue_cleanup.py::test_priority_on_unscheduled_routes_is_served_once[messages] pass pass pass base-r3, head-r6a, head-r6b 0be0db2 PASS
H2 rig /v1/responses with priority: 1 (no scheduler on this path) tests/integration/routing/test_priority_scheduler_queue_cleanup.py::test_priority_on_unscheduled_routes_is_served_once[responses] pass pass pass base-r3, head-r6a, head-r6b 0be0db2 PASS
M1 inmem, pinned connection: A priority: 1 healthy, trip the deployment, B then C priority: 2 during cooldown tests/integration/routing/test_priority_scheduler_queue_cleanup.py::test_in_memory_queue_forgets_served_requests_before_a_cooldown red pass pass base-r5, head-r5a, head-r5b 0be0db2 PASS
P1 pair cross-instance: A at first, B at second, C at first, all healthy tests/integration/routing/test_priority_scheduler_queue_cleanup.py::test_served_request_leaves_no_queue_entry_for_the_other_instance red pass pass base-r5, head-r5a, head-r5b 0be0db2 PASS
P2 pair burst: primer at first, then 12 concurrent prioritized requests (both instances x chat, chat stream, completions, completions stream, queue, queue stream), then one more at second tests/integration/routing/test_priority_scheduler_queue_cleanup.py::test_concurrent_prioritized_burst_across_instances_and_endpoints_is_served red pass pass base-r5, head-r5a, head-r5b 0be0db2 PASS
P3 pair /v1/completions SDK during cooldown with a fresh queue tests/integration/routing/test_priority_scheduler_queue_cleanup.py::test_fresh_queue_during_a_cooldown_is_refused_at_once pass pass pass base-r5, head-r5a, head-r5b 0be0db2 PASS
P4 pair: A healthy at first, trip, B priority: 2 at second tests/integration/routing/test_priority_scheduler_queue_cleanup.py::test_served_request_on_one_instance_does_not_block_a_waiter_on_the_other red pass pass base-r5, head-r5a, head-r5b 0be0db2 PASS
P5 pair: dead-replica ghost [[1, "ghost"]] seeded in Redis, trip, B priority: 2 via /queue/chat/completions at second tests/integration/routing/test_priority_scheduler_queue_cleanup.py::test_waiter_behind_an_expired_dead_replica_entry_is_re_enqueued_and_proceeds red pass pass base-r5, head-r5a, head-r5b 0be0db2 PASS
P6 pair: ghost seeded, B priority: 2 waits behind it via /v1/chat/completions, the key is PERSISTed once B's entry shows tests/integration/routing/test_priority_scheduler_queue_cleanup.py::test_waiter_behind_a_live_entry_times_out_and_removes_only_itself red pass pass base-r5, head-r5a, head-r5b 0be0db2 PASS
P7 pair: B priority: 2 waits behind the ghost at /v1/chat/completions, the key is rewritten to B's entry alone tests/integration/routing/test_priority_scheduler_queue_cleanup.py::test_waiter_proceeds_once_the_entry_ahead_is_cleared red pass pass base-r5, head-r5a, head-r5b 0be0db2 PASS
P8 pair: one raw http.client keep-alive connection to second carries the trip, the 429 confirmation and then B priority: 2 at /v1/chat/completions, so all three land on the worker that recorded the cooldown (a fresh connection can reach the other worker, whose cooldown view lags and admits B at once, the pre-existing per-worker convergence P3 also shows); the key is PERSISTed once B's entry shows, then the client closes the connection (cancel_on_disconnect: true) tests/integration/routing/test_priority_scheduler_queue_cleanup.py::test_disconnected_waiter_is_removed_from_the_queue red pass pass base-r7, head-r7a, head-r7b 0be0db2 PASS
J1 chaos: owned Redis + owned Redis-wired 2-worker proxy, 12 prioritized requests across the six endpoints before, during (Redis stopped) and after a Redis restart tests/integration/routing/test_priority_scheduler_queue_cleanup.py::test_prioritized_requests_survive_a_redis_outage red pass pass base-r5, head-r5a, head-r5b 0be0db2 PASS
J2 chaos: owned Redis-wired 2-worker proxy, 20 mixed prioritized requests held at the upstream, SIGKILL the worker holding fewer of them tests/integration/routing/test_priority_scheduler_queue_cleanup.py::test_sibling_worker_keeps_serving_prioritized_requests_after_a_worker_is_killed red pass pass base-r5, head-r5a, head-r5b 0be0db2 PASS

Type

Bug Fix

Caveats (if any)

Low

  • Queue writes on Redis are still read-modify-write, not atomic, so concurrent writers across replicas can erase each other's entries
    • A request whose entry was erased puts it back on its next poll, so the race now costs one extra write per hit instead of a failure or a lost place; healthy requests never read the queue at all
    • Making the queue atomic (a sorted set with a Lua pop) redesigns the scheduler's storage and is net-negative in a bug fix; it is a follow-up
  • An entry left by a worker killed mid-wait (SIGKILL, OOM) keeps ordering later waiters during cooldown until its key expires 5 s after the last write, as the stale head entry case shows
    • Main leaks an entry for every admitted request; a per-entry owner heartbeat would be a redesign, so it is left as is
  • With router_settings.default_priority set, every /v1/chat/completions request fails 500 Router.schedule_acompletion() missing 1 required positional argument: 'priority' on the merge base and on this PR alike
    • Pre-existing and unchanged here; tracked internally for its own fix
  • The new integration cells that boot their own proxy pass --timeout_worker_healthcheck derived from the harness readiness budget, because uvicorn's default 5 s worker ping respawned memory-starved workers in a loop on the loaded QA box and the cell never saw a ready proxy; the harness's other owned-proxy files keep the default
    • Harness-only and shared with the merge base; tracked internally
  • If a second cancel lands during cleanup, the removal finishes in the background a moment later
  • Each prioritized request now does one extra queue write for the removal
    • Polls with a healthy deployment no longer read the queue at all
  • Unit tests stand in for the Redis server with a JSON round-trip stub and an expiring stub, injected through Scheduler's redis_cache constructor seam the way main's existing test does; real Redis is covered by the proxy proof above
    • RedisCache offers no client injection, so a real cache over fakeredis would need a monkeypatch, which the test rules rank worse
  • make check at the tip fails lint-e2e-basedpyright on tests/e2e/llm_translation/realtime/realtime_client.py:366 (InputAudioBufferAppend is not in send()'s event union)

Final Attestation

  • The tests check the right things, including the edge cases, and regressions in the respective real-world customer use-cases are not possible after this PR

Note

Medium Risk
Changes core router scheduling and Redis-backed priority queue semantics (extra writes, different poll behavior); well-covered by integration tests but affects all integer-priority traffic on Redis proxies.

Overview
Fixes prioritized request scheduling by centralizing queue wait logic and ensuring queue entries are always removed when a request finishes, times out, or is cancelled.

Router — schedule_acompletion and _schedule_factory no longer duplicate enqueue/poll loops; both call new _wait_for_scheduler_turn, which delegates to Scheduler.wait_for_turn. Response headers and priority-on-exception behavior are unchanged.

Scheduler — Adds wait_for_turn (enqueue, poll until admitted or timeout) with shielded remove_request in a finally so cleanup completes even on cancel. poll now takes a FlowItem, returns immediately when deployments are healthy (no queue read), and during cooldown re-enqueues requests whose Redis/in-memory entry disappeared instead of erroring. get_queue normalizes cached JSON lists to (priority, request_id) tuples to fix tuple/list comparison failures on Redis-backed proxies.

Tests — Large integration suite for multi-instance Redis queue behavior plus expanded unit/router tests for cancellation, cooldown, and stale entries.

Reviewed by Cursor Bugbot for commit 0be0db2. Bugbot is set up for automated code reviews on this repo. Configure here.

Requests admitted while a healthy deployment existed never left the priority
queue, and neither did requests cancelled while waiting. The stale entries
blocked later requests during cooldown and, with Redis, made add_request
raise a TypeError on queues read back as JSON lists

Both scheduling paths now share one polling helper that removes the entry in
a finally block, whether the request was admitted, timed out or cancelled

Related to BerriAI#43059
@RachelHuangZW
RachelHuangZW requested a review from a team September 24, 2026 22:31
@codecov

codecov Bot commented Sep 24, 2026 •

Copy link
Copy Markdown

Codecov Report

❌ Patch coverage is 82.19178% with 13 lines in your changes missing coverage. Please review.

Files with missing lines Patch % Lines
litellm/router.py 61.76% 13 Missing ⚠️

📢 Thoughts on this report? Let us know!

…ge check

The coverage script only counts direct calls in test files. The helper is
exercised through prioritized acompletion and atext_completion in
test_router.py, like the other allowlisted entries
@codspeed

codspeed Bot commented Sep 24, 2026 •

Copy link
Copy Markdown
Contributor

Merging this PR will not alter performance

✅ 31 untouched benchmarks


Comparing RachelHuangZW:litellm_scheduler_remove_admitted_requests (0be0db2) with main (6498bef)1

Open in CodSpeed

Footnotes

  1. No successful run was found on main (e638540) during the generation of this report, so 6498bef was used instead as the comparison base. There might be some changes unrelated to this pull request in this report. ↩

@greptile-apps

greptile-apps Bot commented Sep 24, 2026 •

Copy link
Copy Markdown
Contributor

RetriggerConfidence Score: 5/5

[High risk] Refactors request scheduling and queue cleanup logic.

The PR appears safe to merge; no new actionable issue or outstanding previous finding remains.

Findings

  1. P2 Cache doubles in scheduler tests ▶

Summary

The PR removes prioritized requests from the scheduler queue when they stop waiting, normalizes Redis queue entries, and adds unit and integration coverage for admission, cancellation, cooldown, and multi-worker behavior.

Reviews (10) · Last reviewed commit: "test(integration): cover priority schedu..." · Reviewed by Greptile

Comment thread litellm/router.py Outdated
Comment thread litellm/router.py Outdated
Comment thread tests/unit/test_router/test_router.py
…clean up cancelled enqueues

poll() raised on an empty queue before checking for healthy deployments. With
the cleanup now rewriting the queue after every admission, a concurrent write
from another replica can erase a waiting request's entry, and that request
then failed while a deployment was healthy. poll() now admits as soon as a
deployment is healthy and only reads the queue during cooldown

add_request also moved inside the try block, so a request cancelled while its
queue write is in flight still has its entry removed
@RachelHuangZW

Copy link
Copy Markdown
Contributor Author

@greptileai

Comment thread tests/unit/test_scheduler.py
Comment thread tests/test_litellm/test_router.py Outdated
The router passes a healthy-deployments callable into the scheduler, so tests
inject a Scheduler directly instead of replacing the router's scheduler
attribute. The cancelled-mid-enqueue test moves to test_scheduler.py with the
other scheduler tests, and poll() takes the deployments as a Sequence since it
only checks whether any are healthy
@RachelHuangZW

Copy link
Copy Markdown
Contributor Author

@greptileai

@RachelHuangZW

Copy link
Copy Markdown
Contributor Author

mcp-integration failure is an unrelated MCP TCP timing flake. It passed in the previous two runs and locally, so it needs a maintainer rerun

@VANDRANKI VANDRANKI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Community review, does not clear the merge gate.

I read the full diff (router.py, scheduler.py, and both test files) and traced the actual bug rather than trusting the title.

The real bug is in Scheduler.poll(), not just in the caller's timeout-cleanup path. The old code:

if len(health_deployments) == 0:
    if queue[0][1] == id:
        heapq.heappop(queue)
        ...
        return True
    else:
        return False
return True

When health_deployments is non-empty, execution falls straight to the final return True without ever touching the queue. So every request admitted via "a healthy deployment exists" (presumably the common case, not the edge case) was returning True and proceeding to make its actual API call while its FlowItem stayed permanently stuck in the scheduler's queue (Redis-backed or in-memory DualCache depending on config). The old caller code (schedule_acompletion/_schedule_factory) only ever called scheduler.remove_request(...) explicitly on the timeout branch; the success branch relied entirely on poll()'s internal heapq.heappop, which never runs on the healthy-deployment fast path. That's an unbounded queue leak under normal healthy operation, not just a cancellation edge case.

There's a second, separate leak this also fixes: the old polling loop in the router methods was a bare while loop with no try/finally. If the awaiting task was cancelled mid-poll (client disconnect, upstream cancellation), asyncio.CancelledError propagates straight out, skipping both the success block and the else: remove_request(...) timeout-cleanup block, leaking the entry.

The fix consolidates both call sites into Scheduler.wait_for_turn(), wrapping add_request and the poll loop in try: ... finally: await self.remove_request(...), which runs on every exit path: success (whether or not poll() itself already popped the item), timeout, or cancellation. I checked remove_request() is safe to call redundantly ([item for item in queue if item[1] != request_id], a no-op if the id is already gone), so calling it unconditionally in finally doesn't double-remove or error when poll() already popped the entry via the priority-order path.

I traced the new tests against this:

  • test_prioritized_request_leaves_queue_when_it_stops_waiting (parametrized cancel/timeout) directly exercises both leak scenarios: a cooled-down deployment forces the second request to sit in queue behind a manually-inserted head-of-queue item, then either cancels the waiting task or lets it time out, and asserts the queue ends up as exactly [(0, "head-of-queue")] in both cases, i.e., the request's own entry is gone either way.
  • test_admitted_prioritized_request_does_not_block_later_request_during_cooldown checks the router's cooldown/rate-limit path still raises RouterRateLimitError correctly through the refactored _wait_for_scheduler_turn.

One gap I want to flag honestly: test_wait_for_turn_removes_entry_when_cancelled_mid_enqueue in the new test_scheduler.py sets up a scenario where cancellation happens while still inside add_request (via a subclass that pauses forever right after enqueueing), asserts the entry is present, cancels, and asserts CancelledError is raised — but it never asserts the queue is actually empty afterward, despite the test's name claiming to verify removal. I traced the production code myself and the finally block should still run in this case since the cancellation lands inside the try, so I believe the behavior is correct, but this particular test doesn't actually prove it. Worth tightening before merge, not a blocker on its own since the other test (test_prioritized_request_leaves_queue_when_it_stops_waiting) does verify post-cancellation queue state for the main call path.

This is a real, meaningful fix for an unbounded scheduler-queue leak, correctly scoped, with one test that could use a stronger final assertion.

@apex-mochen

Copy link
Copy Markdown

Offering a regression suite — no obligation, and no competing PR: I had a minimal fix for the same issue (#43060) and closed it in favour of this one, since wait_for_turn covers the same exit paths in one place rather than duplicating the cleanup across the two copies of the poll loop.

Before closing I built tests that fail on main and pass with a fix. They may save you time, because a few of these paths are easy to miss:

case on unpatched main
request is admitted (healthy deployments exist, so poll() returns True without popping) fails
underlying call raises fails
caller cancels while the request is still waiting fails
request times out passes already — guards existing behaviour, not a regression test
the cleanup itself raises request still succeeds, entry leaked
atext_completion(priority=...), i.e. the _schedule_factory copy of the loop fails

Two things I hit that may be useful for your finally:

  1. The try has to start before the poll loop, not only around the dispatch. My first attempt wrapped just the post-loop block, and the cancellation test caught it immediately: a cancel that lands while the request is still waiting never reaches the finally.
  2. On the cancellation path, awaiting the cleanup directly can be cut short by the pending cancellation. await asyncio.shield(...) is what makes it actually complete there. The tests poll briefly for the queue to drain (rather than asserting instantly) because that cleanup now lands a tick later.

I measured both the failures and the fix through Router.acompletion/atext_completion rather than by calling the internals, so the tests exercise the real dispatch path — including the fact that priority=0 never reaches the scheduler at all (that is a separate, already-claimed bug: #33894 / #36774, so the tests use a positive priority).

Happy to push these as a commit to your branch or paste them into the thread, whichever you prefer.

…emove_admitted_requests

# Conflicts:
#	tests/test_litellm/test_router.py
@RachelHuangZW

Copy link
Copy Markdown
Contributor Author

@VANDRANKI really appreciate the thorough review. For the mid-enqueue test, the queue-is-empty check is on its last line, in case it got cut off

@VANDRANKI

Copy link
Copy Markdown
Contributor

You're right, thanks for pointing that out. I just went back and checked the full test body and assert await scheduler.get_queue("sched-model") == [] is there as the last line. I must have gotten a truncated view of the file when I wrote that, sorry for the noise. That closes the gap I flagged, the mid-enqueue cancellation path is verified too.

…ed again during cleanup

A second cancel, or an anyio cancel scope that re-cancels on every await,
interrupted remove_request mid-write and left the entry in Redis.
@RachelHuangZW

Copy link
Copy Markdown
Contributor Author

@apex-mochen Thanks, the shield point was real. Added asyncio.shield plus a double-cancel regression test in 0c9ab36. Cleanup can't raise, since DualCache swallows Redis errors

@RachelHuangZW

Copy link
Copy Markdown
Contributor Author

@greptileai

…emove_admitted_requests

# Conflicts:
#	tests/code_coverage_tests/router_code_coverage.py

Copy link
Copy Markdown

Thanks for confirming this and for adding the double-cancel regression test.

For the record, the asyncio.shield concern came from the cancellation-cleanup review on #43060, and I’m glad it was useful in hardening this PR. If LiteLLM keeps contributor or review acknowledgments in the PR description, a brief note there would be appreciated. No commit-history change is needed.

@apex-mochen

Copy link
Copy Markdown

Hi @RachelHuangZW, thanks again for incorporating the cancellation-cleanup point — specifically asyncio.shield and the double-cancel regression. Since that review feedback directly influenced the final implementation, would you mind adding a brief acknowledgment in the PR description, e.g.:

Thanks to @apex-mochen for identifying the cancellation-cleanup race and suggesting shielded cleanup / regression coverage.

…emove_admitted_requests

# Conflicts:
#	litellm/router.py
#	litellm/scheduler.py
@RachelHuangZW

Copy link
Copy Markdown
Contributor Author

@apex-mochen Done, added the acknowledgment to the description under Relevant issues. Sorry for the slow reply, and thanks again for the review

…ove_admitted_requests

# Conflicts:
#	litellm/router.py
#	tests/unit/test_scheduler.py
@mateo-berri

Copy link
Copy Markdown
Contributor

@greptileai

@mateo-berri

Copy link
Copy Markdown
Contributor

bugbot run

@cursor cursor Bot left a comment •

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Stale Bugbot comment from a previous run.

@mateo-berri

Copy link
Copy Markdown
Contributor

@greptileai

@mateo-berri

Copy link
Copy Markdown
Contributor

bugbot run

@cursor cursor Bot left a comment •

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Stale Bugbot comment from a previous run.

@mateo-berri

Copy link
Copy Markdown
Contributor

@greptileai

Comment thread litellm/scheduler.py Outdated
@mateo-berri

Copy link
Copy Markdown
Contributor

@greptileai




class _JsonRoundTripRedisCache:

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Cache doubles in scheduler tests These tests replace LiteLLM's cache with _JsonRoundTripRedisCache and _ExpiringCache, violating the repository directive against doubles for its own code. This requirement must be satisfied before merging

Context Used: AGENTS.md (source)

Note: If this suggestion doesn't match your team's coding style, reply to this and let me know. I'll remember it for next time!

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

These stand in for the Redis server behind Scheduler's redis_cache seam, like main's StubRedisCache; RedisCache offers no client injection, and tests assert queue state

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

That’s a fair clarification. These are focused cache-seam fakes rather than mocks changing Scheduler behavior: _JsonRoundTripRedisCache reproduces Redis’s JSON list round-trip, _ExpiringCache models the queue key disappearing, and the existing StubRedisCache test establishes the same pattern in this file.

Since RedisCache does not expose client injection, requiring a live Redis server for these unit-level state assertions would make the tests slower and less deterministic. I’m withdrawing the finding; the real-Redis behavior is appropriately covered by the live proxy verification.

@mateo-berri

Copy link
Copy Markdown
Contributor

@greptileai

…ances

Adds tests/integration/routing/test_priority_scheduler_queue_cleanup.py: 27 cells
against the real proxy (two workers, Postgres, Redis, a scripted upstream) for every
prioritized surface (/v1/chat/completions, /v1/completions, /queue/chat/completions,
streaming and not, OpenAI SDK sync and async, raw httpx), the non-integer priority
pass-through, the in-memory queue on one proxy, two proxies sharing a Redis queue
(served requests leave no entry, a dead replica's entry is skipped or expires, a
waiter that times out or disconnects removes only itself), a Redis outage mid burst
and a SIGKILLed worker. Each cell asserts the caller's response, the upstream's
requests by marker and the Redis queue contents. On the merge base the cross-instance
cells fail with the list-of-lists TypeError and the in-memory cell with 408s behind
the leaked entry; on this branch every cell passes
@mateo-berri

Copy link
Copy Markdown
Contributor

@greptileai

@mateo-berri

Copy link
Copy Markdown
Contributor

bugbot run

@cursor cursor Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

✅ Bugbot reviewed your changes and found no new issues!

Comment @cursor review or bugbot run to trigger another review on this PR

Reviewed by Cursor Bugbot for commit 0be0db2. Configure here.

@mateo-berri mateo-berri left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

LGTM. Thanks for the contribution!

@mateo-berri
mateo-berri merged commit bf9f59d into BerriAI:main Oct 8, 2026
104 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

4 participants