Skip to content

fix(azure): propagate asyncio.CancelledError instead of raising a 500 - #35330

Closed
devin-ai-integration[bot] wants to merge 7 commits into
mainfrom
devin_ai_fix_azure_cancellederror_35329
Closed

devin-ai-integration[bot] wants to merge 7 commits into
mainfrom
devin_ai_fix_azure_cancellederror_35329

Conversation

@devin-ai-integration

@devin-ai-integration devin-ai-integration Bot commented Jul 31, 2026 •

Copy link
Copy Markdown
Contributor

TLDR

Problem this solves:

  • Azure's async handler turned task cancellation into a fake 500
  • With cancel_on_disconnect, one client hang-up became a deployment failure
  • The router benched the healthy Azure deployment and fell back
  • Logs showed AzureException APIError 500s Azure never returned

How it solves it:

  • Re-raise asyncio.CancelledError instead of AzureOpenAIError(500)
  • Keeps the existing post_call logging for observability
  • Unit regression test that fails without the fix
  • Live e2e test: a client hang-up must not bench the Azure deployment
  • e2e harness gains Transport.abandon, which closes the socket mid-answer

User Flow

Before: a platform team running cancel_on_disconnect: true in front of an Azure model group watches one impatient client take a healthy Azure deployment out of rotation

  1. Their app sends POST https://litellm-domain/v1/chat/completions with "model": "azure-group" and a prompt whose answer takes about a minute
  2. The app's own timeout fires a few seconds in and it closes the connection before any answer arrives
  3. On https://litellm-domain/ui/?page=logs that request shows as a 500 AzureException APIError with an empty error body, booked against the Azure deployment that was serving it
  4. The next call, POST https://litellm-domain/v1/chat/completions with the same "model": "azure-group", comes back 200 with an x-litellm-model-id header naming a different deployment: the one the client hung up on is in cooldown
  5. Every call for the length of the cooldown lands on the fallback deployment, while Azure's own metrics show no failed request

After: the same hang-up is recorded as the client's doing and the Azure deployment keeps serving

  1. Their app sends POST https://litellm-domain/v1/chat/completions with "model": "azure-group" and a prompt whose answer takes about a minute
  2. The app's own timeout fires a few seconds in and it closes the connection before any answer arrives
  3. On https://litellm-domain/ui/?page=logs that request shows as a 499 Client disconnected the request and is not counted against any deployment
  4. The next call, POST https://litellm-domain/v1/chat/completions with the same "model": "azure-group", comes back 200 with x-litellm-model-id naming the same Azure deployment the client hung up on
  5. Every following call keeps landing on that Azure deployment and nothing is in cooldown

Relevant issues

Fixes #35329
Fixes #42222

Affected release

Linear ticket

Resolves LIT-8248

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/test_litellm/llms/azure/test_azure.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

Shared setup: a local proxy on a random port with general_settings.cancel_on_disconnect: true and store_model_in_db: true, router_settings on simple-shuffle with num_retries: 3, real Azure OpenAI (azure/gpt-5.4-nano, api_version 2024-10-21) and OpenAI credentials from the environment. Every case registers a fresh two-deployment group through POST /model/new: the Azure deployment with weight: 1, max_retries: 0, cooldown_time: 300 and model_info.allowed_fails: 0, plus a weight: 0 OpenAI backup (openai/gpt-5.5) that simple-shuffle can only reach once the Azure deployment is benched. Before ran the proxy with the merge base's Azure handler and this PR's tests, After ran the proxy at this PR's tip. Real Azure and OpenAI calls, no mocks

Before (a83773c)

Live e2e cell: a client hanging up mid-answer never benches the deployment

  1. From tests/e2e: LITELLM_PROXY_URL=http://127.0.0.1:38037 pytest router/test_reliability_cancel_on_disconnect_e2e.py -p no:cacheprovider -q --no-header -rA (the cell reads cancel_on_disconnect back from the proxy, warms the key up with one cheap call, asks for a 3000-word essay with retries off, hangs up 8s in, settles 3s, then sends six cheap calls)
  2. Observed output:
E           AssertionError: call 1 after the hang-up should have been served by the Azure deployment e27d0b54-743e-4fc4-b5f5-201a65852657, the proxy named 'a48126f4-f205-4517-9ec0-2f99ae0686bf': the cancelled call was booked as a failure and benched it
FAILED router/test_reliability_cancel_on_disconnect_e2e.py::TestReliabilityCancelOnDisconnect::test_client_hanging_up_never_benches_the_deployment
1 failed in 57.80s

