Skip to content

Commit

Permalink
[core-lro] Simplify engine logic (Azure#22379)
Browse files Browse the repository at this point in the history
Simplifies the polling logic in the lroEngine by merging the body, location, and passthrough polling algorithms into one. This was done by capturing the essence of each scenario in a configuration bag and providing an explicit and general termination condition in each case. This change eliminates code duplication and provides a precise and simple polling algorithm that follows the [illustrated LRO flowchart](https://microsoft.sharepoint.com/:u:/t/AzureDeveloperExperience/ERFsgxlpa2tBrRKxceLzB8gB_44VE_Gr-dQdCFwvB2h0GQ?e=bHbo2T):

Consider the top right corner that describes sending the final GET request:
![finalGet](https://user-images.githubusercontent.com/6074665/176753403-3b4296f1-b6c9-4318-9835-fab20cb7375c.png)
This is implemented as follows: https://github.com/Azure/azure-sdk-for-js/blob/817c92fa9e946bc661818804566d394c8386a5bf/sdk/core/core-lro/src/lroEngine/impl.ts#L67-L96 For example, there is no such request in the case of a DELETE (line 78-80).

The rest of the chart, mainly the left half:
![scenarios](https://user-images.githubusercontent.com/6074665/176754810-fe201f45-d735-4b47-9b66-67d9f0634415.png)
can be seen implemented naturally in: https://github.com/Azure/azure-sdk-for-js/blob/817c92fa9e946bc661818804566d394c8386a5bf/sdk/core/core-lro/src/lroEngine/impl.ts#L98-L134 For example, the initial response for a PUT LRO without an operation location header polls from the request URL (lines 124-128).

Now termination can easily be checked as follows: https://github.com/Azure/azure-sdk-for-js/blob/817c92fa9e946bc661818804566d394c8386a5bf/sdk/core/core-lro/src/lroEngine/impl.ts#L191-L213

Note that there is a change in behavior where some previously thrown errors are no longer thrown. For context, the engine used to throw an error if the status code of the polling response is _weird_. For example, receiving a 204 from polling a resource location is weird. However, throwing an error in this case doesn't make the customer experience any better. The change is to throw errors for >=400 status codes only.
  • Loading branch information
deyaaeldeen authored Jun 30, 2022
1 parent c2e98b7 commit 244864b
Show file tree
Hide file tree
Showing 12 changed files with 511 additions and 525 deletions.
31 changes: 0 additions & 31 deletions sdk/core/core-lro/src/lroEngine/bodyPolling.ts

This file was deleted.

335 changes: 335 additions & 0 deletions sdk/core/core-lro/src/lroEngine/impl.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,335 @@
// Copyright (c) Microsoft Corporation.
// Licensed under the MIT license.

import {
GetLroStatusFromResponse,
LongRunningOperation,
LroBody,
LroInfo,
LroResourceLocationConfig,
LroResponse,
LroStatus,
PollerConfig,
RawResponse,
ResumablePollOperationState,
} from "./models";
import { PollOperationState } from "../pollOperation";
import { logger } from "./logger";

export function throwIfUndefined<T>(
input: T | undefined,
options: { errorMessage?: string } = {}
): T {
if (input === undefined) {
throw new Error(options.errorMessage ?? "undefined variable");
}
return input;
}

export function updatePollingUrl(inputs: { rawResponse: RawResponse; info: LroInfo }): void {
const { info, rawResponse } = inputs;
switch (info.mode) {
case "OperationLocation": {
const operationLocation = getOperationLocation(rawResponse);
const azureAsyncOperation = getAzureAsyncOperation(rawResponse);
info.pollingUrl =
getOperationLocationPollingUrl({ operationLocation, azureAsyncOperation }) ??
throwIfUndefined(info.pollingUrl);
break;
}
case "ResourceLocation": {
info.pollingUrl = getLocation(rawResponse) ?? throwIfUndefined(info.pollingUrl);
break;
}
}
}

function getOperationLocationPollingUrl(inputs: {
operationLocation?: string;
azureAsyncOperation?: string;
}): string | undefined {
const { azureAsyncOperation, operationLocation } = inputs;
return operationLocation ?? azureAsyncOperation;
}

function getLocation(rawResponse: RawResponse): string | undefined {
return rawResponse.headers["location"];
}

function getOperationLocation(rawResponse: RawResponse): string | undefined {
return rawResponse.headers["operation-location"];
}

function getAzureAsyncOperation(rawResponse: RawResponse): string | undefined {
return rawResponse.headers["azure-asyncoperation"];
}

function findResourceLocation(inputs: {
requestMethod: string;
location?: string;
requestPath: string;
lroResourceLocationConfig?: LroResourceLocationConfig;
}): string | undefined {
const { location, requestMethod, requestPath, lroResourceLocationConfig } = inputs;
switch (requestMethod) {
case "PUT": {
return requestPath;
}
case "DELETE": {
return undefined;
}
default: {
switch (lroResourceLocationConfig) {
case "azure-async-operation": {
return undefined;
}
case "original-uri": {
return requestPath;
}
case "location":
default: {
return location;
}
}
}
}
}

function inferLroMode(inputs: {
rawResponse: RawResponse;
requestPath: string;
requestMethod: string;
lroResourceLocationConfig?: LroResourceLocationConfig;
}): LroInfo {
const { rawResponse, requestMethod, requestPath, lroResourceLocationConfig } = inputs;
const operationLocation = getOperationLocation(rawResponse);
const azureAsyncOperation = getAzureAsyncOperation(rawResponse);
const location = getLocation(rawResponse);
if (operationLocation !== undefined || azureAsyncOperation !== undefined) {
return {
mode: "OperationLocation",
pollingUrl: operationLocation ?? azureAsyncOperation,
resourceLocation: findResourceLocation({
requestMethod,
location,
requestPath,
lroResourceLocationConfig,
}),
};
} else if (location !== undefined) {
return {
mode: "ResourceLocation",
pollingUrl: location,
};
} else if (requestMethod === "PUT") {
return {
mode: "Body",
pollingUrl: requestPath,
};
} else {
return {
mode: "None",
};
}
}

class SimpleRestError extends Error {
public statusCode?: number;
constructor(message: string, statusCode: number) {
super(message);
this.name = "RestError";
this.statusCode = statusCode;

Object.setPrototypeOf(this, SimpleRestError.prototype);
}
}

function throwIfError(rawResponse: RawResponse): void {
const code = rawResponse.statusCode;
if (code >= 400) {
throw new SimpleRestError(
`Received unexpected HTTP status code ${code} while polling. This may indicate a server issue.`,
code
);
}
}

function getStatus(rawResponse: RawResponse): string {
const { status } = (rawResponse.body as LroBody) ?? {};
return typeof status === "string" ? status.toLowerCase() : "succeeded";
}

function getProvisioningState(rawResponse: RawResponse): string {
const { properties, provisioningState } = (rawResponse.body as LroBody) ?? {};
const state = properties?.provisioningState ?? provisioningState;
return typeof state === "string" ? state.toLowerCase() : "succeeded";
}

function isCanceled<TResult, TState extends PollOperationState<TResult>>(operation: {
state: TState;
status: string;
}): boolean {
const { state, status } = operation;
if (["canceled", "cancelled"].includes(status)) {
state.isCancelled = true;
return true;
}
return false;
}

function isTerminal<TResult, TState extends PollOperationState<TResult>>(operation: {
state: TState;
status: string;
}): boolean {
const { state, status } = operation;
if (status === "failed") {
throw new Error(`The long-running operation has failed.`);
}
return status === "succeeded" || isCanceled({ state, status });
}

function isDone<TResult, TState extends PollOperationState<TResult>>(result: {
rawResponse: RawResponse;
state: TState;
info: LroInfo;
responseKind?: "Initial" | "Polling";
}): boolean {
const { rawResponse, state, info, responseKind = "Polling" } = result;
throwIfError(rawResponse);
switch (info.mode) {
case "OperationLocation": {
return responseKind === "Polling" && isTerminal({ state, status: getStatus(rawResponse) });
}
case "Body": {
return isTerminal({ state, status: getProvisioningState(rawResponse) });
}
case "ResourceLocation": {
return responseKind === "Polling" && rawResponse.statusCode !== 202;
}
case "None": {
return true;
}
}
}

/**
* Creates a polling operation.
*/
export function createPoll<TResult>(
lroPrimitives: LongRunningOperation<TResult>
): (
pollingURL: string,
pollerConfig: PollerConfig,
getLroStatusFromResponse: GetLroStatusFromResponse<TResult>
) => Promise<LroStatus<TResult>> {
return async (
path: string,
pollerConfig: PollerConfig,
getLroStatusFromResponse: GetLroStatusFromResponse<TResult>
): Promise<LroStatus<TResult>> => {
const response = await lroPrimitives.sendPollRequest(path);
const retryAfter: string | undefined = response.rawResponse.headers["retry-after"];
if (retryAfter !== undefined) {
// Retry-After header value is either in HTTP date format, or in seconds
const retryAfterInSeconds = parseInt(retryAfter);
pollerConfig.intervalInMs = isNaN(retryAfterInSeconds)
? calculatePollingIntervalFromDate(new Date(retryAfter), pollerConfig.intervalInMs)
: retryAfterInSeconds * 1000;
}
return getLroStatusFromResponse(response);
};
}

function calculatePollingIntervalFromDate(
retryAfterDate: Date,
defaultIntervalInMs: number
): number {
const timeNow = Math.floor(new Date().getTime());
const retryAfterTime = retryAfterDate.getTime();
if (timeNow < retryAfterTime) {
return retryAfterTime - timeNow;
}
return defaultIntervalInMs;
}

export function buildResult<TResult, TState extends PollOperationState<TResult>>(inputs: {
response: TResult;
state: TState;
processResult?: (result: unknown, state: TState) => TResult;
}): TResult {
const { processResult, response, state } = inputs;
return processResult ? processResult(response, state) : response;
}

/**
* Creates a callback to be used to initialize the polling operation state.
*/
export function createStateInitializer<
TResult,
TState extends PollOperationState<TResult>
>(inputs: {
state: ResumablePollOperationState<TResult>;
requestPath: string;
requestMethod: string;
lroResourceLocationConfig?: LroResourceLocationConfig;
processResult?: (result: unknown, state: TState) => TResult;
}): (response: LroResponse<TResult>) => void {
const { requestMethod, requestPath, state, lroResourceLocationConfig, processResult } = inputs;
return (response: LroResponse<TResult>): void => {
state.initialRawResponse = response.rawResponse;
state.isStarted = true;
state.config = inferLroMode({
rawResponse: state.initialRawResponse,
requestPath,
requestMethod,
lroResourceLocationConfig,
});
/** short circuit before polling */
if (
isDone({
rawResponse: state.initialRawResponse,
state,
info: state.config,
responseKind: "Initial",
})
) {
state.result = buildResult({
response: response.flatResponse,
state: state as TState,
processResult,
});
state.isCompleted = true;
}
logger.verbose(`LRO: initial state: ${JSON.stringify(state)}`);
};
}

export function createGetLroStatusFromResponse<
TResult,
TState extends PollOperationState<TResult>
>(inputs: {
lro: LongRunningOperation<TResult>;
state: TState;
info: LroInfo;
}): (response: LroResponse<TResult>) => LroStatus<TResult> {
const { lro, state, info } = inputs;
const location = info.resourceLocation;
return (response: LroResponse<TResult>): LroStatus<TResult> => {
const isTerminalStatus = isDone({
info,
rawResponse: response.rawResponse,
state,
});
return {
...response,
done: isTerminalStatus && !location,
next: !(isTerminalStatus && location)
? undefined
: () =>
lro.sendPollRequest(location).then((res) => ({
...res,
done: true,
})),
};
};
}
Loading

0 comments on commit 244864b

Please sign in to comment.