Skip to content

[Router][Bugfix] reset K8s watcher on stale resource version errors - #999

Open
loicrouillermonay wants to merge 3 commits into
vllm-project:mainfrom
loicrouillermonay:fix/router-k8s-watcher-stale-resource-version
Open

[Router][Bugfix] reset K8s watcher on stale resource version errors#999
loicrouillermonay wants to merge 3 commits into
vllm-project:mainfrom
loicrouillermonay:fix/router-k8s-watcher-stale-resource-version

Conversation

@loicrouillermonay

Copy link
Copy Markdown

Getting 504 Timeout: Too large resource version looping in the router logs. vLLM pods were healthy, but the K8s watcher was stuck.

The bug: both K8sPodIPServiceDiscovery and K8sServiceNameServiceDiscovery create one kubernetes.watch.Watch instance and never replace it. The Python client stores the last resourceVersion it saw and feeds it back into every reconnect. Once the API server's watch cache moves past that version, the server throws the 504 (or 410 Gone) and the router catches it, sleeps 0.5s then calls stream() again with the same stale bookmark forever.

The Kubernetes Python client docs call this out explicitly in Watch.stream() here:

"Note that watching an API resource can expire... if that last result is too old as well, an ApiException exception will be thrown... In that case you have to recover yourself, probably by listing the API resource to obtain the latest state..."

So the fix is to reset the watcher when we hit a stale resource version. A new Watch() starts with resource_version=None, which forces a fresh LIST on the next attempt. Then we grab the current bookmark and resume. I only do this for stale-version errors.

Here is below an anonymized snippet so you can see the watcher logs 504 with a stale resource version over and over while the router reports 0 serving engine(s).

2026-07-17T16:25:53.730Z INFO:     Application startup complete.
2026-07-17T16:25:53.730Z INFO:     Uvicorn running on http://0.0.0.0:8000
2026-07-17T16:25:56.694Z ERROR: K8s watcher error: (504)
Reason: Timeout: Timeout: Too large resource version: <newer>, current: <current>
 (service_discovery.py:721)
 
2026-07-17T16:26:00.208Z ERROR: K8s watcher error: (504)
Reason: Timeout: Timeout: Too large resource version: <newer>, current: <current>
 (service_discovery.py:721)
 
2026-07-17T16:26:03.722Z ERROR: K8s watcher error: (504)
Reason: Timeout: Timeout: Too large resource version: <newer>, current: <current>
 (service_discovery.py:721)
 
2026-07-17T16:26:08.678Z INFO: Scraping metrics from 0 serving engine(s)
2026-07-17T16:26:10.749Z ERROR: K8s watcher error: (504)
Reason: Timeout: Timeout: Too large resource version: <newer>, current: <current>
 (service_discovery.py:721)
 
...

2026-07-17T16:28:03.203Z ERROR: K8s watcher error: (504)
Reason: Timeout: Timeout: Too large resource version: <newer>, current: <current>
 (service_discovery.py:721)

@loicrouillermonay
loicrouillermonay force-pushed the fix/router-k8s-watcher-stale-resource-version branch from 5f3ae72 to ec1210c Compare July 17, 2026 16:33

@gemini-code-assist gemini-code-assist 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.

Code Review

This pull request introduces recovery logic for Kubernetes service discovery watchers when they encounter stale resource version errors. It adds helper functions to detect stale resource versions via HTTP status codes (410, 504) or specific error message phrases, and resets the watcher accordingly. Unit tests are also added to verify this recovery behavior. The review feedback points out that unconditionally treating all 504 Gateway Timeout errors as stale is risky and could overload the API server; it suggests removing 504 from the unconditional status check and relying on phrase matching instead, along with a corresponding update to the test suite.

Comment thread src/vllm_router/service_discovery.py Outdated
Comment on lines +53 to +64
# HTTP status codes that indicate the watch bookmark is no longer in the API
# server's watch cache and the client must list the resource again.
_STALE_RESOURCE_VERSION_STATUSES: Tuple[int, ...] = (410, 504)


def _is_stale_resource_version_error(exc: Exception) -> bool:
"""Return True if exc indicates the K8s watch bookmark is stale."""
status = getattr(exc, "status", None)
if isinstance(status, int) and status in _STALE_RESOURCE_VERSION_STATUSES:
return True
message = str(exc).lower()
return any(phrase in message for phrase in _STALE_RESOURCE_VERSION_PHRASES)

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.

high

Unconditionally treating all 504 Gateway Timeout errors as stale resource version errors is risky. A 504 is a generic HTTP status code that can be returned by any intermediate proxy, load balancer, or the Kubernetes API server itself when a request times out (e.g., due to high load or network issues).

