fix: cover streamed access errors without alerting on outages

This commit is contained in:
Thomas Durieux committed 2026-10-09 06:00:16 +00:00
1 parent 85b9a50d4d
commit 2b20abe0a5
5 files changed
+128 -45

No files matched your search

+13 -2
View File
@@ -1,3 +1,4 @@
import { notifyOwnerAccessProblem } from "./owner-notifications";
import { streamResponse } from "./response-stream";
import { join, basename, dirname } from "path";
import { Response } from "express";
@@ -355,7 +356,11 @@ export default class AnonymizedFile {
httpStatus: 413,
});
}
const content = await this.repository.source?.getFileContent(this);
const content = await this.repository.source?.getFileContent(this).catch(error => {
void notifyOwnerAccessProblem(this.repository.owner.id, error);
throw error;
});
content.on("error", error => { void notifyOwnerAccessProblem(this.repository.owner.id, error); });
const cacheWasReset = this.repository.model.isReseted;
if (cacheWasReset) {
await this.repository.markCachePresent();
@@ -378,7 +383,7 @@ export default class AnonymizedFile {
}
// use the streamer service
return got.stream(join(config.STREAMER_ENTRYPOINT, "api"), {
const content = got.stream(join(config.STREAMER_ENTRYPOINT, "api"), {
method: "POST",
headers: requestHeaders(),
json: {
@@ -395,6 +400,11 @@ export default class AnonymizedFile {
anonymizerOptions: anonymizer.opt,
},
});
content.on("error", err => {
const { error } = streamerErrorToAnonymous(err, { repoId: this.repository.repoId, filePath: this.anonymizedPath });
void notifyOwnerAccessProblem(this.repository.owner.id, error);
});
return content;
}
get filePath() {
@@ -472,6 +482,7 @@ export default class AnonymizedFile {
filePath: this.anonymizedPath,
}
);
void notifyOwnerAccessProblem(this.repository.owner.id, error);
error.value = this;
handleError(error, res);
if (res.headersSent && !res.writableEnded) res.destroy();
+6 -38
View File
@@ -1,3 +1,4 @@
import { isGitHubRateLimitError, OctokitRequestErrorLike } from "./github-rate-limit";
import { refreshLegacyToken } from "./legacy-token-refresh";
import { notifyOwnerAccessProblem } from "./owner-notifications";
import AnonymizedRepositoryModel from "./model/anonymizedRepositories/anonymizedRepositories.model";
@@ -17,35 +18,7 @@ import { measureStage } from "./request-monitoring";
const logger = createLogger("github");
// Octokit RequestError shape (subset we care about for rate-limit detection).
interface OctokitRequestErrorLike {
status?: number;
message?: string;
response?: {
headers?: Record<string, string | undefined>;
};
}
/**
* Detect GitHub rate-limit / abuse responses (primary 5k/h or undocumented
* "secondary" limits) and rewrap them as a translatable AnonymousError so
* the UI can show a friendly message instead of a raw HttpError stack. The
* GitHub `x-github-request-id` header is preserved in `detail` so users can
* cite it if they reach out to GitHub Support.
*/
export function isGitHubRateLimitError(err: unknown): boolean {
if (!err || typeof err !== "object") return false;
const e = err as OctokitRequestErrorLike;
const msg = (e.message ?? "").toLowerCase();
// Primary limits return 403 with "x-ratelimit-remaining: 0"; secondary
// limits return 403 (sometimes 429) with "secondary rate limit" in the
// body. Match on either signal so we catch both.
const status = e.status ?? 0;
if (status !== 403 && status !== 429) return false;
if (msg.includes("rate limit") || msg.includes("abuse")) return true;
const remaining = e.response?.headers?.["x-ratelimit-remaining"];
return remaining === "0";
}
export { isGitHubRateLimitError } from "./github-rate-limit";
function rateLimitDetail(err: OctokitRequestErrorLike): string {
const headers = err.response?.headers ?? {};
@@ -299,15 +272,10 @@ export async function checkToken(token: string, ownerId?: string) {
else await oct.users.getAuthenticated();
return true;
} catch (err) {
if (
err instanceof AnonymousError &&
err.message === "github_rate_limit_exceeded"
) {
throw err;
}
if (ownerId && (err as { status?: number }).status === 401) {
void notifyOwnerAccessProblem(ownerId, "token_expired");
}
// Only a confirmed invalid credential permits fallback. Network failures,
// permission failures and rate limits must retain their original meaning.
if ((err as { status?: number })?.status !== 401) throw err;
if (ownerId) void notifyOwnerAccessProblem(ownerId, "token_expired");
return false;
}
}
+26
View File
@@ -0,0 +1,26 @@
// Octokit RequestError shape (subset we care about for rate-limit detection).
export interface OctokitRequestErrorLike {
status?: number;
httpStatus?: number;
message?: string;
response?: {
statusCode?: number;
headers?: Record<string, string | undefined>;
};
}
/** Detect GitHub primary and secondary rate limits in API and stream errors. */
export function isGitHubRateLimitError(err: unknown): boolean {
if (!err || typeof err !== "object") return false;
const e = err as OctokitRequestErrorLike;
const msg = (e.message ?? "").toLowerCase();
// Primary limits return 403 with "x-ratelimit-remaining: 0"; secondary
// limits return 403 (sometimes 429) with "secondary rate limit" in the
// body. Match on either signal so we catch both.
const status = e.status ?? e.response?.statusCode ?? e.httpStatus ?? 0;
if (status !== 403 && status !== 429) return false;
if (msg.includes("rate limit") || msg.includes("abuse")) return true;
const remaining = e.response?.headers?.["x-ratelimit-remaining"];
return remaining === "0";
}
+7 -4
View File
@@ -1,3 +1,4 @@
import { isGitHubRateLimitError } from "./github-rate-limit";
import config from "../config";
import UserModel from "./model/users/users.model";
import { createLogger } from "./logger";
@@ -7,7 +8,7 @@ const DAY = 24 * 60 * 60 * 1000;
const accessErrors = new Set([
"token_expired", "github_oauth_required", "github_app_reconnect_required",
"github_app_access_required", "repo_not_found", "repository_not_found",
"pull_request_not_found", "gist_not_found", "repo_not_accessible", "repo_access_limited", "repo_saml_enforcement",
"file_not_accessible", "file_not_found", "pull_request_not_found", "gist_not_found", "repo_not_accessible", "repo_access_limited", "repo_saml_enforcement",
]);
export function normalizeEmail(value: unknown): string | null {
@@ -26,14 +27,16 @@ export function notificationEmail(emails?: { email: string; default: boolean }[]
}
export function isAccessFailure(error: unknown): boolean {
if (isGitHubRateLimitError(error)) return false;
const code = typeof error === "string" ? error : error instanceof Error ? error.message : "";
const rawStatus = error instanceof Error ? (error as Error & { status?: number }).status : undefined;
if (!accessErrors.has(code) && ![401, 404].includes(rawStatus || 0)) return false;
if (!accessErrors.has(code) && ![401, 403, 404].includes(rawStatus || 0)) return false;
if (error instanceof Error) {
const upstream = (error as Error & { cause?: { status?: number; httpStatus?: number } }).cause;
const upstream = (error as Error & { cause?: { status?: number; httpStatus?: number; response?: { statusCode?: number } } }).cause;
// Some legacy paths wrap timeouts or 5xx responses as repo_not_found.
// Only a confirmed access failure should prompt the owner to reconnect.
if (upstream && ![401, 403, 404].includes(upstream.status || upstream.httpStatus || 0)) return false;
if (isGitHubRateLimitError(upstream)) return false;
if (upstream && ![401, 403, 404].includes(upstream.status || upstream.response?.statusCode || upstream.httpStatus || 0)) return false;
const status = (error as Error & { httpStatus?: number }).httpStatus;
if (status && (status === 429 || status >= 500)) return false;
}