fix(azure-devops): retry with -preview api-version and keep project-level Git base for on-prem Server (STA-3494) (#12832)

* fix(azure-devops): retry with -preview api-version and keep project-level Git base for on-prem Server (STA-3494)

Azure DevOps Server rejects api-version=7.1 with 400
VssInvalidPreviewVersionException unless the -preview suffix is supplied,
so auth and every Git endpoint failed. Retry once with -preview on that
rejection and remember the requirement per origin. Also stop letting a
same-origin ORCA_AZURE_DEVOPS_API_BASE_URL (collection-level, needed only
for the connectionData auth probe) override the project-level base derived
from the remote for Git endpoints; cross-origin (proxy) overrides keep
working.

* fix(azure-devops): constrain preview retry and base override
This commit is contained in:
Brennan Benson
2026-08-06 13:42:37 -07:00
committed by GitHub
parent 18b27ac11c
commit b81450f085
5 changed files with 375 additions and 22 deletions
@@ -0,0 +1,134 @@
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import {
_resetAzureDevOpsPreviewApiVersionCache,
requestAzureDevOpsJson,
requestAzureDevOpsJsonAtBase
} from './azure-devops-api-request'
import type { AzureDevOpsRepoRef } from './repository-ref'
const OLD_ENV = process.env
const OLD_FETCH = globalThis.fetch
const SERVER_BASE = 'https://ado.example.com:8443/tfs/MyCollection'
function previewRejection(): Response {
return new Response(
JSON.stringify({
message:
'The requested version "7.1" of the resource is under preview. The -preview flag must be supplied in the api-version for such requests. For example: "7.1-preview"',
typeKey: 'VssInvalidPreviewVersionException'
}),
{ status: 400, headers: { 'Content-Type': 'application/json' } }
)
}
function serverRepoRef(): AzureDevOpsRepoRef {
return {
host: 'ado.example.com',
organization: null,
project: 'MyProject',
repository: 'my-repo',
apiBaseUrl: `${SERVER_BASE}/MyProject`,
webBaseUrl: `${SERVER_BASE}/MyProject/_git/my-repo`
}
}
describe('Azure DevOps API request (STA-3494)', () => {
beforeEach(() => {
process.env = { ...OLD_ENV, ORCA_AZURE_DEVOPS_TOKEN: 'pat-token' }
delete process.env.ORCA_AZURE_DEVOPS_API_BASE_URL
_resetAzureDevOpsPreviewApiVersionCache()
})
afterEach(() => {
process.env = OLD_ENV
globalThis.fetch = OLD_FETCH
})
it('retries with -preview when Azure DevOps Server rejects the api-version', async () => {
const versions: (string | null)[] = []
globalThis.fetch = vi.fn(async (input: string | URL | Request) => {
const url = new URL(String(input))
versions.push(url.searchParams.get('api-version'))
if (!url.searchParams.get('api-version')?.endsWith('-preview')) {
return previewRejection()
}
return Response.json({ authenticatedUser: { providerDisplayName: 'Server User' } })
}) as never
await expect(
requestAzureDevOpsJsonAtBase(SERVER_BASE, '/_apis/connectionData')
).resolves.toEqual({ authenticatedUser: { providerDisplayName: 'Server User' } })
expect(versions).toEqual(['7.1', '7.1-preview'])
})
it('remembers the -preview requirement per origin after the first rejection', async () => {
const versions: (string | null)[] = []
globalThis.fetch = vi.fn(async (input: string | URL | Request) => {
const url = new URL(String(input))
versions.push(url.searchParams.get('api-version'))
if (!url.searchParams.get('api-version')?.endsWith('-preview')) {
return previewRejection()
}
return Response.json({ ok: true })
}) as never
const base = 'https://ado-sticky.example.com/tfs/MyCollection'
await requestAzureDevOpsJsonAtBase(base, '/_apis/connectionData')
await requestAzureDevOpsJsonAtBase(base, '/_apis/connectionData')
// First request learns the suffix; the second must not repeat the 400 round trip.
expect(versions).toEqual(['7.1', '7.1-preview', '7.1-preview'])
})
it('does not retry a 400 that is not a preview-version rejection', async () => {
const fetchMock = vi.fn(async () =>
Response.json({ message: 'A project name is required.' }, { status: 400 })
)
globalThis.fetch = fetchMock as never
await expect(
requestAzureDevOpsJsonAtBase(
'https://ado-other.example.com/tfs/Coll',
'/_apis/connectionData'
)
).resolves.toBeNull()
expect(fetchMock).toHaveBeenCalledTimes(1)
})
it('uses the remote-derived project base for Git endpoints when the configured base shares its origin', async () => {
process.env.ORCA_AZURE_DEVOPS_API_BASE_URL = SERVER_BASE
const paths: string[] = []
globalThis.fetch = vi.fn(async (input: string | URL | Request) => {
paths.push(new URL(String(input)).pathname)
return Response.json({ id: 'repo-guid' })
}) as never
await requestAzureDevOpsJson(serverRepoRef(), '/_apis/git/repositories/my-repo')
// Collection-level env base must not strip the project segment Git endpoints need.
expect(paths).toEqual(['/tfs/MyCollection/MyProject/_apis/git/repositories/my-repo'])
})
it('keeps a cross-origin configured base URL as an override for Git endpoints', async () => {
process.env.ORCA_AZURE_DEVOPS_API_BASE_URL = 'http://127.0.0.1:8123/acme/Project'
const origins: string[] = []
globalThis.fetch = vi.fn(async (input: string | URL | Request) => {
origins.push(new URL(String(input)).origin)
return Response.json({ id: 'repo-guid' })
}) as never
await requestAzureDevOpsJson(serverRepoRef(), '/_apis/git/repositories/my-repo')
expect(origins).toEqual(['http://127.0.0.1:8123'])
})
it('keeps a same-origin non-ancestor base URL as a Git endpoint override', async () => {
process.env.ORCA_AZURE_DEVOPS_API_BASE_URL = 'https://ado.example.com:8443/rewrite/MyProject'
const paths: string[] = []
globalThis.fetch = vi.fn(async (input: string | URL | Request) => {
paths.push(new URL(String(input)).pathname)
return Response.json({ id: 'repo-guid' })
}) as never
await requestAzureDevOpsJson(serverRepoRef(), '/_apis/git/repositories/my-repo')
expect(paths).toEqual(['/rewrite/MyProject/_apis/git/repositories/my-repo'])
})
})
@@ -3,6 +3,42 @@ import type { AzureDevOpsRepoRef } from './repository-ref'
import { cancelUnreadResponseBody } from '../lib/unread-response-body'
const REQUEST_TIMEOUT_MS = 5000
const DEFAULT_API_VERSION = '7.1'
// Why (STA-3494): on-prem Azure DevOps Server rejects versioned requests without
// the -preview suffix; remember which origins need it after the first rejection.
const previewApiVersionOrigins = new Set<string>()
/** @internal - exposed for tests only */
export function _resetAzureDevOpsPreviewApiVersionCache(): void {
previewApiVersionOrigins.clear()
}
export function markAzureDevOpsPreviewApiVersionOrigin(origin: string): void {
previewApiVersionOrigins.add(origin)
}
export function azureDevOpsApiVersionForOrigin(
origin: string,
requested?: string | number
): string {
const version = String(requested ?? DEFAULT_API_VERSION)
return previewApiVersionOrigins.has(origin) && !version.endsWith('-preview')
? `${version}-preview`
: version
}
export function isAzureDevOpsPreviewVersionRejection(status: number | null, body: string): boolean {
if (status !== 400) {
return false
}
try {
const parsed = JSON.parse(body) as { typeKey?: unknown } | null
return parsed?.typeKey === 'VssInvalidPreviewVersionException'
} catch {
return false
}
}
type AzureDevOpsAuthConfig = {
apiBaseUrl: string | null
@@ -52,9 +88,30 @@ function authHeaders(config: AzureDevOpsAuthConfig): Record<string, string> {
return {}
}
function configuredApiBaseUrl(repo: AzureDevOpsRepoRef): string {
function isUrlPathAncestor(ancestor: string, descendant: string): boolean {
try {
const ancestorUrl = new URL(ancestor)
const descendantUrl = new URL(descendant)
const ancestorPath = ancestorUrl.pathname.replace(/\/+$/, '')
const descendantPath = descendantUrl.pathname.replace(/\/+$/, '')
return (
ancestorUrl.origin === descendantUrl.origin &&
(ancestorPath === descendantPath || descendantPath.startsWith(`${ancestorPath}/`))
)
} catch {
return false
}
}
export function resolveAzureDevOpsGitApiBaseUrl(repo: AzureDevOpsRepoRef): string {
const configured = getAzureDevOpsAuthConfig().apiBaseUrl
return configured ? normalizeAzureDevOpsApiBaseUrl(configured) : repo.apiBaseUrl
if (!configured) {
return repo.apiBaseUrl
}
const normalized = normalizeAzureDevOpsApiBaseUrl(configured)
// Why (STA-3494): a configured collection ancestor is the auth-probe URL;
// Git endpoints need the project-level base derived from the remote.
return isUrlPathAncestor(normalized, repo.apiBaseUrl) ? repo.apiBaseUrl : normalized
}
function apiUrl(
@@ -63,13 +120,32 @@ function apiUrl(
searchParams?: AzureDevOpsRequestOptions['searchParams']
): URL {
const url = new URL(`${baseUrl.replace(/\/+$/, '')}${path}`)
const params = { ...searchParams, 'api-version': searchParams?.['api-version'] ?? '7.1' }
const params = {
...searchParams,
'api-version': azureDevOpsApiVersionForOrigin(url.origin, searchParams?.['api-version'])
}
for (const [key, value] of Object.entries(params)) {
url.searchParams.set(key, String(value))
}
return url
}
// Reads the body only for a 400 on a non-preview request; consumed either way.
async function shouldRetryWithPreviewApiVersion(url: URL, response: Response): Promise<boolean> {
if (response.ok || response.status !== 400) {
return false
}
if (url.searchParams.get('api-version')?.endsWith('-preview')) {
return false
}
try {
const body = (await response.json()) as { typeKey?: string | null } | null
return body?.typeKey === 'VssInvalidPreviewVersionException'
} catch {
return false
}
}
export async function requestAzureDevOpsJsonAtBase<T>(
baseUrl: string,
path: string,
@@ -80,14 +156,21 @@ export async function requestAzureDevOpsJsonAtBase<T>(
throwOnFailure = false
): Promise<T | null> {
const config = getAzureDevOpsAuthConfig()
try {
const response = await fetch(apiUrl(baseUrl, path, options.searchParams), {
const doFetch = (url: URL): Promise<Response> =>
fetch(url, {
headers: {
Accept: 'application/json',
...authHeaders(config)
},
signal: AbortSignal.timeout(options.timeoutMs ?? REQUEST_TIMEOUT_MS)
})
try {
const url = apiUrl(baseUrl, path, options.searchParams)
let response = await doFetch(url)
if (await shouldRetryWithPreviewApiVersion(url, response)) {
markAzureDevOpsPreviewApiVersionOrigin(url.origin)
response = await doFetch(apiUrl(baseUrl, path, options.searchParams))
}
if (!response.ok) {
await cancelUnreadResponseBody(response)
if (throwOnFailure) {
@@ -110,5 +193,10 @@ export function requestAzureDevOpsJson<T>(
options: AzureDevOpsRequestOptions = {},
throwOnFailure = false
): Promise<T | null> {
return requestAzureDevOpsJsonAtBase(configuredApiBaseUrl(repo), path, options, throwOnFailure)
return requestAzureDevOpsJsonAtBase(
resolveAzureDevOpsGitApiBaseUrl(repo),
path,
options,
throwOnFailure
)
}
+31
View File
@@ -6,6 +6,7 @@ import {
getAzureDevOpsPullRequestForBranchOrThrow,
normalizeAzureDevOpsApiBaseUrl
} from './client'
import { _resetAzureDevOpsPreviewApiVersionCache } from './azure-devops-api-request'
import { _resetAzureDevOpsRepoRefCache } from './repository-ref'
import { __resetRepoDefaultBranchCacheForTests } from '../source-control/repo-default-branch'
@@ -39,6 +40,7 @@ describe('Azure DevOps client', () => {
process.env = { ...OLD_ENV, ORCA_AZURE_DEVOPS_TOKEN: 'pat-token' }
gitExecFileAsyncMock.mockReset()
_resetAzureDevOpsRepoRefCache()
_resetAzureDevOpsPreviewApiVersionCache()
__resetRepoDefaultBranchCacheForTests()
})
@@ -65,6 +67,35 @@ describe('Azure DevOps client', () => {
})
})
it('authenticates against an on-prem Server that requires -preview api-versions (STA-3494)', async () => {
process.env.ORCA_AZURE_DEVOPS_API_BASE_URL = 'https://ado.example.com:8443/tfs/MyCollection'
const versions: (string | null)[] = []
globalThis.fetch = vi.fn(async (input: string | URL | Request) => {
const url = new URL(String(input))
expect(url.pathname).toBe('/tfs/MyCollection/_apis/connectionData')
versions.push(url.searchParams.get('api-version'))
if (!url.searchParams.get('api-version')?.endsWith('-preview')) {
return new Response(
JSON.stringify({
message: 'The requested version "7.1" of the resource is under preview.',
typeKey: 'VssInvalidPreviewVersionException'
}),
{ status: 400, headers: { 'Content-Type': 'application/json' } }
)
}
return Response.json({ authenticatedUser: { providerDisplayName: 'Server User' } })
}) as never
await expect(getAzureDevOpsAuthStatus()).resolves.toEqual({
configured: true,
authenticated: true,
account: 'Server User',
baseUrl: 'https://ado.example.com:8443/tfs/MyCollection',
tokenConfigured: true
})
expect(versions).toEqual(['7.1', '7.1-preview'])
})
it('resolves a PR for a branch through repository, PR, and status REST calls', async () => {
gitExecFileAsyncMock.mockResolvedValue({
stdout: 'https://dev.azure.com/acme/Project/_git/repo\n'
@@ -3,6 +3,7 @@ import {
createAzureDevOpsPullRequest,
isAzureDevOpsReviewCreationAuthenticated
} from './pull-request-creation'
import { _resetAzureDevOpsPreviewApiVersionCache } from './azure-devops-api-request'
import { _resetAzureDevOpsRepoRefCache } from './repository-ref'
import { REMOTE_URL_PROBE_TIMEOUT_MS } from '../git/remote-url-probe'
@@ -37,6 +38,7 @@ describe('Azure DevOps pull request creation', () => {
stderr: ''
})
_resetAzureDevOpsRepoRefCache()
_resetAzureDevOpsPreviewApiVersionCache()
})
afterEach(() => {
@@ -98,6 +100,77 @@ describe('Azure DevOps pull request creation', () => {
expect(fetchMock).toHaveBeenCalledOnce()
})
it('retries PR creation with -preview when the Server rejects the api-version (STA-3494)', async () => {
gitExecFileAsyncMock.mockResolvedValue({
stdout: 'https://ado.example.com:8443/tfs/MyCollection/MyProject/_git/my-repo\n',
stderr: ''
})
const versions: (string | null)[] = []
const fetchMock = vi.fn(async (input: string | URL | Request) => {
const url = new URL(String(input))
expect(url.pathname).toBe(
'/tfs/MyCollection/MyProject/_apis/git/repositories/my-repo/pullRequests'
)
versions.push(url.searchParams.get('api-version'))
if (!url.searchParams.get('api-version')?.endsWith('-preview')) {
return new Response(
JSON.stringify({
message: 'The requested version "7.1" of the resource is under preview.',
typeKey: 'VssInvalidPreviewVersionException'
}),
{ status: 400, headers: { 'Content-Type': 'application/json' } }
)
}
return Response.json({
pullRequestId: 51,
title: 'Server create',
status: 'active',
creationDate: '2026-06-01T00:00:00Z',
_links: {
web: {
href: 'https://ado.example.com:8443/tfs/MyCollection/MyProject/_git/my-repo/pullrequest/51'
}
}
})
})
globalThis.fetch = fetchMock as never
await expect(
createAzureDevOpsPullRequest('/repo', {
provider: 'azure-devops',
base: 'main',
head: 'feature/server',
title: 'Server create',
body: 'Body'
})
).resolves.toEqual({
ok: true,
number: 51,
url: 'https://ado.example.com:8443/tfs/MyCollection/MyProject/_git/my-repo/pullrequest/51'
})
expect(versions).toEqual(['7.1', '7.1-preview'])
})
it('does not retry PR creation when only the error message names the preview exception', async () => {
const fetchMock = vi.fn(async () =>
Response.json(
{ message: 'Validation failed near VssInvalidPreviewVersionException' },
{ status: 400 }
)
)
globalThis.fetch = fetchMock as never
await expect(
createAzureDevOpsPullRequest('/repo', {
provider: 'azure-devops',
base: 'main',
head: 'feature/azure',
title: 'Do not retry'
})
).resolves.toMatchObject({ ok: false, code: 'validation' })
expect(fetchMock).toHaveBeenCalledOnce()
})
it('resolves Azure DevOps remotes through the SSH git provider', async () => {
const remoteGit = {
exec: vi.fn(async () => ({
+43 -16
View File
@@ -9,6 +9,12 @@ import {
requestHostedReviewJson
} from '../source-control/hosted-review-api-request'
import { readHostedPullRequestTemplate } from '../source-control/pull-request-template'
import {
azureDevOpsApiVersionForOrigin,
isAzureDevOpsPreviewVersionRejection,
markAzureDevOpsPreviewApiVersionOrigin,
resolveAzureDevOpsGitApiBaseUrl
} from './azure-devops-api-request'
import { getAzureDevOpsPullRequestForBranch } from './client'
import { mapAzureDevOpsPullRequest, type RawAzureDevOpsPullRequest } from './pull-request-mappers'
import { getAzureDevOpsRepoRef, type AzureDevOpsRepoRef } from './repository-ref'
@@ -16,7 +22,6 @@ import { getAzureDevOpsRepoRef, type AzureDevOpsRepoRef } from './repository-ref
const CREATE_REQUEST_TIMEOUT_MS = 60_000
type AzureDevOpsCreateAuthConfig = {
apiBaseUrl: string | null
pat: string | null
accessToken: string | null
username: string | null
@@ -27,16 +32,8 @@ function envValue(name: string): string | null {
return value.length > 0 ? value : null
}
function normalizeApiBaseUrl(value: string): string {
return value
.trim()
.replace(/\/+$/, '')
.replace(/\/_apis$/i, '')
}
function getAuthConfig(): AzureDevOpsCreateAuthConfig {
return {
apiBaseUrl: envValue('ORCA_AZURE_DEVOPS_API_BASE_URL'),
pat: envValue('ORCA_AZURE_DEVOPS_TOKEN') ?? envValue('ORCA_AZURE_DEVOPS_PAT'),
accessToken: envValue('ORCA_AZURE_DEVOPS_ACCESS_TOKEN'),
username: envValue('ORCA_AZURE_DEVOPS_USERNAME')
@@ -60,13 +57,43 @@ function authHeaders(config: AzureDevOpsCreateAuthConfig): Record<string, string
}
function apiUrl(repo: AzureDevOpsRepoRef, path: string): URL {
const config = getAuthConfig()
const baseUrl = config.apiBaseUrl ? normalizeApiBaseUrl(config.apiBaseUrl) : repo.apiBaseUrl
const baseUrl = resolveAzureDevOpsGitApiBaseUrl(repo)
const url = new URL(`${baseUrl.replace(/\/+$/, '')}${path}`)
url.searchParams.set('api-version', '7.1')
url.searchParams.set('api-version', azureDevOpsApiVersionForOrigin(url.origin))
return url
}
// Why (STA-3494): Azure DevOps Server 400s versioned requests without -preview;
// the rejection happens before the PR is created, so one retry is safe.
async function requestCreatePullRequest(
repo: AzureDevOpsRepoRef,
path: string,
init: Omit<RequestInit, 'signal'>
): Promise<RawAzureDevOpsPullRequest> {
const url = apiUrl(repo, path)
try {
return await requestHostedReviewJson<RawAzureDevOpsPullRequest>(
url,
init,
CREATE_REQUEST_TIMEOUT_MS
)
} catch (error) {
if (
url.searchParams.get('api-version')?.endsWith('-preview') ||
!(error instanceof HostedReviewApiRequestError) ||
!isAzureDevOpsPreviewVersionRejection(error.status, error.message)
) {
throw error
}
markAzureDevOpsPreviewApiVersionOrigin(url.origin)
return requestHostedReviewJson<RawAzureDevOpsPullRequest>(
apiUrl(repo, path),
init,
CREATE_REQUEST_TIMEOUT_MS
)
}
}
function encodePathSegment(value: string): string {
return encodeURIComponent(value)
}
@@ -192,8 +219,9 @@ export async function createAzureDevOpsPullRequest(
}
try {
const raw = await requestHostedReviewJson<RawAzureDevOpsPullRequest>(
apiUrl(repo, `/_apis/git/repositories/${encodePathSegment(repo.repository)}/pullRequests`),
const raw = await requestCreatePullRequest(
repo,
`/_apis/git/repositories/${encodePathSegment(repo.repository)}/pullRequests`,
{
method: 'POST',
headers: {
@@ -202,8 +230,7 @@ export async function createAzureDevOpsPullRequest(
...authHeaders(getAuthConfig())
},
body: JSON.stringify(requestBody)
},
CREATE_REQUEST_TIMEOUT_MS
}
)
const created = mapAzureDevOpsPullRequest(raw, 'neutral', repo.webBaseUrl)
if (created) {