microsoft / microsoft/vscode-pull-request-github

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

Open
#8,899 0 comments 1 reaction 2 assignees View on GitHub

@alexr00 is already working on this.

Since Aug 25, 2026.

  • #8904 by @copilot-swe-agent — open
Dominant language
TypeScript
Stars
2.6k
Forks
796
Avg merge
1d 4h
Merged PRs (30d)
46

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

https://github.com/microsoft/vscode-pull-request-github/blob/849821a34981608ca9705439e458ffe5527fe480/src/github/credentials.ts#L569-L582

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:

https://github.com/microsoft/vscode-pull-request-github/blob/849821a34981608ca9705439e458ffe5527fe480/src/github/credentials.ts#L653-L658

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:

https://github.com/microsoft/vscode-pull-request-github/blob/849821a34981608ca9705439e458ffe5527fe480/src/github/credentials.ts#L558-L567

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

https://github.com/microsoft/vscode-pull-request-github/blob/849821a34981608ca9705439e458ffe5527fe480/src/github/githubRepository.ts#L431-L449

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 https://github.com/microsoft/vscode-pull-request-github/blob/849821a34981608ca9705439e458ffe5527fe480/src/github/githubRepository.ts#L184) 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:

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

https://github.com/microsoft/vscode-pull-request-github/blob/849821a34981608ca9705439e458ffe5527fe480/src/github/pullRequestOverview.ts#L338-L487

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.

Contributor guide

No contributing guide indexed for this repository

First steps

  1. Read the whole issue, then the project's contributing guide.
  2. Comment on the issue to say you are picking it up — it saves two people doing the same work.
  3. Fork the repository and make your change on a branch.
  4. Open a pull request that references the issue number.

Assessment

This issue has not been assessed yet.

Get new issues in your inbox

A short digest of beginner-friendly GitHub issues.