Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
83 changes: 25 additions & 58 deletions cs/src/Management/TunnelManagementClient.cs
Original file line number Diff line number Diff line change
Expand Up @@ -279,8 +279,8 @@ private static void ValidateHttpHandler(HttpMessageHandler httpHandler)
{
throw new NotSupportedException(
$"Unsupported HTTP handler type: {httpHandler?.GetType().Name}. " +
"HTTP handler chain must consist of 0 or more DelegatingHandlers " +
"ending with a HttpClientHandler.");
$"HTTP handler chain must consist of 0 or more {nameof(DelegatingHandler)}s " +
$"ending with a {nameof(HttpClientHandler)} or {nameof(SocketsHttpHandler)}.");
}
}

Expand Down Expand Up @@ -1055,14 +1055,15 @@ public async Task<Tunnel> CreateTunnelAsync(
Requires.NotNull(tunnel, nameof(tunnel));
options ??= new TunnelRequestOptions();
options.AdditionalHeaders ??= new List<KeyValuePair<string, string>>();
options.AdditionalHeaders = options.AdditionalHeaders.Append(new KeyValuePair<string, string>("If-None-Match", "*"));
options.AdditionalHeaders = options.AdditionalHeaders.Append(
new KeyValuePair<string, string>("If-None-Match", "*"));
var tunnelId = tunnel.TunnelId;
var idGenerated = string.IsNullOrEmpty(tunnelId);
if (idGenerated)
{
tunnel.TunnelId = IdGeneration.GenerateTunnelId();
}
for (int retries = 0; retries <= CreateNameRetries; retries++)
for (int retries = 0; ; retries++)
{
try
{
Expand All @@ -1079,25 +1080,14 @@ public async Task<Tunnel> CreateTunnelAsync(
PreserveAccessTokens(tunnel, result);
return result!;
}
catch (UnauthorizedAccessException) when (idGenerated && retries < CreateNameRetries) // The tunnel ID was already taken.
catch (InvalidOperationException ex)
when (ex.InnerException is HttpRequestException hrex &&
hrex.StatusCode == HttpStatusCode.Conflict &&
idGenerated && retries < CreateNameRetries) // The tunnel ID was already taken.
{
tunnel.TunnelId = IdGeneration.GenerateTunnelId();
}
}

// This code is unreachable, but the compiler still requires it.
var result2 = await this.SendTunnelRequestAsync<Tunnel, Tunnel>(
HttpMethod.Put,
tunnel,
ManageAccessTokenScope,
path: null,
query: GetApiQuery(),
options,
ConvertTunnelForRequest(tunnel),
cancellation,
true);
PreserveAccessTokens(tunnel, result2);
return result2!;
}

/// <inheritdoc />
Expand All @@ -1108,48 +1098,25 @@ public async Task<Tunnel> CreateOrUpdateTunnelAsync(
{
Requires.NotNull(tunnel, nameof(tunnel));

var tunnelId = tunnel.TunnelId;
var idGenerated = string.IsNullOrEmpty(tunnelId);
if (idGenerated)
if (string.IsNullOrEmpty(tunnel.TunnelId))
{
tunnel.TunnelId = IdGeneration.GenerateTunnelId();
}
for (int retries = 0; retries <= CreateNameRetries; retries++)
{
try
{
var result = await this.SendTunnelRequestAsync<Tunnel, Tunnel>(
HttpMethod.Put,
tunnel,
ManageAccessTokenScope,
path: null,
query: GetApiQuery(),
options,
ConvertTunnelForRequest(tunnel),
cancellation,
true);
PreserveAccessTokens(tunnel, result);
return result!;
}
catch (UnauthorizedAccessException) when (idGenerated && retries < 3) // The tunnel ID was already taken.
{
tunnel.TunnelId = IdGeneration.GenerateTunnelId();
}
// When no tunnel ID is specified, a randomly-generated ID should not unintionally
// update an existing tunnel.
return await CreateTunnelAsync(tunnel, options, cancellation);
}

// This code is unreachable, but the compiler still requires it.
var result2 = await this.SendTunnelRequestAsync<Tunnel, Tunnel>(
HttpMethod.Put,
tunnel,
ManageAccessTokenScope,
path: null,
query: GetApiQuery(),
options,
ConvertTunnelForRequest(tunnel),
cancellation,
true);
PreserveAccessTokens(tunnel, result2);
return result2!;
var result = await this.SendTunnelRequestAsync<Tunnel, Tunnel>(
HttpMethod.Put,
tunnel,
ManageAccessTokenScope,
path: null,
query: GetApiQuery(),
options,
ConvertTunnelForRequest(tunnel),
cancellation,
true);
PreserveAccessTokens(tunnel, result);
return result!;
}

/// <inheritdoc />
Expand Down
56 changes: 56 additions & 0 deletions cs/test/TunnelsSDK.Test/TunnelManagementClientTests.cs
Original file line number Diff line number Diff line change
Expand Up @@ -144,6 +144,62 @@ public async Task PreserveAccessTokens()
TunnelAccessScopes.Manage, "manage-token-2"), item)); // updated
}

