Skip to content

Commit b6e89b5

Browse files
authored
fix(init): retry transient auth validation once (#1271)
## Summary Retries a wizard request once when the init service explicitly reports that upstream Sentry authentication failed before workflow execution. This covers the rare auth timeout behind [CLI-17A](https://sentry.sentry.io/issues/7415001837/) without enabling broad HTTP retries or replaying ambiguous resume requests. Companion server change: [getsentry/cli-init-api#203](getsentry/cli-init-api#203) ## Changes - retry exactly once for a real `MastraClientError` with status 503, `safeToRetry: true`, and code `AUTH_UPSTREAM_TIMEOUT` or `AUTH_UPSTREAM_UNAVAILABLE` - preserve immediate failure for every other HTTP, network, auth, and structurally similar error - keep the Mastra client general retry setting disabled - cover success, exhaustion, second-attempt 401 classification, and negative contracts ## Test Plan - `pnpm exec biome check --write src/lib/init/init-service-auth.ts test/lib/init/init-service-auth.test.ts` - `pnpm exec vitest run test/lib/init/init-service-auth.test.ts test/lib/init/wizard-runner.test.ts` - `pnpm run generate:schema && pnpm run typecheck`
1 parent abde588 commit b6e89b5

2 files changed

Lines changed: 122 additions & 4 deletions

File tree

src/lib/init/init-service-auth.ts

Lines changed: 39 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,4 @@
1+
import { MastraClientError } from "@mastra/client-js";
12
import { enrich401Detail } from "../api/infrastructure.js";
23
import { ApiError, HostScopeError } from "../errors.js";
34
import { isSaaSTrustOrigin, normalizeOrigin } from "../sentry-urls.js";
@@ -15,6 +16,10 @@ export const WORKFLOW_CREATE_RUN_ENDPOINT = `${WORKFLOW_ENDPOINT}/create-run`;
1516
export const WORKFLOW_RESUME_ASYNC_ENDPOINT = `${WORKFLOW_ENDPOINT}/resume-async`;
1617
export const WORKFLOW_START_ASYNC_ENDPOINT = `${WORKFLOW_ENDPOINT}/start-async`;
1718
const MASTRA_HTTP_401_RE = /HTTP error!\s*status:\s*401\b/i;
19+
const RETRYABLE_AUTH_FAILURE_CODES = new Set([
20+
"AUTH_UPSTREAM_TIMEOUT",
21+
"AUTH_UPSTREAM_UNAVAILABLE",
22+
]);
1823

1924
function classifyInitServiceAuthFailure(
2025
err: unknown,
@@ -32,14 +37,44 @@ function classifyInitServiceAuthFailure(
3237
);
3338
}
3439

40+
function isRetryableInitServiceAuthFailure(err: unknown): boolean {
41+
if (
42+
!(err instanceof MastraClientError) ||
43+
err.status !== 503 ||
44+
typeof err.body !== "object" ||
45+
err.body === null
46+
) {
47+
return false;
48+
}
49+
50+
const body = err.body as Record<string, unknown>;
51+
return (
52+
body.safeToRetry === true &&
53+
typeof body.code === "string" &&
54+
RETRYABLE_AUTH_FAILURE_CODES.has(body.code)
55+
);
56+
}
57+
3558
export async function withInitServiceAuthClassification<T>(
3659
operation: () => Promise<T>,
3760
endpoint: string
3861
): Promise<T> {
39-
try {
40-
return await operation();
41-
} catch (err) {
42-
throw classifyInitServiceAuthFailure(err, endpoint) ?? err;
62+
let retried = false;
63+
64+
while (true) {
65+
try {
66+
return await operation();
67+
} catch (err) {
68+
const classified = classifyInitServiceAuthFailure(err, endpoint);
69+
if (classified) {
70+
throw classified;
71+
}
72+
if (!retried && isRetryableInitServiceAuthFailure(err)) {
73+
retried = true;
74+
continue;
75+
}
76+
throw err;
77+
}
4378
}
4479
}
4580

Lines changed: 83 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,83 @@
1+
import { MastraClientError } from "@mastra/client-js";
2+
import { describe, expect, test, vi } from "vitest";
3+
import { ApiError } from "../../../src/lib/errors.js";
4+
import { withInitServiceAuthClassification } from "../../../src/lib/init/init-service-auth.js";
5+
6+
const ENDPOINT = "/api/workflows/sentry-wizard/resume-async";
7+
8+
function authFailure(
9+
code: string,
10+
safeToRetry = true,
11+
status = 503
12+
): MastraClientError {
13+
return new MastraClientError(status, "Service Unavailable", "auth failed", {
14+
code,
15+
safeToRetry,
16+
});
17+
}
18+
19+
describe("withInitServiceAuthClassification", () => {
20+
test.each([
21+
"AUTH_UPSTREAM_TIMEOUT",
22+
"AUTH_UPSTREAM_UNAVAILABLE",
23+
])("retries %s once", async (code) => {
24+
const failure = authFailure(code);
25+
const operation = vi
26+
.fn<() => Promise<string>>()
27+
.mockRejectedValueOnce(failure)
28+
.mockResolvedValueOnce("ok");
29+
30+
await expect(
31+
withInitServiceAuthClassification(operation, ENDPOINT)
32+
).resolves.toBe("ok");
33+
expect(operation).toHaveBeenCalledTimes(2);
34+
});
35+
36+
test("stops after one retry when the auth service remains unavailable", async () => {
37+
const failure = authFailure("AUTH_UPSTREAM_TIMEOUT");
38+
const operation = vi.fn<() => Promise<never>>().mockRejectedValue(failure);
39+
40+
await expect(
41+
withInitServiceAuthClassification(operation, ENDPOINT)
42+
).rejects.toBe(failure);
43+
expect(operation).toHaveBeenCalledTimes(2);
44+
});
45+
46+
test("classifies a 401 returned by the second attempt", async () => {
47+
const operation = vi
48+
.fn<() => Promise<never>>()
49+
.mockRejectedValueOnce(authFailure("AUTH_UPSTREAM_TIMEOUT"))
50+
.mockRejectedValueOnce(
51+
new Error(
52+
'HTTP error! status: 401 - {"error":"Unauthorized: invalid token"}'
53+
)
54+
);
55+
56+
const error = await withInitServiceAuthClassification(
57+
operation,
58+
ENDPOINT
59+
).catch((caught) => caught);
60+
61+
expect(error).toBeInstanceOf(ApiError);
62+
expect(error).toMatchObject({ status: 401, endpoint: ENDPOINT });
63+
expect(operation).toHaveBeenCalledTimes(2);
64+
});
65+
66+
test.each([
67+
authFailure("AUTH_UPSTREAM_RATE_LIMITED", false),
68+
authFailure("UNKNOWN_CODE"),
69+
authFailure("AUTH_UPSTREAM_TIMEOUT", true, 502),
70+
new TypeError("fetch failed"),
71+
Object.assign(new Error("auth failed"), {
72+
status: 503,
73+
body: { code: "AUTH_UPSTREAM_TIMEOUT", safeToRetry: true },
74+
}),
75+
])("does not retry an unauthorized failure contract", async (failure) => {
76+
const operation = vi.fn<() => Promise<never>>().mockRejectedValue(failure);
77+
78+
await expect(
79+
withInitServiceAuthClassification(operation, ENDPOINT)
80+
).rejects.toBe(failure);
81+
expect(operation).toHaveBeenCalledTimes(1);
82+
});
83+
});

0 commit comments

Comments
 (0)