This is an automated email from the ASF dual-hosted git repository.
RongtongJin pushed a commit to branch master
in repository https://gitbox.apache.org/repos/asf/rocketmq-clients.git
The following commit(s) were added to refs/heads/master by this push:
new 3357f9c0 fix(nodejs): align retry units, endpoints parsing (#1373)
3357f9c0 is described below
commit 3357f9c077bfc15caa506a6271ecdf0e288f85a0
Author: zhaohai <[email protected]>
AuthorDate: Tue Sep 22 10:50:19 2026 +0800
fix(nodejs): align retry units, endpoints parsing (#1373)
* fix(nodejs): correct retry backoff units, endpoints parsing and status
mappings
- Retry delays are milliseconds (the protobuf contract's unit was
misimplemented); inherited backoff preserves sub-second precision from
the server Duration (seconds * 1000 + nanos / 1e6)
- Endpoints string parsing rewritten: strip the optional http(s)://
prefix, parse bracketed and bare IPv6 addresses, split the port via
lastIndexOf(':')
- StatusChecker new mappings: ILLEGAL_LITE_TOPIC -> BadRequestException,
MESSAGE_BODY_EMPTY -> PayloadEmptyException,
LITE_SUBSCRIPTION_QUOTA_EXCEEDED -> LiteSubscriptionQuotaExceededException
- TelemetrySession: fix the reconnect timer leak; release() clears the
timer and guards reconnection, error/end events dedupe through a
single reconnect schedule
- Add tsconfig.test.json for compiling src and test together
Tests: rename regression suite to test/fixes.test.ts (13 offline cases,
all pass); tsc clean for both the prod and the test config
* fix(nodejs): keep auto-reconnect alive after telemetry session refresh
refresh() tore down the old stream through release(), which permanently
set #released and thus made #scheduleRenewStream() a no-op forever. Any
later stream error or completion was silently never reconnected.
Refresh now only cancels a pending reconnect timer and closes the stream,
leaving release() as the sole shutdown path.
---
nodejs/src/client/TelemetrySession.ts | 50 +++++--
nodejs/src/consumer/PushSubscriptionSettings.ts | 24 +++-
.../PayloadEmptyException.ts} | 10 +-
nodejs/src/exception/StatusChecker.ts | 7 +
nodejs/src/exception/index.ts | 1 +
nodejs/src/retry/CustomizedBackoffRetryPolicy.ts | 81 +++++++++++
nodejs/src/retry/ExponentialBackoffRetryPolicy.ts | 17 ++-
nodejs/src/retry/RetryPolicy.ts | 2 +-
nodejs/src/retry/index.ts | 1 +
nodejs/src/route/Endpoints.ts | 30 +++-
nodejs/test/fixes.test.ts | 156 +++++++++++++++++++++
11 files changed, 352 insertions(+), 27 deletions(-)
diff --git a/nodejs/src/client/TelemetrySession.ts
b/nodejs/src/client/TelemetrySession.ts
index d0fcf231..782481fb 100644
--- a/nodejs/src/client/TelemetrySession.ts
+++ b/nodejs/src/client/TelemetrySession.ts
@@ -27,6 +27,8 @@ export class TelemetrySession {
#logger: ILogger;
#stream: ClientDuplexStream<TelemetryCommand, TelemetryCommand>;
#isRefreshing = false;
+ #released = false;
+ #reconnectTimer?: NodeJS.Timeout;
constructor(baseClient: BaseClient, endpoints: Endpoints, logger: ILogger) {
this.#endpoints = endpoints;
@@ -38,8 +40,20 @@ export class TelemetrySession {
}
release() {
+ if (this.#released) {
+ return;
+ }
+ this.#released = true;
+ if (this.#reconnectTimer) {
+ clearTimeout(this.#reconnectTimer);
+ this.#reconnectTimer = undefined;
+ }
this.#logger.info('Begin to release telemetry session, endpoints=%s,
clientId=%s',
this.#endpoints, this.#baseClient.clientId);
+ this.#closeStream();
+ }
+
+ #closeStream() {
try {
this.#stream.end();
this.#stream.removeAllListeners();
@@ -70,7 +84,11 @@ export class TelemetrySession {
this.#endpoints, this.#baseClient.clientId);
try {
- this.release();
+ if (this.#reconnectTimer) {
+ clearTimeout(this.#reconnectTimer);
+ this.#reconnectTimer = undefined;
+ }
+ this.#closeStream();
this.#renewStream(false);
} catch (err) {
this.#logger.error('Failed to refresh telemetry session, endpoints=%s,
clientId=%s, error=%s',
@@ -150,21 +168,35 @@ export class TelemetrySession {
}
}
+ /**
+ * Schedule a telemetry stream renewal. The timer is tracked so that it can
+ * be cancelled on release(), and a pending timer doubles as a guard to
+ * prevent error/end events from scheduling duplicate reconnects.
+ */
+ #scheduleRenewStream() {
+ if (this.#released || this.#reconnectTimer) {
+ return;
+ }
+ this.#reconnectTimer = setTimeout(() => {
+ this.#reconnectTimer = undefined;
+ if (this.#released) {
+ return;
+ }
+ this.#renewStream(false);
+ }, 1000);
+ }
+
#onError(err: Error) {
this.#logger.error('Exception raised from stream response observer,
endpoints=%s, clientId=%s, error=%s',
this.#endpoints, this.#baseClient.clientId, err);
- this.release();
- setTimeout(() => {
- this.#renewStream(false);
- }, 1000);
+ this.#closeStream();
+ this.#scheduleRenewStream();
}
#onEnd() {
this.#logger.info('Receive completion for stream response observer,
endpoints=%s, clientId=%s',
this.#endpoints, this.#baseClient.clientId);
- this.release();
- setTimeout(() => {
- this.#renewStream(false);
- }, 1000);
+ this.#closeStream();
+ this.#scheduleRenewStream();
}
}
diff --git a/nodejs/src/consumer/PushSubscriptionSettings.ts
b/nodejs/src/consumer/PushSubscriptionSettings.ts
index d02ca277..2f3b1a8c 100644
--- a/nodejs/src/consumer/PushSubscriptionSettings.ts
+++ b/nodejs/src/consumer/PushSubscriptionSettings.ts
@@ -21,9 +21,10 @@ import {
Subscription,
RetryPolicy as RetryPolicyPB,
} from '../../proto/apache/rocketmq/v2/definition_pb';
+import { Duration } from 'google-protobuf/google/protobuf/duration_pb';
import { Endpoints } from '../route';
import { Settings, UserAgent } from '../client';
-import { ExponentialBackoffRetryPolicy, RetryPolicy } from '../retry';
+import { CustomizedBackoffRetryPolicy, ExponentialBackoffRetryPolicy,
RetryPolicy } from '../retry';
import { createDuration, createResource } from '../util';
import { FilterExpression } from './FilterExpression';
@@ -102,20 +103,29 @@ export class PushSubscriptionSettings extends Settings {
}
const backoffPolicy = settings.getBackoffPolicy();
if (backoffPolicy) {
+ // Convert protobuf Duration (seconds + nanos) to milliseconds without
+ // losing sub-second precision.
+ const toMillis = (duration?: Duration) =>
+ duration ? duration.getSeconds() * 1000 + duration.getNanos() / 1e6 :
0;
switch (backoffPolicy.getStrategyCase()) {
case RetryPolicyPB.StrategyCase.EXPONENTIAL_BACKOFF: {
- const exponential =
backoffPolicy.getExponentialBackoff()!.toObject();
+ const exponential = backoffPolicy.getExponentialBackoff()!;
this.retryPolicy = new ExponentialBackoffRetryPolicy(
backoffPolicy.getMaxAttempts(),
- exponential.initial?.seconds,
- exponential.max?.seconds,
- exponential.multiplier,
+ toMillis(exponential.getInitial()),
+ toMillis(exponential.getMax()),
+ exponential.getMultiplier(),
);
break;
}
- case RetryPolicyPB.StrategyCase.CUSTOMIZED_BACKOFF:
- // CustomizedBackoffRetryPolicy not yet implemented in Node.js
+ case RetryPolicyPB.StrategyCase.CUSTOMIZED_BACKOFF: {
+ const customizedBackoff = backoffPolicy.getCustomizedBackoff()!;
+ const durations = customizedBackoff.getNextList().map((duration:
Duration) => toMillis(duration));
+ if (durations.length > 0) {
+ this.retryPolicy = new CustomizedBackoffRetryPolicy(durations,
backoffPolicy.getMaxAttempts());
+ }
break;
+ }
default:
break;
}
diff --git a/nodejs/src/retry/index.ts
b/nodejs/src/exception/PayloadEmptyException.ts
similarity index 75%
copy from nodejs/src/retry/index.ts
copy to nodejs/src/exception/PayloadEmptyException.ts
index 79d29d0f..8625e6f8 100644
--- a/nodejs/src/retry/index.ts
+++ b/nodejs/src/exception/PayloadEmptyException.ts
@@ -15,5 +15,11 @@
* limitations under the License.
*/
-export * from './ExponentialBackoffRetryPolicy';
-export * from './RetryPolicy';
+import { ClientException } from './ClientException';
+
+export class PayloadEmptyException extends ClientException {
+ constructor(code: number, message: string, requestId?: string) {
+ super(code, message, requestId);
+ this.name = 'PayloadEmptyException';
+ }
+}
diff --git a/nodejs/src/exception/StatusChecker.ts
b/nodejs/src/exception/StatusChecker.ts
index bb7cdae4..dfd68167 100644
--- a/nodejs/src/exception/StatusChecker.ts
+++ b/nodejs/src/exception/StatusChecker.ts
@@ -19,8 +19,10 @@ import { Status, Code } from
'../../proto/apache/rocketmq/v2/definition_pb';
import { BadRequestException } from './BadRequestException';
import { ForbiddenException } from './ForbiddenException';
import { InternalErrorException } from './InternalErrorException';
+import { LiteSubscriptionQuotaExceededException } from
'./LiteSubscriptionQuotaExceededException';
import { LiteTopicQuotaExceededException } from
'./LiteTopicQuotaExceededException';
import { NotFoundException } from './NotFoundException';
+import { PayloadEmptyException } from './PayloadEmptyException';
import { PayloadTooLargeException } from './PayloadTooLargeException';
import { PaymentRequiredException } from './PaymentRequiredException';
import { ProxyTimeoutException } from './ProxyTimeoutException';
@@ -44,6 +46,7 @@ export class StatusChecker {
case Code.ILLEGAL_MESSAGE_KEY:
case Code.ILLEGAL_MESSAGE_GROUP:
case Code.ILLEGAL_MESSAGE_PROPERTY_KEY:
+ case Code.ILLEGAL_LITE_TOPIC:
case Code.INVALID_TRANSACTION_ID:
case Code.ILLEGAL_MESSAGE_ID:
case Code.ILLEGAL_FILTER_EXPRESSION:
@@ -71,6 +74,8 @@ export class StatusChecker {
case Code.PAYLOAD_TOO_LARGE:
case Code.MESSAGE_BODY_TOO_LARGE:
throw new PayloadTooLargeException(status.code, status.message,
requestId);
+ case Code.MESSAGE_BODY_EMPTY:
+ throw new PayloadEmptyException(status.code, status.message,
requestId);
case Code.TOO_MANY_REQUESTS:
throw new TooManyRequestsException(status.code, status.message,
requestId);
case Code.REQUEST_HEADER_FIELDS_TOO_LARGE:
@@ -90,6 +95,8 @@ export class StatusChecker {
throw new UnsupportedException(status.code, status.message, requestId);
case Code.LITE_TOPIC_QUOTA_EXCEEDED:
throw new LiteTopicQuotaExceededException(status.code, status.message
|| '', requestId);
+ case Code.LITE_SUBSCRIPTION_QUOTA_EXCEEDED:
+ throw new LiteSubscriptionQuotaExceededException(status.code,
requestId ?? null, status.message || '');
default:
throw new UnsupportedException(status.code, status.message, requestId);
}
diff --git a/nodejs/src/exception/index.ts b/nodejs/src/exception/index.ts
index e542e96e..381db8ce 100644
--- a/nodejs/src/exception/index.ts
+++ b/nodejs/src/exception/index.ts
@@ -22,6 +22,7 @@ export * from './InternalErrorException';
export * from './LiteSubscriptionQuotaExceededException';
export * from './LiteTopicQuotaExceededException';
export * from './NotFoundException';
+export * from './PayloadEmptyException';
export * from './PayloadTooLargeException';
export * from './PaymentRequiredException';
export * from './ProxyTimeoutException';
diff --git a/nodejs/src/retry/CustomizedBackoffRetryPolicy.ts
b/nodejs/src/retry/CustomizedBackoffRetryPolicy.ts
new file mode 100644
index 00000000..204130c5
--- /dev/null
+++ b/nodejs/src/retry/CustomizedBackoffRetryPolicy.ts
@@ -0,0 +1,81 @@
+/**
+ * Licensed to the Apache Software Foundation (ASF) under one or more
+ * contributor license agreements. See the NOTICE file distributed with
+ * this work for additional information regarding copyright ownership.
+ * The ASF licenses this file to You under the Apache License, Version 2.0
+ * (the "License"); you may not use this file except in compliance with
+ * the License. You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+import assert from 'node:assert';
+import { Duration } from 'google-protobuf/google/protobuf/duration_pb';
+import {
+ RetryPolicy as RetryPolicyPB,
+ CustomizedBackoff,
+} from '../../proto/apache/rocketmq/v2/definition_pb';
+import { createDuration } from '../util';
+import { RetryPolicy } from './RetryPolicy';
+
+/**
+ * Backoff policy whose durations are customized by the server side,
+ * mirroring Java's CustomizedBackoffRetryPolicy: the Nth attempt waits
+ * durations[N - 1], and once attempts run past the list, the last
+ * duration applies.
+ */
+export class CustomizedBackoffRetryPolicy implements RetryPolicy {
+ #maxAttempts: number;
+ // Backoff durations in milliseconds.
+ #durations: number[];
+
+ constructor(durations: number[], maxAttempts: number) {
+ assert(Array.isArray(durations) && durations.length > 0, 'durations must
not be empty');
+ this.#durations = durations;
+ this.#maxAttempts = maxAttempts;
+ }
+
+ getMaxAttempts(): number {
+ return this.#maxAttempts;
+ }
+
+ getNextAttemptDelay(attempt: number): number {
+ assert(attempt > 0, 'attempt must be positive');
+ return attempt > this.#durations.length
+ ? this.#durations[this.#durations.length - 1]
+ : this.#durations[attempt - 1];
+ }
+
+ inheritBackoff(retryPolicy: RetryPolicyPB): RetryPolicy {
+ assert(retryPolicy.getStrategyCase() ===
RetryPolicyPB.StrategyCase.CUSTOMIZED_BACKOFF,
+ 'strategy must be customized backoff');
+ return new CustomizedBackoffRetryPolicy(
+
CustomizedBackoffRetryPolicy.durationsToMillis(retryPolicy.getCustomizedBackoff()!),
+ this.#maxAttempts);
+ }
+
+ toProtobuf(): RetryPolicyPB {
+ const customizedBackoff = new CustomizedBackoff();
+ for (const duration of this.#durations) {
+ customizedBackoff.addNext(createDuration(duration));
+ }
+ return new RetryPolicyPB()
+ .setMaxAttempts(this.#maxAttempts)
+ .setCustomizedBackoff(customizedBackoff);
+ }
+
+ /**
+ * Convert the protobuf CustomizedBackoff durations (seconds + nanos) to
+ * milliseconds without losing sub-second precision.
+ */
+ static durationsToMillis(customizedBackoff: CustomizedBackoff): number[] {
+ return customizedBackoff.getNextList().map((duration: Duration) =>
+ duration.getSeconds() * 1000 + duration.getNanos() / 1e6);
+ }
+}
diff --git a/nodejs/src/retry/ExponentialBackoffRetryPolicy.ts
b/nodejs/src/retry/ExponentialBackoffRetryPolicy.ts
index 3fc8997b..e851d0e8 100644
--- a/nodejs/src/retry/ExponentialBackoffRetryPolicy.ts
+++ b/nodejs/src/retry/ExponentialBackoffRetryPolicy.ts
@@ -16,16 +16,16 @@
*/
import assert from 'node:assert';
-import { Duration } from 'google-protobuf/google/protobuf/duration_pb';
import {
RetryPolicy as RetryPolicyPB,
ExponentialBackoff,
} from '../../proto/apache/rocketmq/v2/definition_pb';
+import { createDuration } from '../util';
import { RetryPolicy } from './RetryPolicy';
export class ExponentialBackoffRetryPolicy implements RetryPolicy {
#maxAttempts: number;
- // seconds
+ // milliseconds
#initialBackoff: number;
#maxBackoff: number;
#backoffMultiplier: number;
@@ -58,9 +58,14 @@ export class ExponentialBackoffRetryPolicy implements
RetryPolicy {
assert(retryPolicy.getStrategyCase() ===
RetryPolicyPB.StrategyCase.EXPONENTIAL_BACKOFF,
'strategy must be exponential backoff');
const backoff = retryPolicy.getExponentialBackoff()!.toObject();
+ // Convert protobuf Duration (seconds + nanos) to milliseconds without
+ // losing sub-second precision (e.g. an initial backoff of 0.5s used to
+ // be truncated to plain 0, causing immediate retries).
+ const toMillis = (duration?: { seconds?: number; nanos?: number }) =>
+ duration ? (duration.seconds ?? 0) * 1000 + (duration.nanos ?? 0) / 1e6
: 0;
return new ExponentialBackoffRetryPolicy(this.#maxAttempts,
- backoff.initial?.seconds,
- backoff.max?.seconds,
+ toMillis(backoff.initial),
+ toMillis(backoff.max),
backoff.multiplier);
}
@@ -69,8 +74,8 @@ export class ExponentialBackoffRetryPolicy implements
RetryPolicy {
.setMaxAttempts(this.#maxAttempts)
.setExponentialBackoff(
new ExponentialBackoff()
- .setInitial(new Duration().setSeconds(this.#initialBackoff))
- .setMax(new Duration().setSeconds(this.#maxBackoff))
+ .setInitial(createDuration(this.#initialBackoff))
+ .setMax(createDuration(this.#maxBackoff))
.setMultiplier(this.#backoffMultiplier));
}
}
diff --git a/nodejs/src/retry/RetryPolicy.ts b/nodejs/src/retry/RetryPolicy.ts
index 0e559d34..f16dc359 100644
--- a/nodejs/src/retry/RetryPolicy.ts
+++ b/nodejs/src/retry/RetryPolicy.ts
@@ -32,7 +32,7 @@ export interface RetryPolicy {
* Get await time after current attempts, the attempt index starts at 1.
*
* @param attempt current attempt.
- * @return await time in seconds.
+ * @return await time in milliseconds.
*/
getNextAttemptDelay(attempt: number): number;
diff --git a/nodejs/src/retry/index.ts b/nodejs/src/retry/index.ts
index 79d29d0f..119a55e5 100644
--- a/nodejs/src/retry/index.ts
+++ b/nodejs/src/retry/index.ts
@@ -15,5 +15,6 @@
* limitations under the License.
*/
+export * from './CustomizedBackoffRetryPolicy';
export * from './ExponentialBackoffRetryPolicy';
export * from './RetryPolicy';
diff --git a/nodejs/src/route/Endpoints.ts b/nodejs/src/route/Endpoints.ts
index df08382a..840faf5e 100644
--- a/nodejs/src/route/Endpoints.ts
+++ b/nodejs/src/route/Endpoints.ts
@@ -35,7 +35,33 @@ export class Endpoints {
const splits = endpoints.split(';');
this.addressesList = [];
for (const endpoint of splits) {
- const [ host, port ] = endpoint.split(':');
+ // Strip the optional http:// or https:// prefix, mirroring the Java
client.
+ const candidate = endpoint.trim().replace(/^https?:\/\//, '');
+ let host: string;
+ let port: number;
+ if (candidate.startsWith('[')) {
+ // Bracketed IPv6 address, e.g. [::1]:10911 or [fe80::1]
+ const match = candidate.match(/^\[([^\]]+)\](?::(\d+))?$/);
+ if (!match) {
+ throw new TypeError(`Invalid IPv6 endpoint: ${endpoint}`);
+ }
+ host = match[1];
+ port = match[2] ? parseInt(match[2], 10) : DEFAULT_PORT;
+ } else if (isIPv6(candidate)) {
+ // Bare IPv6 address without brackets, e.g. ::1
+ host = candidate;
+ port = DEFAULT_PORT;
+ } else {
+ // IPv4 address or domain name, e.g. 127.0.0.1:10911, example.com:80
+ const index = candidate.lastIndexOf(':');
+ if (index > 0) {
+ host = candidate.substring(0, index);
+ port = parseInt(candidate.substring(index + 1), 10) ||
DEFAULT_PORT;
+ } else {
+ host = candidate;
+ port = DEFAULT_PORT;
+ }
+ }
if (isIPv4(host)) {
this.scheme = AddressScheme.IPV4;
} else if (isIPv6(host)) {
@@ -43,7 +69,7 @@ export class Endpoints {
} else {
this.scheme = AddressScheme.DOMAIN_NAME;
}
- this.addressesList.push({ host, port: parseInt(port) || DEFAULT_PORT
});
+ this.addressesList.push({ host, port });
}
} else {
this.scheme = endpoints.scheme;
diff --git a/nodejs/test/fixes.test.ts b/nodejs/test/fixes.test.ts
new file mode 100644
index 00000000..d230cbc1
--- /dev/null
+++ b/nodejs/test/fixes.test.ts
@@ -0,0 +1,156 @@
+/**
+ * Licensed to the Apache Software Foundation (ASF) under one or more
+ * contributor license agreements. See the NOTICE file distributed with
+ * this work for additional information regarding copyright ownership.
+ * The ASF licenses this file to You under the Apache License, Version 2.0
+ * (the "License"); you may not use this file except in compliance with
+ * the License. You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+/**
+ * Regression tests for defect fixes aligned with the Java client:
+ * - Retry backoff unit contract (milliseconds, sub-second precision kept)
+ * - CustomizedBackoffRetryPolicy implementation
+ * - Endpoints string parsing (IPv6 / http(s) prefix)
+ * - StatusChecker mappings (ILLEGAL_LITE_TOPIC / MESSAGE_BODY_EMPTY /
+ * LITE_SUBSCRIPTION_QUOTA_EXCEEDED)
+ */
+
+import { describe, it } from 'node:test';
+import * as assert from 'node:assert';
+import { Duration } from 'google-protobuf/google/protobuf/duration_pb';
+import {
+ ExponentialBackoffRetryPolicy,
+ CustomizedBackoffRetryPolicy,
+} from '../src/retry';
+import { Endpoints } from '../src/route';
+import {
+ StatusChecker,
+ BadRequestException,
+ PayloadEmptyException,
+ LiteSubscriptionQuotaExceededException,
+} from '../src/exception';
+import { Code, RetryPolicy as RetryPolicyPB, ExponentialBackoff,
CustomizedBackoff } from '../proto/apache/rocketmq/v2/definition_pb';
+import { Status } from '../proto/apache/rocketmq/v2/definition_pb';
+
+function statusOf(code: Code): Status.AsObject {
+ return { code, message: 'mock message', requestId: '' } as unknown as
Status.AsObject;
+}
+
+describe('ExponentialBackoffRetryPolicy (ms contract)', () => {
+ it('should return delays in milliseconds', () => {
+ const policy = new ExponentialBackoffRetryPolicy(3, 1000, 10000, 2);
+ assert.strictEqual(policy.getNextAttemptDelay(1), 1000);
+ assert.strictEqual(policy.getNextAttemptDelay(2), 2000);
+ assert.strictEqual(policy.getNextAttemptDelay(3), 4000);
+ assert.strictEqual(policy.getNextAttemptDelay(4), 8000);
+ // capped by maxBackoff
+ assert.strictEqual(policy.getNextAttemptDelay(5), 10000);
+ });
+
+ it('should keep sub-second precision when inheriting backoff', () => {
+ const retryPolicy = new RetryPolicyPB().setExponentialBackoff(
+ new ExponentialBackoff()
+ .setInitial(new Duration().setSeconds(0).setNanos(500000000)) // 0.5s
+ .setMax(new Duration().setSeconds(3).setNanos(250000000)) // 3.25s
+ .setMultiplier(2));
+ const policy = new
ExponentialBackoffRetryPolicy(3).inheritBackoff(retryPolicy) as
ExponentialBackoffRetryPolicy;
+ assert.strictEqual(policy.getNextAttemptDelay(1), 500);
+ assert.strictEqual(policy.getNextAttemptDelay(2), 1000);
+ assert.strictEqual(policy.getNextAttemptDelay(3), 2000);
+ // capped by max backoff (3.25s, sub-second precision kept)
+ assert.strictEqual(policy.getNextAttemptDelay(4), 3250);
+ });
+
+ it('should serialize back to protobuf without losing precision', () => {
+ const policy = new ExponentialBackoffRetryPolicy(3, 500, 3250, 2);
+ const pb = policy.toProtobuf();
+ const exponential = pb.getExponentialBackoff()!;
+ assert.strictEqual(exponential.getInitial()!.getSeconds(), 0);
+ assert.strictEqual(exponential.getInitial()!.getNanos(), 500000000);
+ assert.strictEqual(exponential.getMax()!.getSeconds(), 3);
+ assert.strictEqual(exponential.getMax()!.getNanos(), 250000000);
+ });
+});
+
+describe('CustomizedBackoffRetryPolicy', () => {
+ it('should return the Nth duration and clamp to the last one', () => {
+ const policy = new CustomizedBackoffRetryPolicy([1000, 5000, 10000], 5);
+ assert.strictEqual(policy.getNextAttemptDelay(1), 1000);
+ assert.strictEqual(policy.getNextAttemptDelay(2), 5000);
+ assert.strictEqual(policy.getNextAttemptDelay(3), 10000);
+ assert.strictEqual(policy.getNextAttemptDelay(4), 10000);
+ assert.strictEqual(policy.getMaxAttempts(), 5);
+ });
+
+ it('should inherit customized backoff from protobuf', () => {
+ const customizedBackoff = new CustomizedBackoff();
+ customizedBackoff.addNext(new Duration().setSeconds(1));
+ customizedBackoff.addNext(new Duration().setNanos(500000000));
+ const retryPolicy = new RetryPolicyPB()
+ .setMaxAttempts(4)
+ .setCustomizedBackoff(customizedBackoff);
+ const policy = new CustomizedBackoffRetryPolicy([100],
3).inheritBackoff(retryPolicy);
+ assert.strictEqual(policy.getMaxAttempts(), 3);
+ assert.strictEqual(policy.getNextAttemptDelay(1), 1000);
+ assert.strictEqual(policy.getNextAttemptDelay(2), 500);
+ });
+});
+
+describe('Endpoints parsing', () => {
+ it('should parse IPv4 endpoints with port', () => {
+ const endpoints = new Endpoints('127.0.0.1:10911');
+ assert.deepStrictEqual(endpoints.addressesList, [{ host: '127.0.0.1',
port: 10911 }]);
+ assert.strictEqual(endpoints.scheme, 1); // IPV4
+ });
+
+ it('should parse domain endpoints', () => {
+ const endpoints = new Endpoints('example.com:443');
+ assert.deepStrictEqual(endpoints.addressesList, [{ host: 'example.com',
port: 443 }]);
+ assert.strictEqual(endpoints.scheme, 3); // DOMAIN_NAME
+ });
+
+ it('should parse bracketed IPv6 endpoints with port', () => {
+ const endpoints = new Endpoints('[::1]:10911');
+ assert.deepStrictEqual(endpoints.addressesList, [{ host: '::1', port:
10911 }]);
+ assert.strictEqual(endpoints.scheme, 2); // IPV6
+ });
+
+ it('should parse bare IPv6 endpoints without port', () => {
+ const endpoints = new Endpoints('1050:0000:0000:0000:0005:0600:300c:326b');
+ assert.strictEqual(endpoints.addressesList[0].host,
'1050:0000:0000:0000:0005:0600:300c:326b');
+ assert.strictEqual(endpoints.addressesList[0].port, 80);
+ assert.strictEqual(endpoints.scheme, 2); // IPV6
+ });
+
+ it('should strip http(s) prefixes and parse multiple endpoints', () => {
+ const endpoints = new
Endpoints('http://127.0.0.1:10911;https://example.com:8080');
+ assert.strictEqual(endpoints.addressesList.length, 2);
+ assert.deepStrictEqual(endpoints.addressesList[0], { host: '127.0.0.1',
port: 10911 });
+ assert.deepStrictEqual(endpoints.addressesList[1], { host: 'example.com',
port: 8080 });
+ });
+});
+
+describe('StatusChecker mappings vs Java', () => {
+ it('should map ILLEGAL_LITE_TOPIC to BadRequestException', () => {
+ assert.throws(() =>
StatusChecker.check(statusOf(Code.ILLEGAL_LITE_TOPIC)), BadRequestException);
+ });
+
+ it('should map MESSAGE_BODY_EMPTY to PayloadEmptyException', () => {
+ assert.throws(() =>
StatusChecker.check(statusOf(Code.MESSAGE_BODY_EMPTY)), PayloadEmptyException);
+ });
+
+ it('should map LITE_SUBSCRIPTION_QUOTA_EXCEEDED to
LiteSubscriptionQuotaExceededException', () => {
+ assert.throws(
+ () =>
StatusChecker.check(statusOf(Code.LITE_SUBSCRIPTION_QUOTA_EXCEEDED)),
+ LiteSubscriptionQuotaExceededException);
+ });
+});