Skip to content
Draft
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
36 changes: 36 additions & 0 deletions src/__tests__/cloudDevice/cloudDeviceApi.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -141,6 +141,42 @@ describe("CloudDeviceApi baseUrl", (): void => {
});

describe("CloudDeviceApi", (): void => {
test("retries an opted-in socket drop with the identical Nexo payload", async (): Promise<void> => {
const requestBodies: string[] = [];
const endpoint = `/merchants/${merchantAccount}/devices/${deviceId}/sync`;
scope
.post(endpoint, (body) => {
requestBodies.push(JSON.stringify(body));
return true;
})
.replyWithError({ message: "socket hang up" })
.post(endpoint, (body) => {
requestBodies.push(JSON.stringify(body));
return true;
})
.reply(200, paymentSyncSuccess);

const request = createCloudDeviceApiPaymentRequest();
await cloudDeviceAPI.CloudDeviceApi.sync(merchantAccount, deviceId, request, { retries: 1 });

expect(requestBodies).toHaveLength(2);
expect(requestBodies[1]).toBe(requestBodies[0]);
expect(JSON.parse(requestBodies[1]).SaleToPOIRequest.MessageHeader.ServiceID)
.toBe(request.SaleToPOIRequest.MessageHeader.ServiceID);
});

test("does not retry an HTTP error", async (): Promise<void> => {
const endpoint = `/merchants/${merchantAccount}/devices/${deviceId}/sync`;
const requestScope = scope
.post(endpoint)
.reply(500, { status: 500, message: "server error" });

const request = createCloudDeviceApiPaymentRequest();
await expect(cloudDeviceAPI.CloudDeviceApi.sync(merchantAccount, deviceId, request, { retries: 1 }))
.rejects.toThrow();
expect(requestScope.isDone()).toBe(true);
});

test("should send a sync payment request", async (): Promise<void> => {
scope
.post(`/merchants/${merchantAccount}/devices/${deviceId}/sync`)
Expand Down
25 changes: 25 additions & 0 deletions src/__tests__/cloudDevice/encryptedCloudDeviceApi.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -95,6 +95,31 @@ afterEach(() => {
});

describe("EncryptedCloudDeviceApi", () => {
test("retries an opted-in socket drop with the identical encrypted Nexo payload", async () => {
const requestBodies: string[] = [];
const endpoint = `/merchants/${merchantAccount}/devices/${deviceId}/sync`;
nock(BASE_URL)
.post(endpoint, (body) => {
requestBodies.push(JSON.stringify(body));
return true;
})
.replyWithError({ message: "socket hang up" })
.post(endpoint, (body) => {
requestBodies.push(JSON.stringify(body));
return true;
})
.reply(200, paymentSyncEncryptedSuccess);

const api = new EncryptedCloudDeviceApi(client, DEFAULT_CREDENTIALS);
const request = createCloudDeviceApiPaymentRequest();
await api.sync(merchantAccount, deviceId, request, { retries: 1 });

expect(requestBodies).toHaveLength(2);
expect(requestBodies[1]).toBe(requestBodies[0]);
expect(JSON.parse(requestBodies[1]).SaleToPOIRequest.MessageHeader.ServiceID)
.toBe(request.SaleToPOIRequest.MessageHeader.ServiceID);
});

test("sendEncryptedSync - encrypts request and decrypts response", async () => {
let capturedBody = "";
nock(BASE_URL)
Expand Down
2 changes: 1 addition & 1 deletion src/httpClient/httpURLConnectionClient.ts
Original file line number Diff line number Diff line change
Expand Up @@ -285,7 +285,7 @@ class HttpURLConnectionClient implements ClientInterface {
connectionRequest.on("timeout", (): void => {
connectionRequest.abort();
});
connectionRequest.on("error", (e) => reject(new ApiException(e.message)));
connectionRequest.on("error", (e: NodeJS.ErrnoException) => reject(new ApiException(e.message, 500, e.code)));
connectionRequest.write(json);
connectionRequest.end();
});
Expand Down
15 changes: 8 additions & 7 deletions src/services/clouddevice/cloudDeviceApi.ts
Original file line number Diff line number Diff line change
Expand Up @@ -17,11 +17,12 @@
* See the LICENSE file for more info.
*/

import getJsonResponse from "../../helpers/getJsonResponse";
import Service from "../../service";
import Client from "../../client";
import getJsonResponse from "../../helpers/getJsonResponse";
import { IRequest } from "../../typings/requestOptions";
import Resource from "../resource";
import requestWithRetries from "./requestWithRetries";
import { CloudDeviceApiAsyncResponse } from "../../typings/clouddevice/models";
import { CloudDeviceApiRequest } from "../../typings/clouddevice/models";
import { CloudDeviceApiResponse } from "../../typings/clouddevice/models";
Expand Down Expand Up @@ -54,10 +55,10 @@ export class CloudDeviceApi extends Service {
.replace("{" + "merchantAccount" + "}", encodeURIComponent(String(merchantAccount)))
.replace("{" + "deviceId" + "}", encodeURIComponent(String(deviceId)));
const resource = new Resource(this, endpoint);
const response = await getJsonResponse<CloudDeviceApiRequest, CloudDeviceApiResponse>(
const response = await requestWithRetries<CloudDeviceApiRequest, CloudDeviceApiResponse>(
resource,
cloudDeviceApiRequest,
{ ...requestOptions, method: "POST" }
JSON.stringify(cloudDeviceApiRequest),
{ ...requestOptions, method: "POST" },
);
return response;
}
Expand All @@ -75,10 +76,10 @@ export class CloudDeviceApi extends Service {
.replace("{" + "merchantAccount" + "}", encodeURIComponent(String(merchantAccount)))
.replace("{" + "deviceId" + "}", encodeURIComponent(String(deviceId)));
const resource = new Resource(this, endpoint);
const response = await getJsonResponse<CloudDeviceApiRequest, string>(
const response = await requestWithRetries<CloudDeviceApiRequest, string>(
resource,
cloudDeviceApiRequest,
{ ...requestOptions, method: "POST" }
JSON.stringify(cloudDeviceApiRequest),
{ ...requestOptions, method: "POST" },
);
const result = new CloudDeviceApiAsyncResponse();
if (typeof response === "string" && response.toLowerCase().trim() === "ok") {
Expand Down
10 changes: 5 additions & 5 deletions src/services/clouddevice/encryptedCloudDeviceApi.ts
Original file line number Diff line number Diff line change
Expand Up @@ -17,7 +17,6 @@
* See the LICENSE file for more info.
*/

import getJsonResponse from "../../helpers/getJsonResponse";
import Service from "../../service";
import Client from "../../client";
import { IRequest } from "../../typings/requestOptions";
Expand All @@ -30,6 +29,7 @@ import { CloudDeviceApiSecuredResponse } from "../../typings/clouddevice/cloudDe
import { EncryptionCredentialDetails } from "../../security/clouddevice/encryptionCredentialDetails";
import { NexoSecurityManager } from "../../security/clouddevice/nexoSecurityManager";
import { NexoSecurityException } from "../../security/clouddevice/nexoSecurityException";
import requestWithRetries from "./requestWithRetries";

/**
* Cloud Device API service with encrypted payloads.
Expand Down Expand Up @@ -84,9 +84,9 @@ export class EncryptedCloudDeviceApi extends Service {
.replace("{deviceId}", encodeURIComponent(deviceId));
const resource = new Resource(this, endpoint);

const response = await getJsonResponse<CloudDeviceApiSecuredRequest, CloudDeviceApiSecuredResponse>(
const response = await requestWithRetries<CloudDeviceApiSecuredRequest, CloudDeviceApiSecuredResponse>(
resource,
securedRequest,
JSON.stringify(securedRequest),
{ ...requestOptions, method: "POST" },
);

Expand Down Expand Up @@ -139,9 +139,9 @@ export class EncryptedCloudDeviceApi extends Service {
.replace("{deviceId}", encodeURIComponent(deviceId));
const resource = new Resource(this, endpoint);

const response = await getJsonResponse<CloudDeviceApiSecuredRequest, string>(
const response = await requestWithRetries<CloudDeviceApiSecuredRequest, string>(
resource,
securedRequest,
JSON.stringify(securedRequest),
{ ...requestOptions, method: "POST" },
);

Expand Down
35 changes: 35 additions & 0 deletions src/services/clouddevice/requestWithRetries.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,35 @@
import getJsonResponse from "../../helpers/getJsonResponse";
import Resource from "../resource";
import { IRequest } from "../../typings/requestOptions";
import ApiException from "../exception/apiException";

const MAX_CLOUD_DEVICE_RETRIES = 3;

const isRetryableTransportError = (error: unknown): boolean => {
if (!(error instanceof ApiException)) {
return false;
}

return error.code === "ECONNRESET" || error.message.includes("socket hang up");
};

export default async function requestWithRetries<T, R>(
resource: Resource,
requestBody: string,
requestOptions: IRequest.Options = {},
): Promise<R> {
const { retries = 0, ...httpOptions } = requestOptions;
const retryLimit = Number.isFinite(retries)
? Math.min(MAX_CLOUD_DEVICE_RETRIES, Math.max(0, Math.floor(retries)))
: 0;

for (let attempt = 0; ; attempt++) {
try {
return await getJsonResponse<T, R>(resource, requestBody, httpOptions);
} catch (error) {
if (attempt >= retryLimit || !isRetryableTransportError(error)) {
throw error;
}
}
}
}
4 changes: 3 additions & 1 deletion src/services/exception/apiException.ts
Original file line number Diff line number Diff line change
Expand Up @@ -22,13 +22,15 @@ import { ApiError } from "../../typings/apiError";
class ApiException implements Error {
public error!: ApiError;
public statusCode: number;
public readonly code?: string;
public readonly message: string;
public readonly name: string;

public constructor(message: string, statusCode = 500) {
public constructor(message: string, statusCode = 500, code?: string) {
this.name = "ApiException";
this.message = message;
this.statusCode = statusCode;
this.code = code;
}
}

Expand Down
2 changes: 1 addition & 1 deletion src/typings/requestOptions.ts
Original file line number Diff line number Diff line change
Expand Up @@ -28,6 +28,6 @@ export namespace IRequest {
export type Options = https.RequestOptions & {
idempotencyKey?: string;
params?: QueryString;
retries?: number;
};
}