Skip to content

Transient connect timeout on GET /user (or repo metadata) permanently breaks PR overview until reload: rejected promises are cached (setCurrentUser / getMetadata) #8899

Description

Summary

A transient network error (e.g. an Undici/Octokit UND_ERR_CONNECT_TIMEOUT on GET /user) that occurs once during authentication/current-user or repository-metadata initialization becomes a permanent failure until the extension host (or window) is reloaded, even after connectivity is restored. This is because several code paths cache the rejected promise itself as the "result", and every later caller reuses that already-rejected promise instead of retrying.

This was observed while opening a PR overview: the underlying failing request was GET https://api.github.com/user (from users.getAuthenticated), which failed once with a nested UND_ERR_CONNECT_TIMEOUT. After that single failure, connectivity was fine, but every subsequent attempt to open a PR overview kept failing with the generic message Error updating pull request description: ... until the window was reloaded. Manually replacing the cached currentUser/isEmu promises and retrying (without reload) fixed it immediately, confirming the promises — not the network — were the persistent problem.

I'm not claiming to know what originally caused the one-off connect timeout (that's environment/network dependent); the bug being reported is that the extension has no recovery path once such a transient rejection is cached.

Root cause 1 — CredentialStore.setCurrentUser caches a rejected getUser promise forever

private setCurrentUser(github: GitHub): void {
const getUser: ReturnType<typeof github.octokit.api.users.getAuthenticated> = new Promise((resolve, reject) => {
Logger.debug('Getting current user', CredentialStore.ID);
github.octokit.call(github.octokit.api.users.getAuthenticated, {}).then(result => {
Logger.debug(`Got current user ${result.data.login}`, CredentialStore.ID);
resolve(result);
}).catch(e => {
Logger.error(`Failed to get current user: ${e}, ${e.message}`, CredentialStore.ID);
reject(e);
});
});
github.currentUser = getUser.then(result => convertRESTUserToAccount(result.data));
github.isEmu = getUser.then(result => result.data.plan?.name === 'emu_user');
}

private setCurrentUser(github: GitHub): void {
    const getUser: ReturnType<typeof github.octokit.api.users.getAuthenticated> = new Promise((resolve, reject) => {
        Logger.debug('Getting current user', CredentialStore.ID);
        github.octokit.call(github.octokit.api.users.getAuthenticated, {}).then(result => {
            Logger.debug(`Got current user ${result.data.login}`, CredentialStore.ID);
            resolve(result);
        }).catch(e => {
            Logger.error(`Failed to get current user: ${e}, ${e.message}`, CredentialStore.ID);
            reject(e);
        });
    });
    github.currentUser = getUser.then(result => convertRESTUserToAccount(result.data));
    github.isEmu = getUser.then(result => result.data.plan?.name === 'emu_user');
}

setCurrentUser is called exactly once, from createHub, when a GitHub hub is created:

const github: GitHub = {
octokit: new LoggingOctokit(octokit, rateLogger),
graphql: new LoggingApolloClient(graphql, rateLogger),
};
this.setCurrentUser(github);
return github;

const github: GitHub = {
    octokit: new LoggingOctokit(octokit, rateLogger),
    graphql: new LoggingApolloClient(graphql, rateLogger),
};
this.setCurrentUser(github);
return github;

github.currentUser and github.isEmu are derived (.then(...)) from the single getUser promise, so if the one underlying GET /user request rejects (any reason — here a transient UND_ERR_CONNECT_TIMEOUT), both github.currentUser and github.isEmu become permanently rejected promises for the lifetime of that GitHub hub instance.

CredentialStore.getCurrentUser / getIsEmu simply return the same cached (possibly rejected) promises every time, with no re-fetch or retry:

public async getIsEmu(authProviderId: AuthProvider): Promise<boolean> {
const github = this.getHub(authProviderId);
return !!(await github?.isEmu);
}
public getCurrentUser(authProviderId: AuthProvider): Promise<IAccount> {
const github = this.getHub(authProviderId);
const octokit = github?.octokit;
return (octokit && github?.currentUser)!;
}

