Skip to content

Commit 6cd411a

Browse files
Protect setContentsJsonString - if localstorage is full, delete all c… (#246)
* Protect setContentsJsonString - if localstorage is full, delete all configuration keys and make a 2nd attempt to write. (FFL-718) * add spec * storage re-throws if fails a 2nd time * correctly mock test to throw on setEntries * clear in try * key? * use applicationLogger * any
1 parent 7771ace commit 6cd411a

10 files changed

+462
-8
lines changed
Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,11 @@
1+
<!-- Do not edit this file. It is automatically generated by API Documenter. -->
2+
3+
[Home](./index.md) &gt; [@eppo/js-client-sdk](./js-client-sdk.md) &gt; [IClientConfigSync](./js-client-sdk.iclientconfigsync.md) &gt; [configFetchedAt](./js-client-sdk.iclientconfigsync.configfetchedat.md)
4+
5+
## IClientConfigSync.configFetchedAt property
6+
7+
**Signature:**
8+
9+
```typescript
10+
configFetchedAt?: string;
11+
```
Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,11 @@
1+
<!-- Do not edit this file. It is automatically generated by API Documenter. -->
2+
3+
[Home](./index.md) &gt; [@eppo/js-client-sdk](./js-client-sdk.md) &gt; [IClientConfigSync](./js-client-sdk.iclientconfigsync.md) &gt; [configPublishedAt](./js-client-sdk.iclientconfigsync.configpublishedat.md)
4+
5+
## IClientConfigSync.configPublishedAt property
6+
7+
**Signature:**
8+
9+
```typescript
10+
configPublishedAt?: string;
11+
```

docs/js-client-sdk.iclientconfigsync.md

Lines changed: 38 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -72,6 +72,44 @@ IBanditLogger
7272
_(Optional)_
7373

7474

75+
</td></tr>
76+
<tr><td>
77+
78+
[configFetchedAt?](./js-client-sdk.iclientconfigsync.configfetchedat.md)
79+
80+
81+
</td><td>
82+
83+
84+
</td><td>
85+
86+
string
87+
88+
89+
</td><td>
90+
91+
_(Optional)_
92+
93+
94+
</td></tr>
95+
<tr><td>
96+
97+
[configPublishedAt?](./js-client-sdk.iclientconfigsync.configpublishedat.md)
98+
99+
100+
</td><td>
101+
102+
103+
</td><td>
104+
105+
string
106+
107+
108+
</td><td>
109+
110+
_(Optional)_
111+
112+
75113
</td></tr>
76114
<tr><td>
77115

package.json

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
{
22
"name": "@eppo/js-client-sdk",
3-
"version": "3.16.1",
3+
"version": "3.16.2",
44
"description": "Eppo SDK for client-side JavaScript applications",
55
"main": "dist/index.js",
66
"files": [

src/isolatable-hybrid.store.spec.ts

Lines changed: 46 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@ import {
22
IsolatableHybridConfigurationStore,
33
ServingStoreUpdateStrategy,
44
} from './isolatable-hybrid.store';
5+
import { StorageFullUnableToWrite } from './string-valued.store';
56

67
describe('IsolatableHybridConfigurationStore', () => {
78
const syncStoreMock = {
@@ -130,4 +131,49 @@ describe('IsolatableHybridConfigurationStore', () => {
130131
});
131132
},
132133
);
134+
135+
describe('StorageFullUnableToWrite exception handling', () => {
136+
const store = new IsolatableHybridConfigurationStore(syncStoreMock, asyncStoreMock, 'always');
137+
138+
beforeEach(() => {
139+
jest.resetAllMocks();
140+
});
141+
142+
it('should continue operating even when async store fails with StorageFullUnableToWrite', async () => {
143+
const entries = { key1: 'value1' };
144+
asyncStoreMock.isInitialized.mockReturnValue(true);
145+
asyncStoreMock.isExpired.mockReturnValue(true);
146+
asyncStoreMock.setEntries.mockRejectedValue(new StorageFullUnableToWrite());
147+
syncStoreMock.setEntries.mockResolvedValue(undefined);
148+
syncStoreMock.getKeys.mockReturnValue([]);
149+
150+
// Should not throw - be resilient to async store failures
151+
await expect(store.setEntries(entries)).resolves.not.toThrow();
152+
153+
// Sync store should still be updated for resilience
154+
expect(syncStoreMock.setEntries).toHaveBeenCalledWith(entries);
155+
});
156+
157+
it('should handle StorageFullUnableToWrite during initialization', async () => {
158+
asyncStoreMock.isInitialized.mockReturnValue(false);
159+
asyncStoreMock.setEntries.mockRejectedValue(new StorageFullUnableToWrite());
160+
161+
await expect(store.init()).resolves.not.toThrow();
162+
});
163+
164+
it('should handle async store failures gracefully', async () => {
165+
const entries = { key1: 'value1' };
166+
asyncStoreMock.isInitialized.mockReturnValue(true);
167+
asyncStoreMock.isExpired.mockReturnValue(true);
168+
asyncStoreMock.setEntries.mockRejectedValue(new StorageFullUnableToWrite());
169+
syncStoreMock.setEntries.mockResolvedValue(undefined);
170+
syncStoreMock.getKeys.mockReturnValue([]);
171+
172+
// Should not throw even if async store fails
173+
await expect(store.setEntries(entries)).resolves.not.toThrow();
174+
175+
expect(asyncStoreMock.setEntries).toHaveBeenCalledWith(entries);
176+
expect(syncStoreMock.setEntries).toHaveBeenCalledWith(entries);
177+
});
178+
});
133179
});

src/isolatable-hybrid.store.ts

Lines changed: 12 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,9 @@
1-
import { HybridConfigurationStore, IAsyncStore, ISyncStore } from '@eppo/js-client-sdk-common';
1+
import {
2+
applicationLogger,
3+
HybridConfigurationStore,
4+
IAsyncStore,
5+
ISyncStore,
6+
} from '@eppo/js-client-sdk-common';
27

38
export type ServingStoreUpdateStrategy = 'always' | 'expired' | 'empty';
49

@@ -21,8 +26,12 @@ export class IsolatableHybridConfigurationStore<T> extends HybridConfigurationSt
2126
/** @Override */
2227
public async setEntries(entries: Record<string, T>): Promise<boolean> {
2328
if (this.persistentStore) {
24-
// always update persistent store
25-
await this.persistentStore.setEntries(entries);
29+
try {
30+
// always update persistent store
31+
await this.persistentStore.setEntries(entries);
32+
} catch (e) {
33+
applicationLogger.warn(`Failed to setEntries on persistent store: ${e}`);
34+
}
2635
}
2736

2837
const persistentStoreIsExpired =

src/local-storage-engine.spec.ts

Lines changed: 158 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,158 @@
1+
import { LocalStorageEngine } from './local-storage-engine';
2+
import { StorageFullUnableToWrite, LocalStorageUnknownFailure } from './string-valued.store';
3+
4+
describe('LocalStorageEngine', () => {
5+
let mockLocalStorage: Storage;
6+
let engine: LocalStorageEngine;
7+
8+
beforeEach(() => {
9+
mockLocalStorage = {
10+
get length() {
11+
return this._length || 0;
12+
},
13+
_length: 0,
14+
clear: jest.fn(),
15+
getItem: jest.fn(),
16+
key: jest.fn(),
17+
removeItem: jest.fn(),
18+
setItem: jest.fn(),
19+
} as Storage & { _length: number };
20+
engine = new LocalStorageEngine(mockLocalStorage, 'test');
21+
});
22+
23+
describe('setContentsJsonString', () => {
24+
it('should set item successfully when no error occurs', async () => {
25+
await engine.setContentsJsonString('test-config');
26+
27+
expect(mockLocalStorage.setItem).toHaveBeenCalledWith(
28+
'eppo-configuration-test',
29+
'test-config',
30+
);
31+
});
32+
33+
it('should clear eppo-configuration keys and retry on quota exceeded error', async () => {
34+
const quotaError = new DOMException('Quota exceeded', 'QuotaExceededError');
35+
Object.defineProperty(quotaError, 'code', { value: DOMException.QUOTA_EXCEEDED_ERR });
36+
37+
// Mock localStorage to have some eppo-configuration keys
38+
(mockLocalStorage as Storage & { _length: number })._length = 3;
39+
(mockLocalStorage.key as jest.Mock)
40+
.mockReturnValueOnce('eppo-configuration-old')
41+
.mockReturnValueOnce('other-key')
42+
.mockReturnValueOnce('eppo-configuration-meta-old');
43+
44+
// First call fails with quota error, second call succeeds
45+
(mockLocalStorage.setItem as jest.Mock)
46+
.mockImplementationOnce(() => {
47+
throw quotaError;
48+
})
49+
.mockImplementationOnce(jest.fn()); // Success on retry
50+
51+
await engine.setContentsJsonString('test-config');
52+
53+
// Verify keys were collected and removed
54+
expect(mockLocalStorage.removeItem).toHaveBeenCalledWith('eppo-configuration-old');
55+
expect(mockLocalStorage.removeItem).toHaveBeenCalledWith('eppo-configuration-meta-old');
56+
expect(mockLocalStorage.removeItem).not.toHaveBeenCalledWith('other-key');
57+
58+
// Verify setItem was called twice (original + retry)
59+
expect(mockLocalStorage.setItem).toHaveBeenCalledTimes(2);
60+
expect(mockLocalStorage.setItem).toHaveBeenLastCalledWith(
61+
'eppo-configuration-test',
62+
'test-config',
63+
);
64+
});
65+
66+
it('should throw StorageFullUnableToWrite when retry fails', async () => {
67+
const quotaError = new DOMException('Quota exceeded', 'QuotaExceededError');
68+
Object.defineProperty(quotaError, 'code', { value: DOMException.QUOTA_EXCEEDED_ERR });
69+
70+
(mockLocalStorage as Storage & { _length: number })._length = 1;
71+
(mockLocalStorage.key as jest.Mock).mockReturnValue('eppo-configuration-old');
72+
73+
// Both calls fail with quota error
74+
(mockLocalStorage.setItem as jest.Mock).mockImplementation(() => {
75+
throw quotaError;
76+
});
77+
78+
// Should throw StorageFullUnableToWrite
79+
await expect(engine.setContentsJsonString('test-config')).rejects.toThrow(
80+
StorageFullUnableToWrite,
81+
);
82+
83+
expect(mockLocalStorage.removeItem).toHaveBeenCalledWith('eppo-configuration-old');
84+
expect(mockLocalStorage.setItem).toHaveBeenCalledTimes(2);
85+
});
86+
87+
it('should handle quota exceeded error by name', async () => {
88+
const quotaError = new DOMException('Quota exceeded');
89+
Object.defineProperty(quotaError, 'name', { value: 'QuotaExceededError' });
90+
91+
(mockLocalStorage as Storage & { _length: number })._length = 1;
92+
(mockLocalStorage.key as jest.Mock).mockReturnValue('eppo-configuration-test-key');
93+
94+
(mockLocalStorage.setItem as jest.Mock)
95+
.mockImplementationOnce(() => {
96+
throw quotaError;
97+
})
98+
.mockImplementationOnce(jest.fn()); // Success on retry
99+
100+
await engine.setContentsJsonString('test-config');
101+
102+
expect(mockLocalStorage.removeItem).toHaveBeenCalledWith('eppo-configuration-test-key');
103+
expect(mockLocalStorage.setItem).toHaveBeenCalledTimes(2);
104+
});
105+
106+
it('should throw LocalStorageUnknownFailure for non-quota errors', async () => {
107+
const securityError = new DOMException('Security error', 'SecurityError');
108+
109+
(mockLocalStorage.setItem as jest.Mock).mockImplementation(() => {
110+
throw securityError;
111+
});
112+
113+
// Should throw LocalStorageUnknownFailure for non-quota errors with original error preserved
114+
const error = await engine.setContentsJsonString('test-config').catch((e) => e);
115+
expect(error).toBeInstanceOf(LocalStorageUnknownFailure);
116+
expect(error.message).toContain('Security error');
117+
expect(error.originalError).toBeTruthy();
118+
expect(error.originalError.message).toBe('Security error');
119+
120+
expect(mockLocalStorage.removeItem).not.toHaveBeenCalled();
121+
expect(mockLocalStorage.setItem).toHaveBeenCalledTimes(1);
122+
});
123+
});
124+
125+
describe('clearEppoConfigurationKeys', () => {
126+
it('should only remove keys starting with eppo-configuration', async () => {
127+
const quotaError = new DOMException('Quota exceeded', 'QuotaExceededError');
128+
Object.defineProperty(quotaError, 'code', { value: DOMException.QUOTA_EXCEEDED_ERR });
129+
130+
(mockLocalStorage as Storage & { _length: number })._length = 5;
131+
(mockLocalStorage.key as jest.Mock)
132+
.mockReturnValueOnce('eppo-configuration')
133+
.mockReturnValueOnce('eppo-configuration-meta')
134+
.mockReturnValueOnce('eppo-overrides')
135+
.mockReturnValueOnce('other-app-data')
136+
.mockReturnValueOnce('eppo-configuration-custom');
137+
138+
(mockLocalStorage.setItem as jest.Mock)
139+
.mockImplementationOnce(() => {
140+
throw quotaError;
141+
})
142+
.mockImplementationOnce(jest.fn()); // Success on retry
143+
144+
await engine.setContentsJsonString('test-config');
145+
146+
// Should remove eppo-configuration keys
147+
expect(mockLocalStorage.removeItem).toHaveBeenCalledWith('eppo-configuration');
148+
expect(mockLocalStorage.removeItem).toHaveBeenCalledWith('eppo-configuration-meta');
149+
expect(mockLocalStorage.removeItem).toHaveBeenCalledWith('eppo-configuration-custom');
150+
151+
// Should not remove other keys
152+
expect(mockLocalStorage.removeItem).not.toHaveBeenCalledWith('eppo-overrides');
153+
expect(mockLocalStorage.removeItem).not.toHaveBeenCalledWith('other-app-data');
154+
155+
expect(mockLocalStorage.removeItem).toHaveBeenCalledTimes(3);
156+
});
157+
});
158+
});

src/local-storage-engine.ts

Lines changed: 62 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,9 @@
11
import { CONFIGURATION_KEY, META_KEY } from './storage-key-constants';
2-
import { IStringStorageEngine } from './string-valued.store';
2+
import {
3+
IStringStorageEngine,
4+
StorageFullUnableToWrite,
5+
LocalStorageUnknownFailure,
6+
} from './string-valued.store';
37

48
/**
59
* Local storage implementation of a string-valued store for storing a configuration and its metadata.
@@ -28,11 +32,66 @@ export class LocalStorageEngine implements IStringStorageEngine {
2832
return this.localStorage.getItem(this.metaKey);
2933
};
3034

35+
/**
36+
* @throws StorageFullUnableToWrite
37+
* @throws LocalStorageUnknownFailure
38+
*/
3139
public setContentsJsonString = async (configurationJsonString: string): Promise<void> => {
32-
this.localStorage.setItem(this.contentsKey, configurationJsonString);
40+
this.safeWrite(this.contentsKey, configurationJsonString);
3341
};
3442

43+
/**
44+
* @throws StorageFullUnableToWrite
45+
* @throws LocalStorageUnknownFailure
46+
*/
3547
public setMetaJsonString = async (metaJsonString: string): Promise<void> => {
36-
this.localStorage.setItem(this.metaKey, metaJsonString);
48+
this.safeWrite(this.metaKey, metaJsonString);
3749
};
50+
51+
/**
52+
* @throws StorageFullUnableToWrite
53+
* @throws LocalStorageUnknownFailure
54+
*/
55+
private safeWrite(key: string, value: string): void {
56+
try {
57+
this.localStorage.setItem(key, value);
58+
} catch (error) {
59+
if (error instanceof DOMException) {
60+
// Check for quota exceeded error
61+
if (error.code === DOMException.QUOTA_EXCEEDED_ERR || error.name === 'QuotaExceededError') {
62+
try {
63+
this.clear();
64+
// Retry setting the item after clearing
65+
this.localStorage.setItem(key, value);
66+
return;
67+
} catch {
68+
throw new StorageFullUnableToWrite();
69+
}
70+
}
71+
}
72+
// For any other error, wrap it in our custom exception
73+
const errorMessage = error instanceof Error ? error.message : String(error);
74+
throw new LocalStorageUnknownFailure(
75+
`Failed to write to localStorage: ${errorMessage}`,
76+
error instanceof Error ? error : (error as Error),
77+
);
78+
}
79+
}
80+
81+
public clear(): void {
82+
const keysToDelete: string[] = [];
83+
84+
// Collect all keys that start with 'eppo-configuration'
85+
for (let i = 0; i < this.localStorage.length; i++) {
86+
const key = this.localStorage.key(i);
87+
if (key?.startsWith(CONFIGURATION_KEY)) {
88+
keysToDelete.push(key);
89+
}
90+
}
91+
92+
// Delete collected keys
93+
keysToDelete.forEach((key) => {
94+
this.localStorage.removeItem(key);
95+
});
96+
}
3897
}

0 commit comments

Comments
 (0)