curl: hang up 2s into a long answer, then ask the group three times

  1. Register the pair with the master key (POST /model/new twice, bodies as in the QA runbook below) and wait for the group to show on GET /v1/models:
azure deployment id:  5fae1d77-fc52-4eab-acc5-c5318dcb710d
backup deployment id: ac1e4332-9293-4e53-9d3d-3e4b6b181f13
  1. curl -s --max-time 2 -X POST http://127.0.0.1:38037/chat/completions -H "Authorization: Bearer $KEY" -H 'Content-Type: application/json' -d '{"model": "azure-disconnect-before3", "messages": [{"role": "user", "content": "Write a 3000 word essay on the history of the telegraph."}], "max_tokens": 4096}'
curl exit code: 28 (28 = client timed out and hung up)
  1. Three seconds later, three times: curl -s -D - -o /dev/null -X POST http://127.0.0.1:38037/chat/completions -H "Authorization: Bearer $KEY" -H 'Content-Type: application/json' -d '{"model": "azure-disconnect-before3", "messages": [{"role": "user", "content": "say hi"}]}' | grep -i x-litellm-model-id
call 1: x-litellm-model-id: ac1e4332-9293-4e53-9d3d-3e4b6b181f13
call 2: x-litellm-model-id: ac1e4332-9293-4e53-9d3d-3e4b6b181f13
call 3: x-litellm-model-id: ac1e4332-9293-4e53-9d3d-3e4b6b181f13

Every follow-up is answered by the zero-weight backup: the Azure deployment is benched

Proxy log

  1. grep -n 'Attempting to add\|client disconnected' litellm.log around each hang-up:
11:53:27 - LiteLLM Router:DEBUG: cooldown_handlers.py:463 - Attempting to add e27d0b54-743e-4fc4-b5f5-201a65852657 to cooldown list
    'exception_received': 'litellm.APIError: AzureException APIError - ', 'status_code': '500'
11:53:58 - LiteLLM Router:DEBUG: cooldown_handlers.py:463 - Attempting to add 5fae1d77-fc52-4eab-acc5-c5318dcb710d to cooldown list

The cancelled call is booked as a bodiless 500 APIError and the deployment goes to cooldown before the 499 reaches the client

After (70be37a)

Live e2e cell: a client hanging up mid-answer never benches the deployment

  1. Same command against the proxy at this tip
  2. Observed output:
PASSED router/test_reliability_cancel_on_disconnect_e2e.py::TestReliabilityCancelOnDisconnect::test_client_hanging_up_never_benches_the_deployment
1 passed in 64.89s (0:01:04)

curl: hang up 2s into a long answer, then ask the group three times

  1. Same registration:
azure deployment id:  a53bc12a-1017-4670-acbe-121f00b035ba
backup deployment id: c1034bb8-92a9-47e8-87e6-576b101ac579
  1. Same hang-up curl:
curl exit code: 28 (28 = client timed out and hung up)
  1. Same three follow-ups:
call 1: x-litellm-model-id: a53bc12a-1017-4670-acbe-121f00b035ba
call 2: x-litellm-model-id: a53bc12a-1017-4670-acbe-121f00b035ba
call 3: x-litellm-model-id: a53bc12a-1017-4670-acbe-121f00b035ba

Every follow-up is answered by the Azure deployment the client hung up on

Proxy log

  1. Same grep:
11:56:42 - LiteLLM Proxy:INFO: common_request_processing.py:1533 - litellm.proxy.proxy_server._handle_llm_api_exception(): client disconnected, upstream LLM request cancelled - litellm_call_id=6f51a272-8718-4e29-aed0-199f2c96c0a3
12:02:28 - LiteLLM Proxy:INFO: common_request_processing.py:1533 - litellm.proxy.proxy_server._handle_llm_api_exception(): client disconnected, upstream LLM request cancelled - litellm_call_id=35b44926-56f5-441b-a8ca-3821c3a01af4

grep -c 'Attempting to add' litellm.log is 0: each hang-up is logged as a client disconnect and nothing is benched

Plain litellm.acompletion against a stalled Azure endpoint, cancelled mid-flight, from the original run of this PR:

before: BUG: converted to APIError: litellm.APIError: AzureException APIError -
after:  CancelledError (correct) / task cancelled state: True

Type

🐛 Bug Fix
✅ Test

Caveats (if any)

