Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

[core-amqp] MessagingError changes #6673

Merged
merged 20 commits into from
Jan 6, 2020
Merged
Show file tree
Hide file tree
Changes from 12 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
2 changes: 2 additions & 0 deletions sdk/core/core-amqp/CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,8 @@

- Improved detection of when an established socket is no longer receiving data from the service.
- Added logging around the network connectivity check.
- Updated MessagingError translation so that all MessagingErrors have the name `MessagingError`.
Now use the `error.code` property to determine the cause of the error when `error.name` is `MessagingError`.

## 1.0.0-preview.6 (2019-12-03)

Expand Down
192 changes: 132 additions & 60 deletions sdk/core/core-amqp/src/errors.ts
Original file line number Diff line number Diff line change
@@ -1,8 +1,8 @@
// Copyright (c) Microsoft Corporation. All rights reserved.
// Licensed under the MIT License.

import { AmqpResponseStatusCode, isAmqpError, AmqpError } from "rhea-promise";
import { isNode } from "../src/util/utils";
import { AmqpResponseStatusCode, isAmqpError as rheaIsAmqpError, AmqpError } from "rhea-promise";
import { isNode, isString, isNumber } from "../src/util/utils";

/**
* Maps the conditions to the numeric AMQP Response status codes.
Expand Down Expand Up @@ -435,38 +435,94 @@ export enum ErrorNameConditionMapper {
SystemError = "system:error"
}

/**
* Describes the fields on a Node.js SystemError.
* Omits fields that are not related to network calls (e.g. file system calls).
* See https://nodejs.org/dist/latest-v12.x/docs/api/errors.html#errors_class_systemerror
*/
interface NetworkSystemError {
address?: string;
code: string;
errno: string | number;
info?: any;
message: string;
name: string;
port?: number;
stack: string;
syscall: string;
}

const systemErrorFieldsToCopy: (keyof Omit<NetworkSystemError, "name" | "message">)[] = [
"address",
"code",
"errno",
"info",
"port",
"stack",
"syscall"
];

