Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions crates/tw-api/msg-codes.txt
Original file line number Diff line number Diff line change
Expand Up @@ -228,6 +228,8 @@ engine.compare.no_operator
engine.duplicate_group
engine.duplicate_route
engine.empty_group
engine.group_unknown_upstream
engine.group_upstream_twice
engine.no_action
engine.no_match
engine.phase_two_with_to
Expand Down
36 changes: 36 additions & 0 deletions crates/tw-config/src/validate.rs
Original file line number Diff line number Diff line change
Expand Up @@ -778,6 +778,42 @@ mod tests {
assert!(e.to_string().contains("__all__"), "{e}");
}

/// 手写的配置里一个组把同一家写了几遍:加载时就拒绝。控制面保存时本来就拦着,
/// 拦不着的是手改的文件 —— 那时候选不去重,故障转移会把同一家再试几遍
#[test]
fn a_group_member_written_twice_or_misspelled_is_refused_at_load_time() {
let text = "version: 1
listen:
control:
key: c0ffee00c0ffee00c0ffee00c0ffee00c0ffee00c0ffee00c0ffee00c0ffee00
clients:
- name: c
key: tw-k
providers:
- name: a
base_url: https://a.example
key: sk-a
- name: b
base_url: https://b.example
key: sk-b
groups:
- name: pool
type: load-balance
providers: [a, a, a, b]
";
let m = crate::try_parse(text).unwrap_err().msg();
assert_eq!(m.code, "engine.group_upstream_twice", "{m:?}");
assert_eq!((m.arg("group"), m.arg("upstream")), ("pool", "a"));
// 每家写一次就收下
let once = text.replace("[a, a, a, b]", "[a, b]");
assert!(crate::try_parse(&once).is_ok(), "{once}");
// 写了一个不是上游的名字也拒绝
let typo = text.replace("[a, a, a, b]", "[a, typo]");
let m = crate::try_parse(&typo).unwrap_err().msg();
assert_eq!(m.code, "engine.group_unknown_upstream", "{m:?}");
assert_eq!((m.arg("group"), m.arg("upstream")), ("pool", "typo"));
}

