forked from Azure/azure-sdk-for-js
-
Notifications
You must be signed in to change notification settings - Fork 16
Commit
This commit does not belong to any branch on this repository, and may belong to a fork outside of the repository.
[core-lro] Simplify engine logic (Azure#22379)
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
1 parent
c2e98b7
commit 244864b
Showing
12 changed files
with
511 additions
and
525 deletions.
There are no files selected for viewing
This file was deleted.
Oops, something went wrong.
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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, | ||
})), | ||
}; | ||
}; | ||
} |
Oops, something went wrong.