Skip to content

Commit

Permalink
refactor: Update Error handling for Identity API Client (#959)
Browse files Browse the repository at this point in the history
  • Loading branch information
alexs-mparticle authored Dec 18, 2024
1 parent 3710586 commit bd9a63d
Show file tree
Hide file tree
Showing 5 changed files with 585 additions and 90 deletions.
3 changes: 3 additions & 0 deletions src/constants.ts
Original file line number Diff line number Diff line change
Expand Up @@ -201,4 +201,7 @@ export const MILLIS_IN_ONE_SEC = 1000;
export const HTTP_OK = 200 as const;
export const HTTP_ACCEPTED = 202 as const;
export const HTTP_BAD_REQUEST = 400 as const;
export const HTTP_UNAUTHORIZED = 401 as const;
export const HTTP_FORBIDDEN = 403 as const;
export const HTTP_NOT_FOUND = 404 as const;
export const HTTP_SERVER_ERROR = 500 as const;
179 changes: 123 additions & 56 deletions src/identityApiClient.ts
Original file line number Diff line number Diff line change
@@ -1,12 +1,12 @@
import Constants, { HTTP_ACCEPTED, HTTP_OK } from './constants';
import Constants, { HTTP_ACCEPTED, HTTP_BAD_REQUEST, HTTP_OK } from './constants';
import {
AsyncUploader,
FetchUploader,
XHRUploader,
IFetchPayload,
} from './uploaders';
import { CACHE_HEADER } from './identity-utils';
import { parseNumber } from './utils';
import { parseNumber, valueof } from './utils';
import {
IAliasCallback,
IAliasRequest,
Expand All @@ -15,7 +15,6 @@ import {
IIdentityAPIRequestData,
} from './identity.interfaces';
import {
Callback,
IdentityApiData,
MPID,
UserIdentities,
Expand Down Expand Up @@ -53,9 +52,8 @@ export interface IIdentityApiClient {
getIdentityResponseFromXHR: (response: XMLHttpRequest) => IIdentityResponse;
}

export interface IAliasResponseBody {
message?: string;
}
// A successfull Alias request will return a 202 with no body
export interface IAliasResponseBody {}

interface IdentityApiRequestPayload extends IFetchPayload {
headers: {
Expand All @@ -65,6 +63,23 @@ interface IdentityApiRequestPayload extends IFetchPayload {
};
}

type HTTP_STATUS_CODES = typeof HTTP_OK | typeof HTTP_ACCEPTED;

interface IdentityApiError {
code: string;
message: string;
}

interface IdentityApiErrorResponse {
Errors: IdentityApiError[],
ErrorCode: string,
StatusCode: valueof<HTTP_STATUS_CODES>;
RequestId: string;
}

// All Identity Api Responses have the same structure, except for Alias
interface IAliasErrorResponse extends IdentityApiError {}

export default function IdentityAPIClient(
this: IIdentityApiClient,
mpInstance: MParticleWebSDK
Expand Down Expand Up @@ -99,55 +114,72 @@ export default function IdentityAPIClient(
try {
const response: Response = await uploader.upload(uploadPayload);

let message: string;
let aliasResponseBody: IAliasResponseBody;

// FetchUploader returns the response as a JSON object that we have to await
if (response.json) {
// HTTP responses of 202, 200, and 403 do not have a response. response.json will always exist on a fetch, but can only be await-ed when the response is not empty, otherwise it will throw an error.
try {
aliasResponseBody = await response.json();
} catch (e) {
verbose('The request has no response body');
}
} else {
// https://go.mparticle.com/work/SQDSDKS-6568
// XHRUploader returns the response as a string that we need to parse
const xhrResponse = (response as unknown) as XMLHttpRequest;

aliasResponseBody = xhrResponse.responseText
? JSON.parse(xhrResponse.responseText)
: '';
}

let message: string;
let errorMessage: string;

switch (response.status) {
case HTTP_OK:
// A successfull Alias request will return without a body
case HTTP_ACCEPTED:
case HTTP_OK:
// https://go.mparticle.com/work/SQDSDKS-6670
message =
'Successfully sent forwarding stats to mParticle Servers';
message = 'Received Alias Response from server: ' + JSON.stringify(response.status);
break;
default:
// 400 has an error message, but 403 doesn't
if (aliasResponseBody?.message) {
errorMessage = aliasResponseBody.message;

// Our Alias Request API will 400 if there is an issue with the request body (ie timestamps are too far
// in the past or MPIDs don't exist).
// A 400 will return an error in the response body and will go through the happy path to report the error
case HTTP_BAD_REQUEST:
// response.json will always exist on a fetch, but can only be await-ed when the
// response is not empty, otherwise it will throw an error.
if (response.json) {
try {
aliasResponseBody = await response.json();
} catch (e) {
verbose('The request has no response body');
}
} else {
// https://go.mparticle.com/work/SQDSDKS-6568
// XHRUploader returns the response as a string that we need to parse
const xhrResponse = (response as unknown) as XMLHttpRequest;

aliasResponseBody = xhrResponse.responseText
? JSON.parse(xhrResponse.responseText)
: '';
}

const errorResponse: IAliasErrorResponse = aliasResponseBody as unknown as IAliasErrorResponse;

if (errorResponse?.message) {
errorMessage = errorResponse.message;
}

message =
'Issue with sending Alias Request to mParticle Servers, received HTTP Code of ' +
response.status;

if (errorResponse?.code) {
message += ' - ' + errorResponse.code;
}

break;

// Any unhandled errors, such as 500 or 429, will be caught here as well
default: {
throw new Error('Received HTTP Code of ' + response.status);
}

}

verbose(message);
invokeAliasCallback(aliasCallback, response.status, errorMessage);
} catch (e) {
const err = e as Error;
error('Error sending alias request to mParticle servers. ' + err);
const errorMessage = (e as Error).message || e.toString();
error('Error sending alias request to mParticle servers. ' + errorMessage);
invokeAliasCallback(
aliasCallback,
HTTPCodes.noHttpCoverage,
err.message
errorMessage,
);
}
};
Expand Down Expand Up @@ -197,33 +229,67 @@ export default function IdentityAPIClient(
},
body: JSON.stringify(identityApiRequest),
};
mpInstance._Store.identityCallInFlight = true;

try {
mpInstance._Store.identityCallInFlight = true;
const response: Response = await uploader.upload(fetchPayload);

let identityResponse: IIdentityResponse;
let message: string;

switch (response.status) {
case HTTP_ACCEPTED:
case HTTP_OK:

// Our Identity API will return a 400 error if there is an issue with the requeest body
// such as if the body is empty or one of the attributes is missing or malformed
// A 400 will return an error in the response body and will go through the happy path to report the error
case HTTP_BAD_REQUEST:

// FetchUploader returns the response as a JSON object that we have to await
if (response.json) {
// https://go.mparticle.com/work/SQDSDKS-6568
// FetchUploader returns the response as a JSON object that we have to await
const responseBody: IdentityResultBody = await response.json();

identityResponse = this.getIdentityResponseFromFetch(
response,
responseBody
);
} else {
identityResponse = this.getIdentityResponseFromXHR(
(response as unknown) as XMLHttpRequest
);
}

if (response.json) {
// https://go.mparticle.com/work/SQDSDKS-6568
// FetchUploader returns the response as a JSON object that we have to await
const responseBody: IdentityResultBody = await response.json();

identityResponse = this.getIdentityResponseFromFetch(
response,
responseBody
);
} else {
identityResponse = this.getIdentityResponseFromXHR(
(response as unknown) as XMLHttpRequest
);
if (identityResponse.status === HTTP_BAD_REQUEST) {
const errorResponse: IdentityApiErrorResponse = identityResponse.responseText as unknown as IdentityApiErrorResponse;
message = 'Issue with sending Identity Request to mParticle Servers, received HTTP Code of ' + identityResponse.status;

if (errorResponse?.Errors) {
const errorMessage = errorResponse.Errors.map((error) => error.message).join(', ');
message += ' - ' + errorMessage;
}

} else {
message = 'Received Identity Response from server: ';
message += JSON.stringify(identityResponse.responseText);
}

break;

// Our Identity API will return:
// - 401 if the `x-mp-key` is incorrect or missing
// - 403 if the there is a permission or account issue related to the `x-mp-key`
// 401 and 403 have no response bodies and should be rejected outright
default: {
throw new Error('Received HTTP Code of ' + response.status);
}
}

verbose(
'Received Identity Response from server: ' +
JSON.stringify(identityResponse.responseText)
);
mpInstance._Store.identityCallInFlight = false;

verbose(message);
parseIdentityResponse(
identityResponse,
previousMPID,
Expand All @@ -234,15 +300,16 @@ export default function IdentityAPIClient(
false
);
} catch (err) {
mpInstance._Store.identityCallInFlight = false;

const errorMessage = (err as Error).message || err.toString();

mpInstance._Store.identityCallInFlight = false;
error('Error sending identity request to servers' + ' - ' + errorMessage);
invokeCallback(
callback,
HTTPCodes.noHttpCoverage,
errorMessage,
);
error('Error sending identity request to servers' + ' - ' + err);
}
};

Expand Down
4 changes: 3 additions & 1 deletion test/src/config/utils.js
Original file line number Diff line number Diff line change
Expand Up @@ -634,7 +634,8 @@ var pluses = /\+/g,
hasIdentifyReturned = () => {
return window.mParticle.Identity.getCurrentUser()?.getMPID() === testMPID;
},
hasIdentityCallInflightReturned = () => !mParticle.getInstance()?._Store?.identityCallInFlight;
hasIdentityCallInflightReturned = () => !mParticle.getInstance()?._Store?.identityCallInFlight,
hasConfigLoaded = () => !!mParticle.getInstance()?._Store?.configurationLoaded

var TestsCore = {
getLocalStorageProducts: getLocalStorageProducts,
Expand Down Expand Up @@ -663,6 +664,7 @@ var TestsCore = {
fetchMockSuccess: fetchMockSuccess,
hasIdentifyReturned: hasIdentifyReturned,
hasIdentityCallInflightReturned,
hasConfigLoaded,
};

export default TestsCore;
40 changes: 17 additions & 23 deletions test/src/tests-core-sdk.js
Original file line number Diff line number Diff line change
Expand Up @@ -12,7 +12,13 @@ const DefaultConfig = Constants.DefaultConfig,
findEventFromRequest = Utils.findEventFromRequest,
findBatch = Utils.findBatch;

const { waitForCondition, fetchMockSuccess, hasIdentifyReturned, hasIdentityCallInflightReturned } = Utils;
const {
waitForCondition,
fetchMockSuccess,
hasIdentifyReturned,
hasIdentityCallInflightReturned,
hasConfigLoaded,
} = Utils;

describe('core SDK', function() {
beforeEach(function() {
Expand Down Expand Up @@ -1126,7 +1132,7 @@ describe('core SDK', function() {
})
});

it('should initialize and log events even with a failed /config fetch and empty config', function async(done) {
it('should initialize and log events even with a failed /config fetch and empty config', async () => {
// this instance occurs when self hosting and the user only passes an object into init
mParticle._resetForTests(MPConfig);

Expand All @@ -1152,12 +1158,7 @@ describe('core SDK', function() {

mParticle.init(apiKey, window.mParticle.config);

waitForCondition(() => {
return (
mParticle.getInstance()._Store.configurationLoaded === true
);
})
.then(() => {
await waitForCondition(hasConfigLoaded);
// fetching the config is async and we need to wait for it to finish
mParticle.getInstance()._Store.isInitialized.should.equal(true);

Expand All @@ -1170,23 +1171,16 @@ describe('core SDK', function() {
mParticle.Identity.identify({
userIdentities: { customerid: 'test' },
});
waitForCondition(() => {
return (
mParticle.Identity.getCurrentUser()?.getMPID() === 'MPID1'
);
})
.then(() => {
mParticle.logEvent('Test Event');
const testEvent = findEventFromRequest(
fetchMock.calls(),
'Test Event'
);

testEvent.should.be.ok();
await waitForCondition(() => mParticle.Identity.getCurrentUser()?.getMPID() === 'MPID1');

done();
});
});
mParticle.logEvent('Test Event');
const testEvent = findEventFromRequest(
fetchMock.calls(),
'Test Event'
);

testEvent.should.be.ok();
});

it('should initialize without a config object passed to init', async function() {
Expand Down
Loading

0 comments on commit bd9a63d

Please sign in to comment.