#[test]
fn error_messages_say_what_to_do_next() {
// 错误信息是降低使用难度最有效的杠杆。判据不是「说清
Expand Down
5 changes: 4 additions & 1 deletion crates/tw-config/tests/manual/schema.rs
Original file line number Diff line number Diff line change
Expand Up @@ -1236,7 +1236,10 @@ pub fn sections() -> Vec<Section> {
"providers",
Kind::Strs,
Def::Required,
t("Member upstreams, by name.", "成员上游的名字。"),
t(
"Member upstreams, by name; not groups. Each upstream appears once in a group.",
"成员上游的名字,不能是策略组。同一个上游在一个策略组中只出现一次。",
),
),
row(
"selected",
Expand Down
107 changes: 107 additions & 0 deletions crates/tw-engine/src/engine.rs
Original file line number Diff line number Diff line change
Expand Up @@ -555,6 +555,10 @@ pub enum RouteError {
#[error("{}", self.msg())]
DuplicateGroup(String),
#[error("{}", self.msg())]
GroupUpstreamTwice { group: String, provider: String },
#[error("{}", self.msg())]
GroupUnknownUpstream { group: String, provider: String },
#[error("{}", self.msg())]
DuplicateRoute(String),
#[error("{}", self.msg())]
UnknownDefaultRoute(String),
Expand Down Expand Up @@ -606,6 +610,16 @@ impl RouteError {
"there is more than one group named `{group}`. Rules refer to a group by name, so \
names have to be unique"
),
RouteError::GroupUpstreamTwice { group, provider } => msg!(
"engine.group_upstream_twice", group = group, upstream = provider =>
"group `{group}` lists upstream `{upstream}` more than once. Each upstream appears \
once in a group"
),
RouteError::GroupUnknownUpstream { group, provider } => msg!(
"engine.group_unknown_upstream", group = group, upstream = provider =>
"group `{group}` lists `{upstream}`, which is not an upstream. A group's members are \
upstreams, by name"
),
RouteError::DuplicateRoute(route) => msg!(
"engine.duplicate_route", route = route =>
"there is more than one route named `{route}`. A gateway key binds to a route by \
Expand Down Expand Up @@ -876,6 +890,26 @@ impl Engine {
if g.providers.is_empty() {
return Err(RouteError::EmptyGroup(g.name.clone()));
}
// 同一家写两次:候选不去重,那一家失败之后故障转移会再试它一遍;`load-balance`
// 还会多轮到它几次 —— 一个没人写下、也看不出来的权重。控制面保存时就拦着
// (`control.group.upstream_twice`),手写的配置在这里拦
let mut members = std::collections::HashSet::new();
for p in &g.providers {
// 不认识的名字(拼错了、或者写了另一个组):候选里它对不上任何上游,
// 这一位就静默地没了。控制面保存时拦着(`control.group.no_such_upstream`)
if !self.providers.contains(p) {
return Err(RouteError::GroupUnknownUpstream {
group: g.name.clone(),
provider: p.clone(),
});
}
if !members.insert(p.as_str()) {
return Err(RouteError::GroupUpstreamTwice {
group: g.name.clone(),
provider: p.clone(),
});
}
}
}
for set in &self.sets {
self.check_rules(&set.rules)?;
Expand Down Expand Up @@ -2228,6 +2262,71 @@ mod builtin_tests {
);
assert_eq!(e.validate(), Err(RouteError::DuplicateGroup("pool".into())));
}

/// 手写的配置里同一家写了几遍:拒绝,说出是哪个组、哪一家。不拦的话候选里它出现
/// 几次,故障转移就试它几次,`load-balance` 也多轮到它几次
#[test]
fn a_group_that_lists_an_upstream_twice_is_rejected() {
let g: Group =
serde_yaml_ng::from_str("name: pool\ntype: load-balance\nproviders: [a, a, a, b]\n")
.unwrap();
let e = Engine::with_default_rules(
vec!["a".into(), "b".into()],
vec![g],
vec![rule("兜底", "{}", "pool")],
);
let err = e.validate().unwrap_err();
assert_eq!(
err,
RouteError::GroupUpstreamTwice {
group: "pool".into(),
provider: "a".into(),
}
);
let m = err.msg();
assert_eq!(m.code, "engine.group_upstream_twice");
assert_eq!((m.arg("group"), m.arg("upstream")), ("pool", "a"));
// 每种类型都一样:不只是 `load-balance` 会多轮到它,故障转移也会再试它
let g = Group {
name: "pool".into(),
kind: GroupType::Fallback,
providers: vec!["a".into(), "b".into(), "a".into()],
selected: None,
};
let e = Engine::with_default_rules(
vec!["a".into(), "b".into()],
vec![g],
vec![rule("兜底", "{}", "pool")],
);
assert!(matches!(
e.validate(),
Err(RouteError::GroupUpstreamTwice { .. })
));
}

/// 组里写了一个不是上游的名字(拼错了,或者写了另一个组):拒绝,说出是哪个组、
/// 哪个名字。不拦的话候选里对不上它,组静默地少了一位
#[test]
fn a_group_member_that_is_not_an_upstream_is_rejected() {
let g: Group =
serde_yaml_ng::from_str("name: pool\ntype: fallback\nproviders: [a, typo]\n").unwrap();
let e = Engine::with_default_rules(
vec!["a".into(), "b".into()],
vec![g],
vec![rule("兜底", "{}", "pool")],
);
let err = e.validate().unwrap_err();
assert_eq!(
err,
RouteError::GroupUnknownUpstream {
group: "pool".into(),
provider: "typo".into(),
}
);
let m = err.msg();
assert_eq!(m.code, "engine.group_unknown_upstream");
assert_eq!((m.arg("group"), m.arg("upstream")), ("pool", "typo"));
}
}

#[cfg(test)]
Expand Down Expand Up @@ -2673,6 +2772,14 @@ mod msg_codes {
},
RouteError::EmptyGroup("g".into()),
RouteError::DuplicateGroup("g".into()),
RouteError::GroupUpstreamTwice {
group: "g".into(),
provider: "p".into(),
},
RouteError::GroupUnknownUpstream {
group: "g".into(),
provider: "p".into(),
},
RouteError::DuplicateRoute("x".into()),
RouteError::UnknownDefaultRoute("x".into()),
RouteError::UnknownRoute {
Expand Down
2 changes: 1 addition & 1 deletion docs/config.md
Original file line number Diff line number Diff line change
Expand Up @@ -953,7 +953,7 @@ group with `to`.
|---|---|---|---|
| `name` | string | **required** | Name of the group; unique, and not the name of an upstream. |
| `type` | `fallback` \| `select` \| `load-balance` \| `url-test` \| `cheapest` | `fallback` | `fallback`: the first healthy member, in order. `select`: the member named in `selected`. `load-balance`: take turns between new conversations. `url-test`: the fastest by measured time to first byte. `cheapest`: the lowest input price. |
| `providers` | list of strings | **required** | Member upstreams, by name. |
| `providers` | list of strings | **required** | Member upstreams, by name; not groups. Each upstream appears once in a group. |
| `selected` | string | — | For `select`: the chosen member. |
<!-- /generated -->

Expand Down
2 changes: 1 addition & 1 deletion docs/config.zh-CN.md
Original file line number Diff line number Diff line change
Expand Up @@ -748,7 +748,7 @@ aliases:
|---|---|---|---|
| `name` | 字符串 | **必填** | 策略组的名字,不能重复,也不能和上游同名。 |
| `type` | `fallback` \| `select` \| `load-balance` \| `url-test` \| `cheapest` | `fallback` | `fallback`:按顺序取第一个健康的。`select`:取 `selected` 指定的那个。`load-balance`:新对话轮流。`url-test`:按实测首字节时间取最快的。`cheapest`:取输入单价最低的。 |
| `providers` | 字符串列表 | **必填** | 成员上游的名字。 |
| `providers` | 字符串列表 | **必填** | 成员上游的名字,不能是策略组。同一个上游在一个策略组中只出现一次。 |
| `selected` | 字符串 | — | `select` 类型选中的成员。 |
<!-- /generated -->

Expand Down
Loading