1. What API-based node failover can—and cannot—do
A Clash client can expose an external-controller API that lets another program inspect proxy groups and, when the active group supports manual selection, choose a different proxy. A small monitoring script can use that interface to test nodes, detect repeated failures, and switch a selector group without requiring a click in the client.
This is control-plane automation, not a guarantee that every application connection instantly recovers. Existing connections may remain attached to the old outbound until they close, and an API health check is only a test from the core’s point of view. It does not prove that a particular browser, DNS path, or remote website is working. Treat failover as a way to change the group’s selection for new connections, then verify the affected application separately.
- External controller: The core’s HTTP API listener, configured with
external-controller. - Selector group: A policy group whose selected member can be changed through the API. In the API response, check the group’s
type,now, andallfields. - Health probe: A request to the API’s proxy delay endpoint for a specific proxy. It can report an error or a measured delay, but it is not a full application-level transaction.
- Failover policy: Your own rules for what counts as unhealthy, how many failures to tolerate, which candidate to try, and when to switch back.
Automatic groups need different handling. A url-test group performs its own URL-based selection, while a fallback group is designed to select an available member according to its configuration. A script that repeatedly writes a member into one of these groups may fight the core’s own behavior. This guide focuses on a select group that the script can manage explicitly.
2. Expose the controller only where the script needs it
For a script running on the same computer as the core, bind the controller to loopback and configure a non-empty secret. The following is a configuration fragment; merge it into the active configuration rather than adding duplicate top-level fields.
external-controller: 127.0.0.1:9090
secret: "replace-with-a-long-random-secret"
After applying the configuration, check the client’s core log for a controller startup message and confirm that the selected port is not already in use. A local request to the version endpoint is a useful first test. With an API client such as curl, send the secret as a bearer token:
curl -H "Authorization: Bearer replace-with-a-long-random-secret" \
http://127.0.0.1:9090/version
A successful response should be JSON describing the running core version. If the request is refused, check the listener address, port, and whether the configuration was applied. If it returns an authentication error, check the bearer-token header and the secret in the active configuration. Do not solve a local connection problem by exposing the controller on every network interface.
Protect the API boundary
The controller can reveal proxy names and connection-related information, and its write endpoints can alter routing behavior. Keep it on 127.0.0.1 when the script runs locally. If a separate host must access it, use a firewall-restricted private network and a protected transport path; do not forward the controller port directly to the public internet. Avoid placing the secret in a shared script, shell history, screenshots, or logs. Environment variables or a local secret store are better options, and file permissions should prevent other local users from reading the script’s environment configuration.
Do not assume that a client’s web dashboard and the core API have the same access controls. A dashboard may proxy API requests, use a different address, or add its own authentication. Test the core endpoint directly from the same host and execution context as the monitoring script.
3. Build a monitor with thresholds and a deliberate switch policy
Before coding, identify the exact selector group name and the proxy names it contains. These are configuration names, not display labels that can safely be guessed. The API returns them from GET /proxies; a group entry typically includes its type, the currently selected member in now, and available members in all. The delay endpoint is GET /proxies/{name}/delay?url=...&timeout=.... To change a selector, send PUT /proxies/{group} with a JSON body such as {"name":"node-name"}.
The example below uses Python 3 and the third-party requests package. It probes the active node once per interval. Only after two consecutive failed probes does it check the configured alternatives and select the first candidate whose delay is below the limit. It deliberately does not switch merely because another node is a few milliseconds faster, which helps prevent oscillation.
import os
import time
from urllib.parse import quote
import requests
API = os.getenv("CLASH_API", "http://127.0.0.1:9090").rstrip("/")
SECRET = os.environ["CLASH_SECRET"]
GROUP = os.getenv("CLASH_GROUP", "Proxy")
CANDIDATES = [
name.strip()
for name in os.getenv("CLASH_NODES", "Node A,Node B").split(",")
if name.strip()
]
TEST_URL = os.getenv("CLASH_TEST_URL", "https://www.gstatic.com/generate_204")
PROBE_TIMEOUT_MS = 5000
HTTP_TIMEOUT_S = 8
MAX_DELAY_MS = 1800
CHECK_INTERVAL_S = 30
FAILURES_BEFORE_SWITCH = 2
session = requests.Session()
session.headers.update({"Authorization": f"Bearer {SECRET}"})
def get_json(path, **kwargs):
response = session.get(f"{API}{path}", timeout=HTTP_TIMEOUT_S, **kwargs)
response.raise_for_status()
return response.json()
def probe(name):
path = f"/proxies/{quote(name, safe='')}/delay"
try:
result = get_json(
path,
params={"url": TEST_URL, "timeout": PROBE_TIMEOUT_MS},
)
delay = int(result["delay"])
return delay if delay <= MAX_DELAY_MS else None
except (requests.RequestException, KeyError, TypeError, ValueError):
return None
def select(name):
path = f"/proxies/{quote(GROUP, safe='')}"
response = session.put(
f"{API}{path}",
json={"name": name},
timeout=HTTP_TIMEOUT_S,
)
response.raise_for_status()
failures = 0
while True:
try:
data = get_json("/proxies")
group = data["proxies"][GROUP]
if group.get("type") != "Selector":
raise RuntimeError(
f"{GROUP!r} is {group.get('type')!r}, not a Selector group"
)
current = group.get("now")
members = group.get("all", [])
if current not in members:
raise RuntimeError(f"Current member {current!r} is not in the group")
delay = probe(current)
if delay is not None:
print(f"{current}: healthy, {delay} ms")
failures = 0
else:
failures += 1
print(f"{current}: failed probe {failures}/{FAILURES_BEFORE_SWITCH}")
if failures >= FAILURES_BEFORE_SWITCH:
switched = False
for candidate in CANDIDATES:
if candidate == current or candidate not in members:
continue
candidate_delay = probe(candidate)
if candidate_delay is not None:
select(candidate)
print(f"Selected {candidate}: {candidate_delay} ms")
failures = 0
switched = True
break
if not switched:
print("No configured alternative passed the health check")
except (requests.RequestException, KeyError, RuntimeError) as error:
print(f"Monitor error: {error}")
time.sleep(CHECK_INTERVAL_S)
Set CLASH_SECRET before starting the process. Optionally set CLASH_API, CLASH_GROUP, and CLASH_NODES to match the local configuration. For example, a node list might be Tokyo 01,Seoul 02; each name must match the API response exactly. Install requests in the same Python environment that will run the script. Keep the monitor in a supervised process if it must restart after a system reboot.
Choose a probe that answers the right question
The probe URL should be stable and reachable through the candidate nodes. A lightweight endpoint that returns quickly is useful for checking basic outbound connectivity, but it does not test every protocol, destination, or account-dependent service. If the endpoint is blocked on one route, the script may label a usable node unhealthy. If it is reachable while the site you care about is not, the probe may label a partially broken route healthy. Test the chosen URL manually through more than one candidate before relying on it.
An interval of 30 seconds and a 5-second probe timeout are starting values, not universal recommendations. Short intervals detect changes sooner but create more requests and can make transient packet loss trigger unnecessary action. Longer intervals reduce probe traffic but delay recovery. The script uses two failed checks before switching, so with a 30-second interval the first decision may take around a minute, plus the probe and API request time. Adjust the threshold to the network’s normal variability, not just its best-case latency.
- Use separate limits: A failed request and a slow response are different signals. Set a reasonable maximum delay for your route, and treat connection errors as failures.
- Avoid rapid retries: Keep enough time between checks for temporary congestion to clear; add backoff if API or network errors persist.
- Keep a known-good fallback: If every candidate fails, the example leaves the current selection unchanged rather than silently switching to
DIRECT. - Prevent flapping: Require multiple failures before switching, and do not automatically switch back as soon as a preferred node passes one probe.
There is one operational caveat in this simple example: the failure counter is held in process memory. Restarting the monitor resets it. That is usually acceptable for a small local tool, but a production service may persist the counter or use a state store. A more advanced monitor can also require several successful checks before restoring a preferred node, enforce a minimum time between switches, and write structured logs with timestamps. Add those safeguards only after the basic API calls and group behavior have been verified.
4. Verify the switch and diagnose common failures
Test with a non-critical configuration and a short maintenance window. First call GET /proxies, confirm that the named group is a selector, and record its current now value and available members. Then probe each candidate individually. Finally, send a single manual PUT to the selector endpoint and call GET /proxies again to confirm that now changed. Once those steps work, run the monitor and observe at least one normal interval without forcing a failure.
| Symptom | Likely cause | Check |
|---|---|---|
| Connection refused | Wrong listener address or port, inactive configuration, or core not running | Check the active controller setting and test /version locally |
| HTTP 401 or 403 | Missing, incorrect, or stale bearer token | Compare the request header with the active secret |
| HTTP 404 | Incorrect endpoint or a proxy/group name not encoded as one URL path segment | Inspect GET /proxies and URL-encode names that contain spaces or reserved characters |
| HTTP 400 on selection | The group is not manually selectable, or the requested member is not valid for it | Check the group’s type and all values |
| Probe fails for every node | Test URL is blocked, DNS or outbound access is broken, or the timeout is too short | Try the URL through a known-good node and inspect the core log |
| API selection changes but traffic does not recover | Existing connections remain open, traffic bypasses the core, or the selected route has a separate issue | Start a new test connection and verify it appears in the client’s connection list |
For failures that are not explained by the HTTP status, temporarily log the request method, endpoint, response status, and sanitized error text. Do not log the authorization header or subscription credentials. Check the core log at the time of the request, and confirm that the client did not replace the configuration or restart the core between checks. If the API reports a successful selection but a new connection still uses the previous route, inspect the actual policy group referenced by the matching rule; changing an unrelated selector will not affect that traffic.
Keep the script’s candidate list synchronized with subscription updates. A node can be renamed or removed, and a stale name will fail even when the subscription has healthy alternatives. For a safer rollout, validate every configured candidate against the group’s current all list at startup and alert when a configured name is missing rather than silently treating that mismatch as a network outage.
FAQ
Does changing the selected node close existing connections?
Not necessarily. The API changes the selector’s current member, which affects routing decisions that use that group. Connections already established through the previous outbound may continue until they close or time out. Test with a new connection, and do not interpret one still-open download or stream as proof that selection failed.
Can the script control a url-test group?
It can inspect group information, but a url-test group manages selection according to its own configuration. Repeatedly forcing a member through a script can conflict with that automatic behavior. Use the group’s built-in test and interval settings, or use a selector group when an external program must make the choice.
Should the script switch to DIRECT if all nodes fail?
Only if that is an explicit policy decision. Direct routing may expose traffic outside the intended proxy route, and it may also fail to reach the destination. The example leaves the current selection unchanged and reports that no candidate passed, so an operator can investigate without an unexpected route change.
How often should the monitor check nodes?
Start with a moderate interval, such as 30 seconds, and a probe timeout around 5 seconds, then adjust after observing real network conditions. Combine a sensible interval with consecutive-failure thresholds and a cooldown. Checking every second is rarely a useful substitute for a stable health policy.