Low

  • The e2e cell needs cancel_on_disconnect: true on the proxy: the stage mirror CI config sets it, and the Buildkite ephemeral stack values get it in the companion PR BerriAI/project-releaser#255, so buildkite/e2e-tests here stays red until that lands
  • Only the non-streaming Azure chat path caught CancelledError; streaming was never affected
  • SDK callers cancelling an Azure acompletion task now see CancelledError, the asyncio contract, instead of APIError
  • The mislabeled exception surfaced as litellm.APIError, which no per-type allowed-fails policy covers, so the test benches on generic allowed_fails: 0
  • A single-deployment group never cools down, so the test needs the zero-weight backup to observe the bench
  • Assumed: a 4096-token gpt-5.4-nano answer takes longer than the 8s hang-up window; if Azure ever answers first the cell fails loudly instead of passing vacuously

QA runbook

  • tests/e2e/router/test_reliability_cancel_on_disconnect_e2e.py::TestReliabilityCancelOnDisconnect::test_client_hanging_up_never_benches_the_deployment - a client that hangs up mid-answer on an Azure deployment does not get it benched; the next six calls all land on it
    • Start the proxy with general_settings.cancel_on_disconnect: true and store_model_in_db: true, with AZURE_API_KEY, AZURE_API_BASE (a resource serving gpt-5.4-nano) and OPENAI_API_KEY in the environment
    • POST /model/new with the master key: {"model_name": "grp", "litellm_params": {"model": "azure/gpt-5.4-nano", "api_key": "os.environ/AZURE_API_KEY", "api_base": "os.environ/AZURE_API_BASE", "api_version": "2024-10-21", "max_retries": 0, "weight": 1, "cooldown_time": 300}, "model_info": {"allowed_fails": 0}} and note the returned model_info.id
    • POST /model/new with the master key: {"model_name": "grp", "litellm_params": {"model": "openai/gpt-5.5", "api_key": "os.environ/OPENAI_API_KEY", "weight": 0}}
    • Warm up: curl -s -D - -o /dev/null -X POST http://localhost:4000/chat/completions -H "Authorization: Bearer sk-1234" -d '{"model": "grp", "messages": [{"role": "user", "content": "say hi"}]}' and expect 200 with x-litellm-model-id equal to the Azure id (a cold virtual key can spend a couple of seconds in auth, and a hang-up that lands before the provider call is in flight cancels nothing)
    • curl -m 8 -X POST http://localhost:4000/chat/completions -H "Authorization: Bearer sk-1234" -d '{"model": "grp", "messages": [{"role": "user", "content": "Write a 3000 word essay on the history of the telegraph."}], "max_tokens": 4096, "stream": false}' and expect curl to exit 28 with no response (a 4096-token answer takes about a minute, so 8s lands well inside it)
    • Wait 3s, then run the warm-up curl six more times and expect 200 with x-litellm-model-id equal to the Azure id every time; before the fix the first one already names the backup id
    • Proxy log: expect client disconnected, upstream LLM request cancelled for the hang-up and no Attempting to add <id> to cooldown list line
    • Sanity check: this test makes sense to add and is not hand-wavey (it asserts the exact deployment id on every follow-up, reads cancel_on_disconnect back from the proxy so it cannot pass vacuously, and fails loudly if Azure answers before the hang-up) or potentially flaky

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

Link to Devin session: https://app.devin.ai/sessions/879961a9c5504feda31adfde569811db
Open in Devin Desktop: https://app.devin.ai/desktop/session/879961a9c5504feda31adfde569811db?variant=devin


Note

Medium Risk
Changes Azure async completion error handling and cancellation propagation, which affects router cooldown behavior under cancel_on_disconnect; scope is narrow but touches reliability-critical routing paths.

Overview
Fixes a bug where Azure async chat turned asyncio.CancelledError (e.g. from cancel_on_disconnect when the client closes the socket) into a fake 500 AzureOpenAIError, so the router treated a hang-up as a deployment failure and could cooldown a healthy Azure deployment.

The handler still runs post_call logging, then re-raises CancelledError instead of mapping it to HTTP 500. SDK callers cancelling an Azure acompletion now get the normal asyncio cancellation contract, not APIError.

Tests and e2e harness: a unit test asserts litellm.acompletion propagates CancelledError; a live e2e case hangs up mid-answer via new abandon / Transport.abandon, checks cancel_on_disconnect via GET /config/list, and verifies follow-up calls still hit the same Azure deployment. CI mirror config enables cancel_on_disconnect: true; coverage registry adds reliability.cooldown.client_disconnect.stays_healthy.

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