public async getIsEmu(authProviderId: AuthProvider): Promise<boolean> {
    const github = this.getHub(authProviderId);
    return !!(await github?.isEmu);
}

public getCurrentUser(authProviderId: AuthProvider): Promise<IAccount> {
    const github = this.getHub(authProviderId);
    const octokit = github?.octokit;
    return (octokit && github?.currentUser)!;
}

FolderRepositoryManager.getCurrentUser delegates straight to CredentialStore.getCurrentUser, so every later consumer (PR overview, issue overview, comment controllers, assignment quick-picks, etc.) keeps awaiting and re-rejecting on the same stale promise:

https://github.com/microsoft/vscode-pull-request-github/blob/849821a34981608ca9705439e458ffe5527fe480/src/github/folderRepositoryManager.ts (see getCurrentUser)

Root cause 2 — GitHubRepository.getMetadata has the same "cache the rejection" pattern

async getMetadata(): Promise<IMetadata> {
if (this._metadata) {
const metadata = await this._metadata;
Logger.debug(`Using cached metadata ${metadata.owner?.login}/${metadata.name}`, this.id);
return metadata;
}
Logger.debug(`Fetch metadata - enter`, this.id);
const { remote } = await this.ensure();
this._metadata = this.getMetadataForRepo(remote.owner, remote.repositoryName).catch(e => {
if ((getErrorCode(e) === '404') && !isSamlError(e) && !this._isInaccessible) {
this._isInaccessible = true;
Logger.warn(`Repository ${remote.owner}/${remote.repositoryName} from remote ${remote.remoteName} in workspace folder ${this.rootUri.fsPath} returned HTTP 404 and will be skipped for this session.`, this.id);
}
throw e;
});
Logger.debug(`Fetch metadata ${remote.owner}/${remote.repositoryName} - done`, this.id);
return this._metadata;
}

async getMetadata(): Promise<IMetadata> {
    if (this._metadata) {
        const metadata = await this._metadata;
        Logger.debug(`Using cached metadata ${metadata.owner?.login}/${metadata.name}`, this.id);
        return metadata;
    }

    Logger.debug(`Fetch metadata - enter`, this.id);
    const { remote } = await this.ensure();
    this._metadata = this.getMetadataForRepo(remote.owner, remote.repositoryName).catch(e => {
        if ((getErrorCode(e) === '404') && !isSamlError(e) && !this._isInaccessible) {
            this._isInaccessible = true;
            Logger.warn(`Repository ${remote.owner}/${remote.repositoryName} from remote ${remote.remoteName} in workspace folder ${this.rootUri.fsPath} returned HTTP 404 and will be skipped for this session.`, this.id);
        }
        throw e;
    });
    Logger.debug(`Fetch metadata ${remote.owner}/${remote.repositoryName} - done`, this.id);
    return this._metadata;
}

this._metadata (declared at

protected _metadata: Promise<IMetadata> | undefined;
) is assigned the promise before it settles, and the if (this._metadata) guard is a truthiness check on the field, not on whether it resolved. If the underlying repos.get call rejects once (same class of transient network error), this._metadata permanently holds a rejected promise and every future getMetadata() call re-awaits (and rethrows from) that same rejection — there is no retry and no way to clear the field.

