Skip to content
Merged
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
29 changes: 29 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
@@ -1,5 +1,33 @@
# CHANGELOG

## v5.2.1

### API Versions

| API Name | API Version |
|----------|-------------|
| shopper-login | 1.46.0 |
| shopper-baskets | 1.11.0 |
| shopper-baskets | 2.5.1 |
| shopper-configurations | 1.2.0 |
| shopper-consents | 1.1.4 |
| shopper-context | 1.1.3 |
| shopper-customers | 1.7.0 |
| shopper-experience | 1.3.0 |
| shopper-gift-certificates | 1.2.0 |
| shopper-orders | 1.12.1 |
| shopper-payments | 1.4.0 |
| shopper-products | 1.3.0 |
| shopper-promotions | 1.2.0 |
| shopper-search | 1.8.0 |
| shopper-seo | 1.0.17 |
| shopper-stores | 1.2.0 |

### Enhancements

- Add automatic maintenance mode detection via `throwOnMaintenanceHeader` client config option. When enabled, the SDK throws `MaintenanceError` (503) if the server responds with `sfdc_maintenance` header set to `'system'` or `'site'`. This feature is opt-in and fully backward compatible.


## v5.2.0

### API Versions
Expand All @@ -23,6 +51,7 @@
| shopper-seo | 1.0.17 |
| shopper-stores | 1.2.0 |


## v5.1.0

### API Versions
Expand Down
34 changes: 34 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -138,6 +138,40 @@ try {
}
```

#### `throwOnMaintenanceHeader`

When `true`, the SDK automatically detects maintenance mode by checking the `sfdc_maintenance` response header. If the header value is `'system'` or `'site'`, the SDK throws a `MaintenanceError` with status 503. This is useful for handling scheduled maintenance windows and displaying appropriate messaging to users. By default, this flag is `false` for backwards compatibility.

```js
import {ShopperProducts, helpers} from 'commerce-sdk-isomorphic';
const {MaintenanceError} = helpers;

const config = {
throwOnMaintenanceHeader: true,
// rest of the config object...
};

const shopperProducts = new ShopperProducts(config);