/**
* Describes the base class for Messaging Error.
* @class {MessagingError}
* @extends Error
*/
export class MessagingError extends Error {
/**
* @property {string} [condition] The error condition.
* Address to which the network connection failed.
* Only available in Node.js.
*/
condition?: string;
address?: string;
/**
* A string label that identifies the error.
*/
code?: string;
/**
* System-provided error number.
* Only available in Node.js.
*/
errno?: number | string;
/**
* @property {string} name The error name. Default value: "MessagingError".
*/
name: string = "MessagingError";
/**
* @property {boolean} translated Has the error been translated. Default: true.
* The unavailable network connection port.
* Only available in Node.js.
*/
translated: boolean = true;
port?: number;
/**
* Name of the system call that triggered the error.
* Only available in Node.js.
*/
syscall?: string;
/**
*
* @property {boolean} retryable Describes whether the error is retryable. Default: true.
*/
retryable: boolean = true;
/**
* @property {any} [info] Any additional error information given by the service.
* @property {any} [info] Extra details about the error.
*/
info?: any;
/**
* @param {string} message The error message that provides more information about the error.
* @param originalError An error whose properties will be copied to the MessagingError if the
* property doesn't already exist.
*/
constructor(message: string) {
constructor(message: string, originalError?: Error) {
super(message);

if (!originalError) {
return;
}

// copy properties from system error
for (const propName of systemErrorFieldsToCopy) {
if ((originalError as NetworkSystemError)[propName] != undefined) {
this[propName] = (originalError as NetworkSystemError)[propName];
}
}
}
}

Expand Down Expand Up @@ -514,17 +570,24 @@ export enum SystemErrorConditionMapper {
ENONET = "com.microsoft:timeout"
}

/**
* Checks whether the provided error is a node.js SystemError.
* @param err An object that may contain error information.
*/
export function isSystemError(err: any): boolean {
let result: boolean = false;
if (
err.code &&
typeof err.code === "string" &&
(err.syscall && typeof err.syscall === "string") &&
(err.errno && (typeof err.errno === "string" || typeof err.errno === "number"))
) {
result = true;
if (!err) {
return false;
}
return result;

if (!isString(err.code) || !isString(err.syscall)) {
return false;
}

if (!isString(err.errno) && !isNumber(err.errno)) {
return false;
}

return true;
}

/**
Expand All @@ -547,65 +610,74 @@ function isBrowserWebsocketError(err: any): boolean {
return result;
}

const rheaPromiseErrors = [
// OperationTimeoutError occurs when the service fails to respond within a given timeframe.
"OperationTimeoutError",

// InsufficientCreditError occurs when the number of credits available on Rhea link is insufficient.
"InsufficientCreditError",

// Defines the error that occurs when the Sender fails to send a message.
"SendOperationFailedError"
];

/**
* Translates the AQMP error received at the protocol layer or a generic Error into a MessagingError.
* Translates the AQMP error received at the protocol layer or a SystemError into a MessagingError.
* All other errors are returned unaltered.
*
* @param {AmqpError} err The amqp error that was received.
* @returns {MessagingError} MessagingError object.
*/
export function translate(err: AmqpError | Error): MessagingError {
if ((err as MessagingError).translated) {
// already translated
return err as MessagingError;
}

let error: MessagingError = err as MessagingError;

export function translate(err: AmqpError | Error): MessagingError | Error {
// Built-in errors like TypeError and RangeError should not be retryable as these indicate issues
// with user input and not an issue with the Messaging process.
if (err instanceof TypeError || err instanceof RangeError) {
error.retryable = false;
return error;
return err;
}

if (isAmqpError(err)) {
// translate
const condition = (err as AmqpError).condition;
const description = (err as AmqpError).description as string;
error = new MessagingError(description);
const condition = err.condition;
const description = err.description!;
const error = new MessagingError(description);
if ((err as any).stack) error.stack = (err as any).stack;
error.info = (err as AmqpError).info;
error.condition = condition;
error.info = err.info;
if (condition) {
error.name = ConditionErrorNameMapper[condition as keyof typeof ConditionErrorNameMapper];
error.code = ConditionErrorNameMapper[condition as keyof typeof ConditionErrorNameMapper];
}
if (!error.name) error.name = "MessagingError";
if (
description &&
(description.includes("status-code: 404") ||
description.match(/The messaging entity .* could not be found.*/i) !== null)
) {
error.name = "MessagingEntityNotFoundError";
error.code = "MessagingEntityNotFoundError";
}
if (retryableErrors.indexOf(error.name) === -1) {
if (error.code && retryableErrors.indexOf(error.code) === -1) {
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

What if by this point we don't have a code?

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

At this point I was treating errors without a code as neither an AmqpError nor a SystemError, so do not alter them. They currently don't have a retryable field so are effectively not retryable.

This could change based on the thread you started at https://github.com/Azure/azure-sdk-for-js/pull/6673/files#r362364248

Copy link
Contributor

@ramya-rao-a ramya-rao-a Jan 6, 2020

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

While isAmqpError() ensures that the error has the condition property, we still won't have a code if there was an error with an amqp condition for which we have no mapping. Such errors will be treated as retryable.

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Currently, if there isn't a code, we will treat the error as retryable since retryable is set to true in the MessagingError constructor and we don't change it to false here. That seems consistent with the behavior prior to this PR, since if no condition was found we'd set the name to MessagingError, and any error with the name MessagingError was treated as retryable.

So, I think the question you're getting at is whether we should set error.code if there is no mapping or not. My preference would be to not do this, because then it would be a breaking change if we wanted to change the code in the future for an error we learned more about but weren't handling before. I think we can safely add a code if it never existed in the 1st place, but not change it if there already was one for an error.

Copy link
Contributor

@ramya-rao-a ramya-rao-a Jan 6, 2020

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

That seems consistent with the behavior prior to this PR, since if no condition was found we'd set the name to MessagingError, and any error with the name MessagingError was treated as retryable.

Not true... retryableErrors would not have an entry for MessagingError and so retryable would be set to false...

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

retryableErrors does have an entry for MessagingError today though...

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

ah! I missed that :)

// not found
error.retryable = false;
}
return error;
}

if (err.name === "MessagingError") {
// already translated
return err;
}

if (isSystemError(err)) {
// translate
const condition = (err as any).code;
const description = (err as Error).message;
error = new MessagingError(description);
if ((err as any).stack) error.stack = (err as any).stack;
const description = err.message;
const error = new MessagingError(description, err);
if (err.stack) error.stack = err.stack;
let errorType = "SystemError";
if (condition) {
const amqpErrorCondition = SystemErrorConditionMapper[condition as keyof typeof SystemErrorConditionMapper];
error.name = ConditionErrorNameMapper[amqpErrorCondition as keyof typeof ConditionErrorNameMapper];
const amqpErrorCondition =
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Idle speculation: if this cast is valid, then perhaps code could be typed as keyof typeof SystemErrorConditionMapper to begin with?

SystemErrorConditionMapper[condition as keyof typeof SystemErrorConditionMapper];
errorType =
ConditionErrorNameMapper[amqpErrorCondition as keyof typeof ConditionErrorNameMapper];
}
if (!error.name) error.name = "SystemError";
if (retryableErrors.indexOf(error.name) === -1) {
if (retryableErrors.indexOf(errorType) === -1) {
// not found
error.retryable = false;
}
Expand All @@ -614,27 +686,27 @@ export function translate(err: AmqpError | Error): MessagingError {

if (isBrowserWebsocketError(err)) {
// Translate browser communication errors during opening handshake to generic SeviceCommunicationError
error = new MessagingError("Websocket connection failed.");
error.name = ConditionErrorNameMapper[ErrorNameConditionMapper.ServiceCommunicationError];
const error = new MessagingError("Websocket connection failed.");
error.code = ConditionErrorNameMapper[ErrorNameConditionMapper.ServiceCommunicationError];
error.retryable = false;
return error;
}

// instanceof checks on custom Errors doesn't work without manually setting the prototype within the error.
// Must do a name check until the custom error is updated, and that doesn't break compatibility
// https://github.com/Microsoft/TypeScript/wiki/Breaking-Changes#extending-built-ins-like-error-array-and-map-may-no-longer-work
const errorName = (err as Error).name;
if (retryableErrors.indexOf(errorName) > -1) {
error.retryable = true;
return error;
}
if (errorName === "AbortError") {
error.retryable = false;
// Some errors come from rhea-promise and need to be converted to MessagingError.
// A subset of these are also retryable.
if (rheaPromiseErrors.indexOf(err.name) !== -1) {
const error = new MessagingError(err.message, err);
error.code = err.name;
if (error.code && retryableErrors.indexOf(error.code) === -1) {
// not found
error.retryable = false;
}
return error;
}

// Translate a generic error into MessagingError.
error = new MessagingError((err as Error).message);
error.stack = (err as Error).stack;
return error;
return err;
}

function isAmqpError(error: any): error is AmqpError {
return rheaIsAmqpError(error);
}
16 changes: 16 additions & 0 deletions sdk/core/core-amqp/src/util/utils.ts
Original file line number Diff line number Diff line change
Expand Up @@ -298,3 +298,19 @@ export function isIotHubConnectionString(connectionString: string): boolean {
}
return result;
}

/**
* @ignore
* @internal
*/
export function isString(s: any): s is string {
return typeof s === "string";
}

/**
* @ignore
* @internal
*/
export function isNumber(n: any): n is number {
return typeof n === "number";
}
Loading