This directly breaks the two main consumers, which each call getMetadata() and fail every time afterwards:

  • getDefaultBranch()
    async getDefaultBranch(): Promise<string> {
    const overrideSetting = getOverrideBranch();
    if (overrideSetting) {
    return overrideSetting;
    }
    try {
    const data = await this.getMetadata();
    return data.default_branch;
    } catch (e) {
    if (!this._isInaccessible) {
    Logger.warn(`Fetching default branch for ${this.remote.owner}/${this.remote.repositoryName} in workspace folder ${this.rootUri.fsPath} failed: ${e}`, this.id);
    }
    }
    return 'master';
    }
  • getRepoAccessAndMergeMethods()
    private _repoAccessAndMergeMethods: RepoAccessAndMergeMethods | undefined;
    async getRepoAccessAndMergeMethods(refetch: boolean = false): Promise<RepoAccessAndMergeMethods> {
    try {
    if (!this._repoAccessAndMergeMethods || refetch) {
    Logger.debug(`Fetch repo permissions and available merge methods - enter`, this.id);
    const data = await this.getMetadata();
    Logger.debug(`Fetch repo permissions and available merge methods - done`, this.id);
    const hasWritePermission = data.permissions?.push ?? false;
    this._repoAccessAndMergeMethods = {
    // Users with push access to repo have rights to merge/close PRs,
    // edit title/description, assign reviewers/labels etc.
    hasWritePermission,
    mergeMethodsAvailability: {
    merge: data.allow_merge_commit ?? false,
    squash: data.allow_squash_merge ?? false,
    rebase: data.allow_rebase_merge ?? false,
    },
    viewerCanAutoMerge: (data.allow_auto_merge && hasWritePermission) ?? false
    };
    }
    return this._repoAccessAndMergeMethods;
    } catch (e) {
    Logger.warn(`GitHubRepository> Fetching repo permissions and available merge methods failed: ${e}`);
    }

Both catch the error locally (falling back to 'master', or logging a warning), but neither resets this._metadata, so the cached rejection keeps being reused on every subsequent call for the lifetime of the GitHubRepository instance.

Compounding issue — PullRequestOverview.updateItem masks which dependency failed

protected override async updateItem(pullRequestModel: PullRequestModel): Promise<void> {
if (this._updatingPromise) {
Logger.error('Already updating pull request webview', PullRequestOverviewPanel.ID);
return;
}
this._item = pullRequestModel;
try {
const updatingPromise = Promise.all([
this._folderRepositoryManager.resolvePullRequest(
pullRequestModel.remote.owner,
pullRequestModel.remote.repositoryName,
pullRequestModel.number,
),
pullRequestModel.getTimelineEvents(),
this._folderRepositoryManager.getPullRequestRepositoryDefaultBranch(pullRequestModel),
pullRequestModel.getStatusChecks(),
pullRequestModel.getReviewRequests(),
this._folderRepositoryManager.getPullRequestRepositoryAccessAndMergeMethods(pullRequestModel),
this._folderRepositoryManager.getBranchNameForPullRequest(pullRequestModel),
this._folderRepositoryManager.getCurrentUser(pullRequestModel.githubRepository),
pullRequestModel.canEdit(),
this._folderRepositoryManager.getOrgTeamsCount(pullRequestModel.githubRepository),
this._folderRepositoryManager.mergeQueueMethodForBranch(pullRequestModel.base.ref, pullRequestModel.remote.owner, pullRequestModel.remote.repositoryName),
this._folderRepositoryManager.isHeadUpToDateWithBase(pullRequestModel),
pullRequestModel.getMergeability(),
this._folderRepositoryManager.getPreferredEmail(pullRequestModel),
pullRequestModel.getCoAuthors(),
pullRequestModel.validateDraftMode(),
this._folderRepositoryManager.getAssignableUsers()
]);
const clearingPromise = updatingPromise.finally(() => {
if (this._updatingPromise === clearingPromise) {
this._updatingPromise = undefined;
}
});
this._updatingPromise = clearingPromise;
const [
pullRequest,
timelineEvents,
defaultBranch,
status,
requestedReviewers,
repositoryAccess,
branchInfo,
currentUser,
viewerCanEdit,
orgTeamsCount,
mergeQueueMethod,
isBranchUpToDateWithBase,
mergeability,
emailForCommit,
coAuthors,
hasReviewDraft,
assignableUsers
] = await updatingPromise;
if (!pullRequest) {
throw new Error(
`Fail to resolve Pull Request #${pullRequestModel.number} in ${pullRequestModel.remote.owner}/${pullRequestModel.remote.repositoryName}`,
);
}
this._item = pullRequest;
this.registerPrListeners();
this._repositoryDefaultBranch = defaultBranch!;
this._teamsCount = orgTeamsCount;
this._assignableUsers = assignableUsers;
this.setPanelTitle(this.buildPanelTitle(pullRequestModel.number, pullRequestModel.title));
const isCurrentlyCheckedOut = pullRequestModel.equals(this._folderRepositoryManager.activePullRequest);
const mergeMethodsAvailability = repositoryAccess!.mergeMethodsAvailability;
const defaultMergeMethod = getDefaultMergeMethod(mergeMethodsAvailability);
this._existingReviewers = parseReviewers(requestedReviewers!, timelineEvents, pullRequest.author);
const isUpdateBranchWithGitHubEnabled: boolean = this.isUpdateBranchWithGitHubEnabled();
const reviewState = this.getCurrentUserReviewState(this._existingReviewers, currentUser);
Logger.debug('pr.initialize', PullRequestOverviewPanel.ID);
const users = this._assignableUsers[pullRequestModel.remote.remoteName] ?? [];
const copilotUser = users.find(user => COPILOT_ACCOUNTS[user.login]);
const isCopilotAlreadyReviewer = this._existingReviewers.some(reviewer => !isITeam(reviewer.reviewer) && reviewer.reviewer.login === COPILOT_REVIEWER);
const baseContext = await this.getInitializeContext(currentUser, pullRequest, timelineEvents ?? [], repositoryAccess, viewerCanEdit, users);
this.preLoadInfoNotRequiredForOverview(pullRequest);
const postDoneAction = vscode.workspace.getConfiguration(PR_SETTINGS_NAMESPACE).get<string>(POST_DONE, CHECKOUT_DEFAULT_BRANCH);
const doneCheckoutBranch = postDoneAction.startsWith(CHECKOUT_PULL_REQUEST_BASE_BRANCH)
? pullRequest.base.ref
: defaultBranch;
const context: Partial<PullRequest> = {
...baseContext,
canRequestCopilotReview: copilotUser !== undefined && !isCopilotAlreadyReviewer,
isCurrentlyCheckedOut: isCurrentlyCheckedOut,
isRemoteBaseDeleted: pullRequest.isRemoteBaseDeleted,
base: `${pullRequest.base.owner}/${pullRequest.remote.repositoryName}:${pullRequest.base.ref}`,
isRemoteHeadDeleted: pullRequest.isRemoteHeadDeleted,
isLocalHeadDeleted: !branchInfo,
head: pullRequest.head ? `${pullRequest.head.owner}/${pullRequest.remote.repositoryName}:${pullRequest.head.ref}` : '',
repositoryDefaultBranch: defaultBranch,
doneCheckoutBranch: doneCheckoutBranch,
status: status[0],
reviewRequirement: status[1],
canUpdateBranch: pullRequest.item.viewerCanUpdate && !isBranchUpToDateWithBase && isUpdateBranchWithGitHubEnabled,
mergeable: mergeability.mergeability,
reviewers: this._existingReviewers,
isDraft: pullRequest.isDraft,
mergeMethodsAvailability,
defaultMergeMethod,
hasReviewDraft,
autoMerge: pullRequest.autoMerge,
allowAutoMerge: pullRequest.allowAutoMerge,
autoMergeMethod: pullRequest.autoMergeMethod,
mergeQueueMethod,
mergeQueueEntry: pullRequest.mergeQueueEntry,
mergeCommitMeta: pullRequest.mergeCommitMeta,
squashCommitMeta: pullRequest.squashCommitMeta,
isIssue: false,
emailForCommit,
currentUserReviewState: reviewState,
revertable: pullRequest.state === GithubItemStateEnum.Merged,
isCopilotOnMyBehalf: await isCopilotOnMyBehalf(pullRequest, currentUser, coAuthors),
generateDescriptionTitle: this.getGenerateDescriptionTitle(),
attestationCommitsEnabled: isAttestationCommitsEnabled(),
closingIssues: await (async () => {
const enterpriseUri = pullRequest.remote.isEnterprise ? getEnterpriseUri() : undefined;
const issueOrUrlExpression = getIssueOrURLExpression(enterpriseUri);
return Promise.all((pullRequest.closingIssues ?? []).map(async issue => {
const parsed = parseIssueExpressionOutput(issue.url.match(issueOrUrlExpression));
const owner = parsed?.owner ?? pullRequest.remote.owner;
const repo = parsed?.name ?? pullRequest.remote.repositoryName;
const webviewUri = await toOpenIssueWebviewUri({ owner, repo, issueNumber: issue.number });
return { ...issue, url: webviewUri.toString() };
}));
})(),
};
this._postMessage({
command: 'pr.initialize',
pullrequest: context
});
if (pullRequest.isResolved()) {
this._folderRepositoryManager.checkBranchUpToDate(pullRequest, true);
}
} catch (e) {
vscode.window.showErrorMessage(`Error updating pull request description: ${formatError(e)}`);
}
}

updateItem awaits a single Promise.all([...17 entries...]) (lines 346–368), including this._folderRepositoryManager.getCurrentUser(pullRequestModel.githubRepository) (line 358) and this._folderRepositoryManager.getPullRequestRepositoryDefaultBranch(pullRequestModel) / getPullRequestRepositoryAccessAndMergeMethods(pullRequestModel) (lines 353, 356, which internally call getMetadata()), all wrapped in one try { ... } catch (e) { vscode.window.showErrorMessage(...) } block:

} catch (e) {
    vscode.window.showErrorMessage(`Error updating pull request description: ${formatError(e)}`);
}