// in an async function
try {
const product = await shopperProducts.getProduct({
parameters: {id: 'product-id'},
});
} catch (e) {
if (e instanceof MaintenanceError) {
console.log(`Service in maintenance: ${e.maintenanceType}`); // 'system' or 'site'
console.log(`Status: ${e.status}`); // 503
// Display maintenance page to user
} else {
// Handle other errors
console.error('API error:', e);
}
}
```

**Note:** The maintenance check occurs before other error handling and applies even when using `rawResponse: true`.

#### Additional Config Settings

* `headers`: Headers to include with API requests.
Expand Down
2 changes: 1 addition & 1 deletion package.json
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
{
"name": "commerce-sdk-isomorphic",
"version": "5.2.0",
"version": "5.2.1",
"private": false,
"description": "Salesforce Commerce SDK Isomorphic",
"bugs": {
Expand Down
1 change: 1 addition & 0 deletions src/static/clientConfig.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -26,6 +26,7 @@ describe('ClientConfig constructor', () => {
proxy: 'https://proxy.com',
transformRequest: ClientConfig.defaults.transformRequest,
throwOnBadResponse: false,
throwOnMaintenanceHeader: false,
fetch: fetch as FetchFunction,
};
expect(new ClientConfig(init)).toEqual({...init});
Expand Down
4 changes: 4 additions & 0 deletions src/static/clientConfig.ts
Original file line number Diff line number Diff line change
Expand Up @@ -41,6 +41,7 @@ export interface ClientConfigInit<Params extends BaseUriParameters> {
headers: {[key: string]: string}
) => Required<FetchOptions>['body'];
throwOnBadResponse?: boolean;
throwOnMaintenanceHeader?: boolean;
}

/**
Expand All @@ -67,6 +68,8 @@ export default class ClientConfig<Params extends BaseUriParameters>

public throwOnBadResponse: boolean;

public throwOnMaintenanceHeader: boolean;

constructor(config: ClientConfigInit<Params>) {
this.headers = {...config.headers};
this.parameters = {...config.parameters};
Expand All @@ -89,6 +92,7 @@ export default class ClientConfig<Params extends BaseUriParameters>
this.proxy = config.proxy;
}
this.throwOnBadResponse = !!config.throwOnBadResponse;
this.throwOnMaintenanceHeader = !!config.throwOnMaintenanceHeader;

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Why not just use throwOnBadResponse flag instead of using new flag. when throwOnBadResponse=true, the current code already throws error. We can throw MaintenanceError in this case

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

This is actually on purpose to maintain backward compatibility. One may just enable this option while not having exceptions for the other errors.


this.fetch = config.fetch;
}
Expand Down
136 changes: 136 additions & 0 deletions src/static/helpers/fetchHelper.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,8 @@
import {Response} from 'node-fetch';
import * as environment from './environment';
import ClientConfig from '../clientConfig';
import MaintenanceError from '../maintenanceError';
import ResponseError from '../responseError';
import {doFetch, encodeSCAPISpecialCharacters} from './fetchHelper';

describe('doFetch', () => {
Expand Down Expand Up @@ -158,6 +160,140 @@
expect.objectContaining(clientConfig.fetchOptions)
);
});

describe('maintenance header check', () => {
test('throws MaintenanceError when sfdc_maintenance header is "system"', async () => {
nock(basePath)
.post(endpointPath)
.query({siteId: 'site_id'})
.reply(200, responseBody, {sfdc_maintenance: 'system'});

const copyClientConfig = {...clientConfig, throwOnMaintenanceHeader: true};

Check warning on line 171 in src/static/helpers/fetchHelper.test.ts

View workflow job for this annotation

GitHub Actions / linux-tests (20)

Replace `...clientConfig,·throwOnMaintenanceHeader:·true` with `⏎········...clientConfig,⏎········throwOnMaintenanceHeader:·true,⏎······`

Check warning on line 171 in src/static/helpers/fetchHelper.test.ts

View workflow job for this annotation

GitHub Actions / linux-tests (22)

Replace `...clientConfig,·throwOnMaintenanceHeader:·true` with `⏎········...clientConfig,⏎········throwOnMaintenanceHeader:·true,⏎······`

try {
await doFetch(url, options, copyClientConfig);
fail('Expected MaintenanceError to be thrown');
} catch (error) {
expect(error).toBeInstanceOf(MaintenanceError);
if (error instanceof MaintenanceError) {
expect(error.message).toBe('Service unavailable due to system maintenance');

Check warning on line 179 in src/static/helpers/fetchHelper.test.ts

View workflow job for this annotation

GitHub Actions / linux-tests (20)

Replace `'Service·unavailable·due·to·system·maintenance'` with `⏎············'Service·unavailable·due·to·system·maintenance'⏎··········`

Check warning on line 179 in src/static/helpers/fetchHelper.test.ts

View workflow job for this annotation

GitHub Actions / linux-tests (22)

Replace `'Service·unavailable·due·to·system·maintenance'` with `⏎············'Service·unavailable·due·to·system·maintenance'⏎··········`
}
}
});

test('throws MaintenanceError when sfdc_maintenance header is "site"', async () => {
nock(basePath)
.post(endpointPath)
.query({siteId: 'site_id'})
.reply(200, responseBody, {sfdc_maintenance: 'site'});

const copyClientConfig = {...clientConfig, throwOnMaintenanceHeader: true};

Check warning on line 190 in src/static/helpers/fetchHelper.test.ts

View workflow job for this annotation

GitHub Actions / linux-tests (20)

Replace `...clientConfig,·throwOnMaintenanceHeader:·true` with `⏎········...clientConfig,⏎········throwOnMaintenanceHeader:·true,⏎······`

Check warning on line 190 in src/static/helpers/fetchHelper.test.ts

View workflow job for this annotation

GitHub Actions / linux-tests (22)

Replace `...clientConfig,·throwOnMaintenanceHeader:·true` with `⏎········...clientConfig,⏎········throwOnMaintenanceHeader:·true,⏎······`

try {
await doFetch(url, options, copyClientConfig);
fail('Expected MaintenanceError to be thrown');
} catch (error) {
expect(error).toBeInstanceOf(MaintenanceError);
if (error instanceof MaintenanceError) {
expect(error.message).toBe('Service unavailable due to site maintenance');

Check warning on line 198 in src/static/helpers/fetchHelper.test.ts

View workflow job for this annotation

GitHub Actions / linux-tests (20)

Replace `'Service·unavailable·due·to·site·maintenance'` with `⏎············'Service·unavailable·due·to·site·maintenance'⏎··········`

Check warning on line 198 in src/static/helpers/fetchHelper.test.ts

View workflow job for this annotation

GitHub Actions / linux-tests (22)

Replace `'Service·unavailable·due·to·site·maintenance'` with `⏎············'Service·unavailable·due·to·site·maintenance'⏎··········`
}
}
});

test('does not throw when sfdc_maintenance header has different value', async () => {
nock(basePath)
.post(endpointPath)
.query({siteId: 'site_id'})
.reply(200, responseBody, {sfdc_maintenance: 'other'});

const copyClientConfig = {...clientConfig, throwOnMaintenanceHeader: true};

Check warning on line 209 in src/static/helpers/fetchHelper.test.ts

View workflow job for this annotation

GitHub Actions / linux-tests (20)

Replace `...clientConfig,·throwOnMaintenanceHeader:·true` with `⏎········...clientConfig,⏎········throwOnMaintenanceHeader:·true,⏎······`

Check warning on line 209 in src/static/helpers/fetchHelper.test.ts

View workflow job for this annotation

GitHub Actions / linux-tests (22)

Replace `...clientConfig,·throwOnMaintenanceHeader:·true` with `⏎········...clientConfig,⏎········throwOnMaintenanceHeader:·true,⏎······`
const data = await doFetch(url, options, copyClientConfig);
expect(data).toEqual(responseBody);
});

test('does not throw when throwOnMaintenanceHeader is false', async () => {
nock(basePath)
.post(endpointPath)
.query({siteId: 'site_id'})
.reply(200, responseBody, {sfdc_maintenance: 'system'});

const copyClientConfig = {...clientConfig, throwOnMaintenanceHeader: false};

Check warning on line 220 in src/static/helpers/fetchHelper.test.ts

View workflow job for this annotation

GitHub Actions / linux-tests (20)

Replace `...clientConfig,·throwOnMaintenanceHeader:·false` with `⏎········...clientConfig,⏎········throwOnMaintenanceHeader:·false,⏎······`

Check warning on line 220 in src/static/helpers/fetchHelper.test.ts

View workflow job for this annotation

GitHub Actions / linux-tests (22)

Replace `...clientConfig,·throwOnMaintenanceHeader:·false` with `⏎········...clientConfig,⏎········throwOnMaintenanceHeader:·false,⏎······`
const data = await doFetch(url, options, copyClientConfig);
expect(data).toEqual(responseBody);
});

test('does not throw when throwOnMaintenanceHeader is not set', async () => {
nock(basePath)
.post(endpointPath)
.query({siteId: 'site_id'})
.reply(200, responseBody, {sfdc_maintenance: 'system'});

const data = await doFetch(url, options, clientConfig);
expect(data).toEqual(responseBody);
});

test('throws MaintenanceError even when rawResponse is true', async () => {
nock(basePath)
.post(endpointPath)
.query({siteId: 'site_id'})
.reply(200, responseBody, {sfdc_maintenance: 'system'});

const copyClientConfig = {...clientConfig, throwOnMaintenanceHeader: true};

Check warning on line 241 in src/static/helpers/fetchHelper.test.ts

View workflow job for this annotation

GitHub Actions / linux-tests (20)

Replace `...clientConfig,·throwOnMaintenanceHeader:·true` with `⏎········...clientConfig,⏎········throwOnMaintenanceHeader:·true,⏎······`

Check warning on line 241 in src/static/helpers/fetchHelper.test.ts

View workflow job for this annotation

GitHub Actions / linux-tests (22)

Replace `...clientConfig,·throwOnMaintenanceHeader:·true` with `⏎········...clientConfig,⏎········throwOnMaintenanceHeader:·true,⏎······`

try {
await doFetch(url, options, copyClientConfig, true);
fail('Expected MaintenanceError to be thrown');
} catch (error) {
expect(error).toBeInstanceOf(MaintenanceError);
}
});

test('MaintenanceError contains correct properties', async () => {
nock(basePath)
.post(endpointPath)
.query({siteId: 'site_id'})
.reply(200, responseBody, {sfdc_maintenance: 'site'});

const copyClientConfig = {...clientConfig, throwOnMaintenanceHeader: true};

try {
await doFetch(url, options, copyClientConfig);
fail('Expected MaintenanceError to be thrown');
} catch (error) {
expect(error).toBeInstanceOf(MaintenanceError);
if (error instanceof MaintenanceError) {
expect(error.status).toBe(503);
expect(error.maintenanceType).toBe('site');
expect(error.name).toBe('MaintenanceError');
expect(error.response).toBeInstanceOf(Response);
}
}
});

test('throws MaintenanceError before throwOnBadResponse', async () => {
nock(basePath)
.post(endpointPath)
.query({siteId: 'site_id'})
.reply(400, responseBody, {sfdc_maintenance: 'system'});

const copyClientConfig = {
...clientConfig,
throwOnMaintenanceHeader: true,
throwOnBadResponse: true,
};

try {
await doFetch(url, options, copyClientConfig);
fail('Expected MaintenanceError to be thrown');
} catch (error) {
expect(error).toBeInstanceOf(MaintenanceError);
expect(error).not.toBeInstanceOf(ResponseError);
if (error instanceof Error) {
expect(error.message).not.toContain('400 Bad Request');
}
}
});
});
});

describe('encodeSCAPISpecialCharacters', () => {
Expand Down
10 changes: 10 additions & 0 deletions src/static/helpers/fetchHelper.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@ import {BodyInit} from 'node-fetch';
import {BaseUriParameters} from '.';
import type {FetchOptions} from '../clientConfig';
import ResponseError from '../responseError';
import MaintenanceError from '../maintenanceError';
import {fetch} from './environment';
import {ClientConfigInit} from '../clientConfig';

Expand Down Expand Up @@ -55,6 +56,15 @@ export const doFetch = async <Params extends BaseUriParameters>(
const fetcher = clientConfig?.fetch || fetch;

const response = await fetcher(url, requestOptions);

// Check for maintenance header before processing response
if (clientConfig?.throwOnMaintenanceHeader) {
const maintenanceHeader = response.headers.get('sfdc_maintenance');
if (maintenanceHeader === 'system' || maintenanceHeader === 'site') {
throw new MaintenanceError(response, maintenanceHeader);
}
}

if (rawResponse) {
return response;
}
Expand Down
1 change: 1 addition & 0 deletions src/static/helpers/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -11,3 +11,4 @@ export * from './slasHelper';
export * from './types';
export * from './customApi';
export * from './fetchHelper';
export {default as MaintenanceError} from '../maintenanceError';
70 changes: 70 additions & 0 deletions src/static/maintenanceError.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,70 @@
/*
* Copyright (c) 2025, Salesforce, Inc.
* All rights reserved.
* SPDX-License-Identifier: BSD-3-Clause
* For full license text, see the LICENSE file in the repo root or https://opensource.org/licenses/BSD-3-Clause
*/
import {Response} from 'node-fetch';
import MaintenanceError from './maintenanceError';

describe('MaintenanceError', () => {
test('creates error with system maintenance type', () => {
const response = new Response('{}', {
status: 200,
headers: {sfdc_maintenance: 'system'},
});

const error = new MaintenanceError(response, 'system');

expect(error).toBeInstanceOf(Error);
expect(error).toBeInstanceOf(MaintenanceError);
expect(error.name).toBe('MaintenanceError');
expect(error.message).toBe('Service unavailable due to system maintenance');
expect(error.status).toBe(503);
expect(error.maintenanceType).toBe('system');
expect(error.response).toBe(response);
});

test('creates error with site maintenance type', () => {
const response = new Response('{}', {
status: 200,
headers: {sfdc_maintenance: 'site'},
});

const error = new MaintenanceError(response, 'site');

expect(error).toBeInstanceOf(Error);
expect(error).toBeInstanceOf(MaintenanceError);
expect(error.name).toBe('MaintenanceError');
expect(error.message).toBe('Service unavailable due to site maintenance');
expect(error.status).toBe(503);
expect(error.maintenanceType).toBe('site');
expect(error.response).toBe(response);
});

test('error can be caught and properties accessed', () => {
const response = new Response('{}', {status: 200});
const error = new MaintenanceError(response, 'system');

try {
throw error;
} catch (caught) {
expect(caught).toBeInstanceOf(MaintenanceError);
if (caught instanceof MaintenanceError) {
expect(caught.status).toBe(503);
expect(caught.maintenanceType).toBe('system');
expect(caught.response).toBe(response);
}
}
});

test('error has correct property types', () => {
const response = new Response('{}', {status: 200});
const error = new MaintenanceError(response, 'system');

expect(error.status).toBe(503);
expect(typeof error.status).toBe('number');
expect(error.maintenanceType).toBe('system');
expect(['system', 'site']).toContain(error.maintenanceType);
});
});
Loading
Loading