Skip to content

Commit 8b2bbb5

Browse files
committed
fix(#47): add retries to api calls to mitigate sleeping Wi-Fi speakers
1 parent b2f3118 commit 8b2bbb5

8 files changed

+151
-52
lines changed

.github/workflows/build.yml

+3
Original file line numberDiff line numberDiff line change
@@ -39,3 +39,6 @@ jobs:
3939
run: npm run build
4040
env:
4141
CI: true
42+
43+
- name: Test the project
44+
run: npm run test-ci

jest.config.js

+1
Original file line numberDiff line numberDiff line change
@@ -3,4 +3,5 @@ module.exports = {
33
preset: 'ts-jest',
44
testEnvironment: 'node',
55
setupFiles: ['dotenv/config'],
6+
testMatch: [ '**/__tests__/**/*.[jt]s?(x)', '**/?(*.)+(spec|test|it).[jt]s?(x)' ],
67
};

package.json

+2-1
Original file line numberDiff line numberDiff line change
@@ -29,7 +29,8 @@
2929
"prepare": "husky install",
3030
"prepublishOnly": "npm run lint && npm run format && npm run build",
3131
"watch": "npm run build && npm link && nodemon",
32-
"test": "jest --silent=false"
32+
"test": "jest --silent=false --runInBand",
33+
"test-ci": "jest --silent=true --runInBand --testPathPattern='test.ts'"
3334
},
3435
"keywords": [
3536
"homebridge-plugin",

src/spotify-api-wrapper.spec.ts src/spotify-api-wrapper.it.ts

+15-27
Original file line numberDiff line numberDiff line change
@@ -3,15 +3,7 @@ import { SpotifyApiWrapper } from './spotify-api-wrapper';
33

44
it('should authenticate and persist tokens', async () => {
55
// given
6-
const wrapper = new SpotifyApiWrapper(
7-
console as Logger,
8-
{
9-
spotifyAuthCode: process.env.SPOTIFY_AUTH_CODE!,
10-
spotifyClientId: process.env.SPOTIFY_CLIENT_ID!,
11-
spotifyClientSecret: process.env.SPOTIFY_CLIENT_SECRET!,
12-
} as unknown as PlatformConfig,
13-
{ user: { persistPath: () => '.' } } as API,
14-
);
6+
const wrapper = getSpotifyApiWrapper();
157

168
// when
179
const result = await wrapper.authenticate();
@@ -23,15 +15,7 @@ it('should authenticate and persist tokens', async () => {
2315

2416
it('should retrieve device list', async () => {
2517
// given
26-
const wrapper = new SpotifyApiWrapper(
27-
console as Logger,
28-
{
29-
spotifyAuthCode: process.env.SPOTIFY_AUTH_CODE!,
30-
spotifyClientId: process.env.SPOTIFY_CLIENT_ID!,
31-
spotifyClientSecret: process.env.SPOTIFY_CLIENT_SECRET!,
32-
} as unknown as PlatformConfig,
33-
{ user: { persistPath: () => '.' } } as API,
34-
);
18+
const wrapper = getSpotifyApiWrapper();
3519

3620
// when
3721
await wrapper.authenticate();
@@ -43,7 +27,18 @@ it('should retrieve device list', async () => {
4327

4428
it('should retrieve playback state', async () => {
4529
// given
46-
const wrapper = new SpotifyApiWrapper(
30+
const wrapper = getSpotifyApiWrapper();
31+
32+
// when
33+
await wrapper.authenticate();
34+
const state = await wrapper.getPlaybackState();
35+
36+
// then
37+
expect(state?.statusCode && state?.statusCode >= 200 && state?.statusCode <= 300).toBeTruthy();
38+
});
39+
40+
function getSpotifyApiWrapper(): SpotifyApiWrapper {
41+
return new SpotifyApiWrapper(
4742
console as Logger,
4843
{
4944
spotifyAuthCode: process.env.SPOTIFY_AUTH_CODE!,
@@ -52,11 +47,4 @@ it('should retrieve playback state', async () => {
5247
} as unknown as PlatformConfig,
5348
{ user: { persistPath: () => '.' } } as API,
5449
);
55-
56-
// when
57-
await wrapper.authenticate();
58-
const state = await wrapper.getPlaybackState();
59-
60-
// then
61-
expect(state?.statusCode).toEqual(200);
62-
});
50+
}

src/spotify-api-wrapper.test.ts

+86
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,86 @@
1+
import { API, Logger, PlatformConfig } from 'homebridge';
2+
import SpotifyWebApi from 'spotify-web-api-node';
3+
import { SpotifyApiWrapper } from './spotify-api-wrapper';
4+
import type { WebapiErrorBody } from './types';
5+
import { WebapiError } from './types';
6+
7+
jest.mock('spotify-web-api-node');
8+
const spotifyWebApiMocks = SpotifyWebApi as jest.MockedClass<typeof SpotifyWebApi>;
9+
10+
beforeEach(() => {
11+
spotifyWebApiMocks.mockClear();
12+
});
13+
14+
test('should re-attempt to retrieve device list once', async () => {
15+
// given
16+
const wrapper = getSpotifyApiWrapper();
17+
18+
const mockSpotifyWebApi = getMockedSpotifyWebApi();
19+
mockSpotifyWebApi.getMyDevices
20+
.mockRejectedValueOnce(new Error('something went wrong'))
21+
.mockResolvedValueOnce(fakeGetMyDevicesResponse);
22+
23+
// when
24+
const devices = await wrapper.getMyDevices();
25+
26+
// then
27+
expect(devices?.length).toBe(1);
28+
expect(mockSpotifyWebApi.getMyDevices).toHaveBeenCalledTimes(2);
29+
});
30+
31+
test('should re-attempt wrapped request once when 404', async () => {
32+
// given
33+
const wrapper = getSpotifyApiWrapper();
34+
35+
const mockSpotifyWebApi = getMockedSpotifyWebApi();
36+
mockSpotifyWebApi.getMyCurrentPlaybackState
37+
.mockRejectedValueOnce(new WebapiError('test', {} as WebapiErrorBody, {}, 404, 'device not found'))
38+
.mockResolvedValueOnce(fakeGetMyCurrentPlaybackState);
39+
40+
// when
41+
const playbackState = await wrapper.getPlaybackState();
42+
43+
// then
44+
expect(playbackState?.body.is_playing).toBe(false);
45+
expect(mockSpotifyWebApi.getMyCurrentPlaybackState).toHaveBeenCalledTimes(2);
46+
});
47+
48+
function getSpotifyApiWrapper(): SpotifyApiWrapper {
49+
return new SpotifyApiWrapper(
50+
console as Logger,
51+
{
52+
spotifyAuthCode: '',
53+
spotifyClientId: '',
54+
spotifyClientSecret: '',
55+
} as unknown as PlatformConfig,
56+
{ user: { persistPath: () => '.' } } as API,
57+
);
58+
}
59+
60+
function getMockedSpotifyWebApi(): jest.Mocked<SpotifyWebApi> {
61+
return spotifyWebApiMocks.mock.instances[0] as jest.Mocked<SpotifyWebApi>;
62+
}
63+
64+
const fakeGetMyDevicesResponse = {
65+
body: {
66+
devices: [
67+
{
68+
id: 'id',
69+
is_active: false,
70+
is_restricted: false,
71+
is_private_session: false,
72+
name: 'test',
73+
type: 'type',
74+
volume_percent: 0,
75+
},
76+
],
77+
},
78+
headers: {},
79+
statusCode: 200,
80+
};
81+
82+
const fakeGetMyCurrentPlaybackState = {
83+
body: { is_playing: false } as SpotifyApi.CurrentPlaybackResponse,
84+
headers: {},
85+
statusCode: 200,
86+
};

src/spotify-api-wrapper.ts

+26-14
Original file line numberDiff line numberDiff line change
@@ -4,7 +4,7 @@ import { SpotifyDeviceNotFoundError } from './errors';
44

55
import type { API, Logger, PlatformConfig } from 'homebridge';
66
import type { HomebridgeSpotifySpeakerDevice } from './spotify-speaker-accessory';
7-
import type { SpotifyPlaybackState, WebapiError } from './types';
7+
import { SpotifyPlaybackState, WebapiError } from './types';
88

99
const DEFAULT_SPOTIFY_CALLBACK = 'https://example.com/callback';
1010

@@ -95,13 +95,21 @@ export class SpotifyApiWrapper {
9595
await this.wrappedRequest(() => this.spotifyApi.setVolume(volume, { device_id: deviceId }));
9696
}
9797

98-
async getMyDevices() {
98+
async getMyDevices(isFirstAttempt = true): Promise<SpotifyApi.UserDevice[]> {
9999
try {
100100
const res = await this.spotifyApi.getMyDevices();
101101
return res.body.devices;
102102
} catch (error) {
103-
this.log.error('Failed to fetch available Spotify devices.');
104-
return null;
103+
if (isFirstAttempt) {
104+
return new Promise((resolve) => {
105+
setTimeout(() => {
106+
this.getMyDevices(false).then(resolve);
107+
}, 500);
108+
});
109+
} else {
110+
this.log.error('Failed to fetch available Spotify devices.', error);
111+
return [];
112+
}
105113
}
106114
}
107115

@@ -158,29 +166,33 @@ export class SpotifyApiWrapper {
158166
return true;
159167
}
160168

161-
private async wrappedRequest<T>(cb: () => Promise<T>): Promise<T | undefined> {
169+
private async wrappedRequest<T>(cb: () => Promise<T>, isFirstAttempt = true): Promise<T | undefined> {
162170
try {
163171
const response = await cb();
164172
return response;
165173
} catch (error: unknown) {
166-
// eslint-disable-next-line @typescript-eslint/no-explicit-any
167-
const isWebApiError = Object.getPrototypeOf((error as any).constructor).name === 'WebapiError';
168-
169-
if (isWebApiError) {
170-
if ((error as WebapiError).statusCode === 401) {
174+
let errorMessage = error;
175+
if (isWebApiError(error) && isFirstAttempt) {
176+
const webApiError = error as WebapiError;
177+
if (webApiError.statusCode === 401) {
171178
this.log.debug('Access token has expired, attempting token refresh');
172179

173180
const areTokensRefreshed = await this.refreshTokens();
174181
if (areTokensRefreshed) {
175-
return this.wrappedRequest(cb);
182+
return this.wrappedRequest(cb, false);
176183
}
177-
} else if ((error as WebapiError).statusCode === 404) {
178-
throw new SpotifyDeviceNotFoundError();
184+
} else if (webApiError.statusCode === 404) {
185+
return this.wrappedRequest(cb, false);
179186
}
187+
errorMessage = webApiError.body;
180188
}
181189

182-
const errorMessage = isWebApiError ? (error as WebapiError).body : error;
183190
this.log.error('Unexpected error when making a request to Spotify:', JSON.stringify(errorMessage));
184191
}
185192
}
186193
}
194+
195+
// eslint-disable-next-line @typescript-eslint/no-explicit-any
196+
function isWebApiError(error: any): boolean {
197+
return error.constructor.name === 'WebapiError' || Object.getPrototypeOf(error.constructor).name === 'WebapiError';
198+
}

src/spotify-speaker-accessory.ts

+9-3
Original file line numberDiff line numberDiff line change
@@ -13,7 +13,7 @@ export interface HomebridgeSpotifySpeakerDevice {
1313
}
1414

1515
export class SpotifySpeakerAccessory {
16-
private static DEFAULT_POLL_INTERVAL_MS = 20;
16+
private static DEFAULT_POLL_INTERVAL_S = 20;
1717
private service: Service;
1818
private activeState: boolean;
1919
private currentVolume: number;
@@ -59,7 +59,7 @@ export class SpotifySpeakerAccessory {
5959
if (oldVolume !== this.currentVolume) {
6060
this.service.updateCharacteristic(this.platform.Characteristic.Brightness, this.currentVolume);
6161
}
62-
}, (this.platform.config.spotifyPollInterval || SpotifySpeakerAccessory.DEFAULT_POLL_INTERVAL_MS) * 1000);
62+
}, (this.platform.config.spotifyPollInterval || SpotifySpeakerAccessory.DEFAULT_POLL_INTERVAL_S) * 1000);
6363
}
6464

6565
handleOnGet(): boolean {
@@ -119,12 +119,18 @@ export class SpotifySpeakerAccessory {
119119
this.service.getCharacteristic(this.platform.Characteristic.Brightness).updateValue(this.currentVolume);
120120
}
121121

122-
private async setCurrentStates() {
122+
private async setCurrentStates(isFirstAttempt = true) {
123123
if (this.device.spotifyDeviceName) {
124124
const devices = await this.platform.spotifyApiWrapper.getMyDevices();
125125
const match = devices?.find((device) => device.name === this.device.spotifyDeviceName);
126126
if (match?.id) {
127127
this.device.spotifyDeviceId = match.id;
128+
} else if (isFirstAttempt) {
129+
await new Promise((resolve) => {
130+
setTimeout(() => {
131+
this.setCurrentStates(false).then(resolve);
132+
}, 500);
133+
});
128134
} else {
129135
this.log.error(
130136
`spotifyDeviceName '${this.device.spotifyDeviceName}' did not match any Spotify devices. spotifyDeviceName is case sensitive.`,

src/types.ts

+9-7
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
1-
interface WebapiErrorBody {
1+
export interface WebapiErrorBody {
22
error: {
33
message: string;
44
status: number;
@@ -8,12 +8,14 @@ interface WebapiErrorBody {
88
interface WebapiErrorHeaders {
99
[key: string]: string | boolean;
1010
}
11-
export interface WebapiError {
12-
name: string;
13-
body: WebapiErrorBody;
14-
headers: WebapiErrorHeaders;
15-
statusCode: number;
16-
message: string;
11+
export class WebapiError {
12+
constructor(
13+
public readonly name: string,
14+
public readonly body: WebapiErrorBody,
15+
public readonly headers: WebapiErrorHeaders,
16+
public readonly statusCode: number,
17+
public readonly message: string,
18+
) {}
1719
}
1820

1921
/**

0 commit comments

Comments
 (0)