[Fact]
public async Task CreateTunnelRetriesOnGeneratedIdConflict()
{
var requestTunnel = new Tunnel
{
ClusterId = ClusterId,

// Tunnel ID is not set, so the client will generate one.
};

var callCount = 0;
string firstTunnelId = null;
string secondTunnelId = null;

var handler = new MockHttpMessageHandler(
async (message, ct) =>
{
callCount++;
Assert.NotNull(message.Content);

var sentTunnel = await message.Content!.ReadFromJsonAsync<Tunnel>(cancellationToken: ct);
Assert.NotNull(sentTunnel);
Assert.False(string.IsNullOrEmpty(sentTunnel!.TunnelId));

if (callCount == 1)
{
firstTunnelId = sentTunnel.TunnelId;
var conflictResult = new HttpResponseMessage(HttpStatusCode.Conflict);
conflictResult.RequestMessage = message;
return conflictResult;
}

secondTunnelId = sentTunnel.TunnelId;

var responseTunnel = new Tunnel
{
TunnelId = sentTunnel.TunnelId,
ClusterId = ClusterId,
};

var result = new HttpResponseMessage(HttpStatusCode.OK);
result.Content = JsonContent.Create(responseTunnel);
return result;
});

var client = new TunnelManagementClient(this.userAgent, null, this.tunnelServiceUri, handler);

var resultTunnel = await client.CreateTunnelAsync(requestTunnel, options: null, this.timeout);

Assert.Equal(2, callCount);
Assert.NotNull(firstTunnelId);
Assert.NotNull(secondTunnelId);
Assert.Equal(secondTunnelId, resultTunnel.TunnelId);
Assert.Equal(resultTunnel.TunnelId, requestTunnel.TunnelId);
}

[Fact]
public async Task HandleFirewallResponse()
{
Expand Down
4 changes: 2 additions & 2 deletions ts/src/connections/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -18,8 +18,8 @@
"buffer": "^5.2.1",
"debug": "^4.1.1",
"vscode-jsonrpc": "^4.0.0",
"@microsoft/dev-tunnels-contracts": "^1.3.6",
"@microsoft/dev-tunnels-management": "^1.3.6",
"@microsoft/dev-tunnels-contracts": "^1.3.7",
"@microsoft/dev-tunnels-management": "^1.3.7",
"@microsoft/dev-tunnels-ssh": "^3.12.12",
"@microsoft/dev-tunnels-ssh-tcp": "^3.12.12",
"uuid": "^3.3.3",
Expand Down
2 changes: 1 addition & 1 deletion ts/src/management/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -18,7 +18,7 @@
"buffer": "^5.2.1",
"debug": "^4.1.1",
"vscode-jsonrpc": "^4.0.0",
"@microsoft/dev-tunnels-contracts": "^1.3.6",
"@microsoft/dev-tunnels-contracts": "^1.3.7",
"axios": "^1.8.4"
}
}
71 changes: 15 additions & 56 deletions ts/src/management/tunnelManagementHttpClient.ts
Original file line number Diff line number Diff line change
Expand Up @@ -302,7 +302,7 @@ export class TunnelManagementHttpClient implements TunnelManagementClient {
if (idGenerated) {
tunnel.tunnelId = IdGeneration.generateTunnelId();
}
for (let i = 0;i<=createNameRetries; i++){
for (let retries = 0; ; retries++){
try {
const result = (await this.sendTunnelRequest<Tunnel>(
'PUT',
Expand All @@ -320,7 +320,9 @@ export class TunnelManagementHttpClient implements TunnelManagementClient {
parseTunnelDates(result);
return result;
} catch (error) {
if (idGenerated) {
if ((error as AxiosError)?.response?.status === 409 &&
idGenerated && retries < createNameRetries
) {
// The tunnel ID was generated and there was a conflict.
// Try again with a new ID.
tunnel.tunnelId = IdGeneration.generateTunnelId();
Expand All @@ -329,73 +331,30 @@ export class TunnelManagementHttpClient implements TunnelManagementClient {
}
}
}

const result2 = (await this.sendTunnelRequest<Tunnel>(
'PUT',
tunnel,
manageAccessTokenScope,
undefined,
undefined,
options,
this.convertTunnelForRequest(tunnel),
undefined,
cancellation,
true,
))!;
preserveAccessTokens(tunnel, result2);
parseTunnelDates(result2);
return result2;
}

public async createOrUpdateTunnel(tunnel: Tunnel, options?: TunnelRequestOptions, cancellation?: CancellationToken): Promise<Tunnel> {
const tunnelId = tunnel.tunnelId;
const idGenerated = tunnelId === undefined || tunnelId === null || tunnelId === '';
if (idGenerated) {
tunnel.tunnelId = IdGeneration.generateTunnelId();
}
for (let i = 0;i<=createNameRetries; i++){
try {
const result = (await this.sendTunnelRequest<Tunnel>(
'PUT',
tunnel,
manageAccessTokenScope,
undefined,
undefined,
options,
this.convertTunnelForRequest(tunnel),
undefined,
cancellation,
true,
))!;
preserveAccessTokens(tunnel, result);
parseTunnelDates(result);
return result;
} catch (error) {
if (idGenerated) {
// The tunnel ID was generated and there was a conflict.
// Try again with a new ID.
tunnel.tunnelId = IdGeneration.generateTunnelId();
} else {
throw error;
}
}
if (!tunnelId) {
// When no tunnel ID is specified, a randomly-generated ID should not unintionally
// update an existing tunnel.
return this.createTunnel(tunnel, options, cancellation);
}

const result2 = (await this.sendTunnelRequest<Tunnel>(
const result = (await this.sendTunnelRequest<Tunnel>(
'PUT',
tunnel,
manageAccessTokenScope,
undefined,
"forceCreate=true",
undefined,
options,
this.convertTunnelForRequest(tunnel),
undefined,
cancellation,
true,
))!;
preserveAccessTokens(tunnel, result2);
parseTunnelDates(result2);
return result2;
preserveAccessTokens(tunnel, result);
parseTunnelDates(result);
return result;
}

public async updateTunnel(tunnel: Tunnel, options?: TunnelRequestOptions, cancellation?: CancellationToken): Promise<Tunnel> {
Expand Down Expand Up @@ -745,8 +704,8 @@ export class TunnelManagementHttpClient implements TunnelManagementClient {
this.raiseReportProgress(TunnelProgress.CompletedSendTunnelRequest);
return result;
} catch (error) {
if (/certificate/i.test((error as AxiosError<any>).message)) {
const originalErrorMessage = (error as AxiosError<any>).message;
if (/certificate/i.test((error as AxiosError).message)) {
const originalErrorMessage = (error as AxiosError).message;
throw new Error("Tunnel service HTTPS certificate is invalid. This may be caused by the use of a " +
"self-signed certificate or a firewall intercepting the connection. " + originalErrorMessage + ". ");
}
Expand Down
70 changes: 70 additions & 0 deletions ts/test/tunnels-test/tunnelManagementTests.ts
Original file line number Diff line number Diff line change
Expand Up @@ -245,6 +245,76 @@ export class TunnelManagementTests {
assert.strictEqual(resultTunnel.accessTokens['manage'], 'manage-token-2'); // updated
}

@test
public async createTunnelRetriesOnGeneratedIdConflict() {
const requestTunnel = <Tunnel>{
clusterId: 'clusterId',
};

let callCount = 0;
let firstTunnelId: string | undefined;
let secondTunnelId: string | undefined;

const conflictError = new AxiosError();
conflictError.config = {
url: TunnelManagementTests.testServiceUri,
headers: new AxiosHeaders(),
};
conflictError.response = {
status: 409,
statusText: 'Conflict',
headers: new AxiosHeaders(),
data: undefined,
config: conflictError.config,
};

const originalAxiosRequest = (<any>this.managementClient).axiosRequest;
(<any>this.managementClient).axiosRequest = async (
config: AxiosRequestConfig,
cancellation: CancellationToken,
): Promise<AxiosResponse> => {
this.lastRequest = {
method: config.method as Method,
uri: config.url || '',
data: config.data,
config,
};
callCount++;

const sentTunnel = config.data as Tunnel;
if (callCount === 1) {
firstTunnelId = sentTunnel?.tunnelId;
throw conflictError;
}

secondTunnelId = sentTunnel?.tunnelId;

return {
data: <Tunnel>{
tunnelId: sentTunnel?.tunnelId,
clusterId: requestTunnel.clusterId,
},
status: 200,
statusText: 'OK',
headers: {},
config,
} as AxiosResponse;
};

try {
const resultTunnel = await this.managementClient.createTunnel(requestTunnel);

assert.strictEqual(callCount, 2);
assert.ok(firstTunnelId);
assert.ok(secondTunnelId);
assert.notStrictEqual(firstTunnelId, secondTunnelId);
assert.strictEqual(resultTunnel.tunnelId, secondTunnelId);
assert.strictEqual(requestTunnel.tunnelId, secondTunnelId);
} finally {
(<any>this.managementClient).axiosRequest = originalAxiosRequest;
}
}

@test
public async handleFirewallResponse() {
const requestTunnel = <Tunnel>{
Expand Down
Loading