…OpenAIError(500)

Co-Authored-By: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com>
@CLAassistant

CLAassistant commented Jul 31, 2026 •

Copy link
Copy Markdown

CLA assistant check
Thank you for your submission! We really appreciate it. Like many open source projects, we ask that you all sign our Contributor License Agreement before we can accept your contribution.
1 out of 2 committers have signed the CLA.

✅ mateo-berri
❌ devin-ai-integration[bot]
You have signed the CLA already but the status is still pending? Let us recheck it.

@greptile-apps

greptile-apps Bot commented Jul 31, 2026 •

Copy link
Copy Markdown
Contributor

RetriggerConfidence Score: 4/5

The runtime fix appears correct, but the outstanding repository test-convention violation should be resolved before merging

Findings

  1. P2 Avoid direct SDK monkeypatching ▶

Summary

This PR preserves asyncio.CancelledError through Azure non-streaming async completions so client disconnects are not recorded as provider failures. It adds focused unit coverage and a live end-to-end check that verifies the Azure deployment remains healthy after the client closes its connection

  • Re-raises cancellation after retaining post-call logging
  • Adds an HTTP harness operation that abandons a non-streaming request
  • Verifies subsequent requests continue using the same Azure deployment
  • Enables and validates cancel_on_disconnect in the stage-mirror test configuration

Reviews (2) · Last reviewed commit: "Merge branch 'devin_ai_fix_azure_cancell..."

async def cancelled_create(**kwargs):
raise asyncio.CancelledError()

client.chat.completions.with_raw_response.create = cancelled_create

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 Avoid direct SDK monkeypatching

Directly replacing with_raw_response.create on a real SDK client violates the repository's test convention and couples this regression test to the SDK wrapper object's attribute behavior. Use the established dependency-injection or mock-patching pattern so SDK implementation changes do not turn this isolated test into a network request.

Context Used: CLAUDE.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.

The handler type-checks for a real AsyncAzureOpenAI, so the swap is on an instance the test owns, never a class, matching the sibling streaming test.

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.

You're right. This is an instance-local replacement on a client owned by the test, not a class-level monkeypatch, and it follows the existing _FakeRawCompletions injection pattern in the sibling streaming test in this file. Since litellm.acompletion receives that client explicitly and the fake prevents any network call while exercising the real Azure handler path, this is appropriate. I withdraw the comment.

@codecov

codecov Bot commented Jul 31, 2026 •

Copy link
Copy Markdown

Codecov Report

✅ All modified and coverable lines are covered by tests.

📢 Thoughts on this report? Let us know!

@codspeed

codspeed Bot commented Jul 31, 2026 •

Copy link
Copy Markdown
Contributor

Merging this PR will not alter performance

✅ 31 untouched benchmarks


Comparing devin_ai_fix_azure_cancellederror_35329 (70be37a) with main (f92ff60)

Open in CodSpeed

@yuneng-berri
yuneng-berri deleted the branch main September 13, 2026 04:37
@yuneng-berri yuneng-berri reopened this Sep 13, 2026
@devin-ai-integration

Copy link
Copy Markdown
Contributor Author

Customer report (Pylon #8893) confirms this also trips false router cooldowns on healthy Azure deployments. Tracked in LIT-8248

@mateo-berri
mateo-berri changed the base branch from litellm_internal_staging to main September 21, 2026 17:59
@mateo-berri
mateo-berri requested a review from a team September 21, 2026 17:59
mateo-berri and others added 3 commits September 21, 2026 11:01
Co-Authored-By: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com>
…ancelled

Live proxy with cancel_on_disconnect, two-deployment group, generic allowed_fails=0, red at the pre-fix handler and green with the bare raise

Co-Authored-By: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com>
…hub.com/BerriAI/litellm into devin_ai_fix_azure_cancellederror_35329

# Conflicts:
#	tests/e2e/router/reliability_support.py
#	tests/e2e/router/test_reliability_cancel_on_disconnect_e2e.py
#	tests/test_litellm/llms/azure/test_azure.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.

✅ 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 70be37a. Configure here.

@mateo-berri

Copy link
Copy Markdown
Contributor

Superseded by #42295, the same tip moved to a litellm_ branch so CircleCI runs the legacy provider suites on it

This branch was successfully deployed

No deployments
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

3 participants