This generic message (line 485) is shown regardless of which of the 17 concurrent operations actually failed, so the surfaced error looks like a PR-description-specific problem when the true cause is the unrelated, permanently-cached currentUser (or _metadata) rejection from root causes 1/2.

Verified repro

  1. Have the extension hit a transient connect timeout on GET https://api.github.com/user during hub creation (this call happens once per authenticated GitHub hub, via CredentialStore.createHubsetCurrentUser), or on repos.get during GitHubRepository.getMetadata(). In our case, the caught error's underlying cause was UND_ERR_CONNECT_TIMEOUT nested under the Octokit request error for GET /user.
  2. Connectivity recovers immediately afterward (verified other requests to the same host succeed).
  3. Open (or re-open) a pull request overview. updateItem fails every time with Error updating pull request description: ..., because getCurrentUser/getMetadata keep returning the same cached rejected promise from step 1.
  4. Reloading the window (which recreates CredentialStore/GitHubRepository instances and thus the cached promises) fixes it — confirming the cache, not the network, is the persistent problem.
  5. As a workaround we confirmed in-session: manually replacing the GitHub hub's currentUser/isEmu fields with freshly retried promises (without reloading) also immediately fixed subsequent PR overview loads.

Suggested fixes

  1. CredentialStore / setCurrentUser: make the current-user fetch single-flight but rejection-safe — e.g. store the in-flight promise, and on rejection clear the stored reference only if it still points at the failing promise (to avoid a race with a newer, concurrently-started fetch), so the next caller triggers a fresh getAuthenticated request instead of re-awaiting the stale rejection. Centralize currentUser and isEmu so both are derived from one retryable/refreshable "get current user" operation rather than two independent .then() chains hanging off a single one-shot promise.
  2. GitHubRepository.getMetadata / _metadata: same pattern — only treat _metadata as a valid cache once it has resolved; on rejection, clear _metadata (guarding against clearing a newer in-flight fetch) so the next getDefaultBranch() / getRepoAccessAndMergeMethods() (or any other caller) triggers a real retry instead of reusing the cached failure.
  3. PullRequestOverview.updateItem: narrow or tag the error surfaced from the combined Promise.all (e.g. wrap each entry with context, or report e?.message/the failing operation name) so failures unrelated to the PR description (e.g. current-user or repo-metadata fetch failures) aren't reported as Error updating pull request description, which currently makes the actual root cause hard to diagnose from the visible error alone.

Environment

  • Repro is against the current main branch source, commit 849821a34981608ca9705439e458ffe5527fe480.
  • No local machine details, tokens, or private repository paths are included above; the affected endpoint is the public GET https://api.github.com/user call made by users.getAuthenticated.

Metadata

Metadata

Labels

No labels
No labels

Type

No type

Projects

No projects

Milestone

No milestone

Relationships

None yet

Development

No branches or pull requests

Issue actions