Skip to content

Commit 6c5704c

Browse files
committed
fix(test): recover after Linear's "conflict on insert" on projects create
Linear's API has a transient failure mode where `*Create` mutations commit server-side but return "conflict on insert" to the client. The existing `run_lineark_with_retry` helper retries the same request body, which is counterproductive — Linear's content-hash idempotency keeps returning the same conflict, so every retry fails the same way. CI's Tests (Online) job has been flaking on this for at least the last several PRs (#140 hit it before the work in #141 even started). When we see "conflict on insert", read the entity back by name instead of retrying. The entity is already in Linear's database; we just need its post-create state. Both `*Create` and `read` responses share the shape that downstream test assertions care about (`id`, `name`, full details), so the synthesized response is interchangeable. Limited to `projects` for now since `projects read` accepts both UUIDs and names. `issues read` only accepts identifier/UUID; the one issues caller of this helper would need a search-and-filter recovery path that's bigger than this fix.
1 parent 0ee74b2 commit 6c5704c

1 file changed

Lines changed: 67 additions & 1 deletion

File tree

crates/lineark/tests/online.rs

Lines changed: 67 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -30,7 +30,19 @@ fn run_lineark_with_retry(args: &[&str]) -> std::process::Output {
3030
return output;
3131
}
3232
let stderr = String::from_utf8_lossy(&output.stderr);
33-
// Only retry on transient "conflict on insert" errors from the Linear API.
33+
// Linear's API has a transient failure mode where `*Create` mutations
34+
// commit server-side but return "conflict on insert" to the client.
35+
// Retrying the same request body is futile — the server's content-hash
36+
// idempotency keeps returning the same conflict. Recover instead by
37+
// reading back the just-created entity by its name; if found, return
38+
// that as a synthetic success.
39+
if stderr.contains("conflict on insert") {
40+
if let Some(recovered) = recover_after_create_conflict(args) {
41+
return recovered;
42+
}
43+
}
44+
// Non-conflict errors (or unrecoverable conflicts) bubble up after
45+
// the loop exhausts.
3446
if !stderr.contains("conflict on insert") {
3547
return output;
3648
}
@@ -42,6 +54,60 @@ fn run_lineark_with_retry(args: &[&str]) -> std::process::Output {
4254
.expect("failed to execute lineark")
4355
}
4456

57+
/// On a `*Create` "conflict on insert" error, the entity is usually already
58+
/// in Linear's database — the server just lost the response. Read it back by
59+
/// the unique name/title we just sent and return the read response as a
60+
/// stand-in for the original create response. Both responses have the shape
61+
/// callers care about (`id`, `name`, plus full entity details), so the test
62+
/// assertions that follow continue to work.
63+
///
64+
/// Returns `None` when the args don't match a recognized create pattern, the
65+
/// resource has no read-by-name path, or the read itself fails — leaving the
66+
/// caller free to bubble the original conflict error.
67+
fn recover_after_create_conflict(args: &[&str]) -> Option<std::process::Output> {
68+
// Locate the `<resource> create <name>` triple.
69+
let create_pos = args.iter().position(|&a| a == "create")?;
70+
let resource = *args.get(create_pos.checked_sub(1)?)?;
71+
let name = *args.get(create_pos + 1)?;
72+
73+
// Pull the auth token + format from earlier in the args so the read uses
74+
// the same identity and serialization as the original call.
75+
let token_pos = args.iter().position(|&a| a == "--api-token")?;
76+
let token = *args.get(token_pos + 1)?;
77+
let format = args
78+
.iter()
79+
.position(|&a| a == "--format")
80+
.and_then(|i| args.get(i + 1))
81+
.copied()
82+
.unwrap_or("json");
83+
84+
// Only resources whose `read` subcommand accepts a human-readable name
85+
// can recover this way. Projects qualify (`projects read` resolves names
86+
// and UUIDs); issues take an identifier or UUID, so a title-based recovery
87+
// path would need a search + filter step we don't yet have.
88+
if resource != "projects" {
89+
return None;
90+
}
91+
92+
let read = lineark()
93+
.args([
94+
"--api-token",
95+
token,
96+
"--format",
97+
format,
98+
resource,
99+
"read",
100+
name,
101+
])
102+
.output()
103+
.ok()?;
104+
if read.status.success() {
105+
Some(read)
106+
} else {
107+
None
108+
}
109+
}
110+
45111
/// Helper: create a fresh test team via the SDK.
46112
fn create_test_team() -> TestTeam {
47113
let client = Client::from_token(test_token()).unwrap();

0 commit comments

Comments
 (0)