If the API server is already struggling and timing out, resetting the watcher and performing a full LIST request on every generic 504 timeout can cause a 'thundering herd' effect, severely overloading the API server further.

Instead, we should only treat 410 Gone as unconditionally stale (since it always indicates a stale resource version), and rely on the phrase-matching logic for 504 errors to ensure we only reset when the error message explicitly mentions a stale or too large resource version.

Suggested change
# HTTP status codes that indicate the watch bookmark is no longer in the API
# server's watch cache and the client must list the resource again.
_STALE_RESOURCE_VERSION_STATUSES: Tuple[int, ...] = (410, 504)
def _is_stale_resource_version_error(exc: Exception) -> bool:
"""Return True if exc indicates the K8s watch bookmark is stale."""
status = getattr(exc, "status", None)
if isinstance(status, int) and status in _STALE_RESOURCE_VERSION_STATUSES:
return True
message = str(exc).lower()
return any(phrase in message for phrase in _STALE_RESOURCE_VERSION_PHRASES)
# HTTP status codes that indicate the watch bookmark is no longer in the API
# server's watch cache and the client must list the resource again.
_STALE_RESOURCE_VERSION_STATUSES: Tuple[int, ...] = (410,)
def _is_stale_resource_version_error(exc: Exception) -> bool:
"""Return True if exc indicates the K8s watch bookmark is stale."""
status = getattr(exc, "status", None)
if isinstance(status, int) and status in _STALE_RESOURCE_VERSION_STATUSES:
return True
message = str(exc).lower()
return any(phrase in message for phrase in _STALE_RESOURCE_VERSION_PHRASES)

Comment thread src/tests/test_k8s_service_discovery.py Outdated
assert _is_stale_resource_version_error(Exception(message)) is expected


@pytest.mark.parametrize("status", [410, 504])

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.

medium

Update the test to only parameterize over 410 for unconditional status-based stale detection, since 504 is no longer unconditionally treated as stale without matching the error message phrases.

Suggested change
@pytest.mark.parametrize("status", [410, 504])
@pytest.mark.parametrize("status", [410])

@loicrouillermonay loicrouillermonay changed the title fix: reset K8s watcher on stale resource version errors [Router][Bugfix] reset K8s watcher on stale resource version errors Jul 17, 2026
@loicrouillermonay
loicrouillermonay force-pushed the fix/router-k8s-watcher-stale-resource-version branch from ec1210c to 273e803 Compare July 17, 2026 16:45
ruizhang0101
ruizhang0101 previously approved these changes Jul 22, 2026

@ruizhang0101 ruizhang0101 left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

LGTM

@loicrouillermonay
loicrouillermonay force-pushed the fix/router-k8s-watcher-stale-resource-version branch from 273e803 to c19e4a7 Compare July 22, 2026 06:58
@loicrouillermonay

Copy link
Copy Markdown
Author

I rebased my branch with master, now PR workflows requires approval from a maintainer 😉

@arnaudgeiser

Copy link
Copy Markdown
Contributor

This PR is important enough since it might affect absolutely everyone using the vLLM Router when the API servers are not in sync with the latest version of ETCD.
The current state of the router makes it highly sensitive to state drift with the current implementation of the Kubernetes watcher.

I cannot speak to the quality of the implementation, but it will definitely improve the router's stability in production workloads where state drift occurs regularly.

@loicrouillermonay

Copy link
Copy Markdown
Author

I rebased my branch with master, now PR workflows requires approval from a maintainer 😉

cc @ruizhang0101

@ruizhang0101
ruizhang0101 enabled auto-merge (squash) July 22, 2026 19:15
auto-merge was automatically disabled July 22, 2026 20:57

Head branch was pushed to by a user without write access

@loicrouillermonay
loicrouillermonay force-pushed the fix/router-k8s-watcher-stale-resource-version branch from 632604f to 25d1efd Compare July 22, 2026 21:54
@ruizhang0101

Copy link
Copy Markdown
Collaborator

Hi Could you try the PR #1013 and see if this fixed the problem?

@loicrouillermonay

Copy link
Copy Markdown
Author

Will do

loicrouillermonay and others added 3 commits August 3, 2026 17:55
Add detection for 410/504 ApiException status codes and stale-resource-version
phrases. When detected, replace the Watch() instance so the next stream()
performs a fresh LIST and resumes from the current resourceVersion.

Signed-off-by: Loïc Rouiller-Monay <loic.rouiller-monay@exoscale.ch>
Use ApiException.status and _STALE_RESOURCE_VERSION_PHRASES from k8s apiserver Go sources
@loicrouillermonay
loicrouillermonay force-pushed the fix/router-k8s-watcher-stale-resource-version branch from 25d1efd to d02c8c0 Compare August 3, 2026 15:55
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants