Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Typed Errors #21

Merged
merged 7 commits into from
Feb 26, 2024
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
4 changes: 2 additions & 2 deletions package-lock.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

2 changes: 1 addition & 1 deletion package.json
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
{
"name": "speechmatics",
"version": "4.0.0-pre.0",
"version": "4.0.0-pre.1",
"description": "Speechmatics Javascript Libraries",
"types": "./dist/index.d.ts",
"main": "./dist/index.js",
Expand Down
105 changes: 91 additions & 14 deletions src/batch/client.test.ts
Original file line number Diff line number Diff line change
@@ -1,12 +1,24 @@
import { BatchTranscription } from './';
import { request } from '../utils/request';
import {
SpeechmaticsConfigurationError,
SpeechmaticsNetworkError,
SpeechmaticsResponseError,
} from '../utils/errors';

jest.mock('../utils/request');
const mockedRequest = jest.mocked(request);
const originalFetch = global.fetch;
const mockedFetch: jest.Mock<
ReturnType<typeof global.fetch>,
Parameters<typeof global.fetch>
> = jest.fn();
global.fetch = mockedFetch;

describe('BatchTranscription', () => {
afterEach(() => {
jest.clearAllMocks();
mockedFetch.mockReset();
});

afterAll(() => {
global.fetch = originalFetch;
});

it('can be initialized with just an API key string', async () => {
Expand All @@ -28,32 +40,97 @@ describe('BatchTranscription', () => {
const apiKey = jest.fn(async () => 'asyncApiKey');
const batch = new BatchTranscription({ apiKey });

mockedRequest.mockImplementation(async () => ({ jobs: [] }));
mockedFetch.mockImplementation(
async () => new Response(JSON.stringify({ jobs: [] }), { status: 200 }),
);
await batch.listJobs();
await batch.listJobs();

expect(apiKey).toBeCalledTimes(1);
});

it('refreshes the API key on error and retries', async () => {
const keys = (function* () {
yield 'firstKey';
yield 'secondKey';
})();
const apiKey = jest.fn(async () => keys.next().value as string);
const keys: IterableIterator<string> = ['firstKey', 'secondKey'][
Symbol.iterator
]();
const apiKey: () => Promise<string> = jest.fn(
async () => keys.next().value,
);

const batch = new BatchTranscription({ apiKey });

mockedRequest.mockImplementation(async (apiKey: string) => {
mockedFetch.mockImplementation(async (_url, requestOpts) => {
const apiKey = JSON.parse(
JSON.stringify(requestOpts?.headers) ?? '{}',
).Authorization?.split('Bearer ')[1];

if (apiKey === 'firstKey') {
throw new Error('401 Unauthorized (mock)');
return new Response(
'{"code": 401, "error": "Permission Denied", "mock": true}',
{ status: 401 },
);
} else {
return { jobs: [] };
return new Response(JSON.stringify({ jobs: [] }), { status: 200 });
}
});

const result = await batch.listJobs();
expect(apiKey).toBeCalledTimes(2);
expect(result.jobs).toBeInstanceOf(Array);
expect(Array.isArray(result.jobs)).toBe(true);
});

describe('Errors', () => {
it('throws a response error when the given API key is invalid', async () => {
mockedFetch.mockImplementation(async () => {
return new Response(
'{"code": 401, "error": "Permission Denied", "mock": true}',
{ status: 401 },
);
});

const batch = new BatchTranscription({ apiKey: 'some-invalid-key' });
const listJobs = batch.listJobs();
await expect(listJobs).rejects.toBeInstanceOf(SpeechmaticsResponseError);
await expect(listJobs).rejects.toMatchInlineSnapshot(
'[SpeechmaticsResponseError: Permission Denied]',
);
});

it('throws a configuration error if the apiKey is not present', async () => {
// @ts-expect-error
const batch = new BatchTranscription({});
const listJobs = batch.listJobs();
await expect(listJobs).rejects.toBeInstanceOf(
SpeechmaticsConfigurationError,
);
await expect(listJobs).rejects.toMatchInlineSnapshot(
'[SpeechmaticsConfigurationError: Missing apiKey in configuration]',
);
});

it('throws a network error if fetch fails', async () => {
mockedFetch.mockImplementationOnce(() => {
throw new TypeError('failed to fetch');
});

const batch = new BatchTranscription({ apiKey: 'my-key' });
const listJobs = batch.listJobs();
await expect(listJobs).rejects.toBeInstanceOf(SpeechmaticsNetworkError);
await expect(listJobs).rejects.toMatchInlineSnapshot(
'[SpeechmaticsNetworkError: Error fetching from /v2/jobs]',
);
});

it('throws an unexpected response error if an invalid response comes back', async () => {
mockedFetch.mockImplementationOnce(async () => {
return new Response('<html><center>502 bad gateway</center></html>');
});

const batch = new BatchTranscription({ apiKey: 'my-key' });
const listJobs = batch.listJobs();
await expect(listJobs).rejects.toMatchInlineSnapshot(
'[SpeechmaticsInvalidTypeError: Failed to parse response JSON]',
);
});
});
});
58 changes: 43 additions & 15 deletions src/batch/client.ts
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,10 @@ import { QueryParams, request, SM_APP_PARAM_NAME } from '../utils/request';
import poll from '../utils/poll';
import RetrieveJobsFilters from '../types/list-job-filters';
import { BatchFeatureDiscovery } from '../types/batch-feature-discovery';
import {
SpeechmaticsConfigurationError,
SpeechmaticsResponseError,
} from '../utils/errors';

export class BatchTranscription {
private config: ConnectionConfigFull;
Expand All @@ -37,23 +41,26 @@ export class BatchTranscription {
this.config = new ConnectionConfigFull(config);
}

private async refreshOnFail<T>(
private async refreshOnAuthFail<T>(
doRequest: (key: string) => Promise<T>,
): Promise<T> {
try {
return await doRequest(this.apiKey ?? (await this.refreshApiKey()));
} catch (e) {
console.info('Retrying due to error:', e);
return await doRequest(await this.refreshApiKey());
if (e instanceof SpeechmaticsResponseError && e.response.code === 401) {
return await doRequest(await this.refreshApiKey());
} else {
throw e;
}
}
}

private async get<T, K extends string>(
private async get<T>(
endpoint: string,
contentType?: string,
queryParams?: QueryParams<K>,
queryParams?: QueryParams,
): Promise<T> {
return await this.refreshOnFail((key: string) =>
return await this.refreshOnAuthFail((key: string) =>
request(
key,
this.config.batchUrl,
Expand All @@ -71,7 +78,7 @@ export class BatchTranscription {
body: FormData | null = null,
contentType?: string,
): Promise<T> {
return await this.refreshOnFail((key: string) =>
return await this.refreshOnAuthFail((key: string) =>
request(
key,
this.config.batchUrl,
Expand All @@ -84,11 +91,8 @@ export class BatchTranscription {
);
}

private async delete<T, K extends string = string>(
endpoint: string,
params?: QueryParams<K>,
): Promise<T> {
return this.refreshOnFail((key: string) =>
private async delete<T>(endpoint: string, params?: QueryParams): Promise<T> {
return this.refreshOnAuthFail((key: string) =>
request(key, this.config.batchUrl, endpoint, 'DELETE', null, {
...params,
[SM_APP_PARAM_NAME]: this.config.appId,
Expand All @@ -109,7 +113,9 @@ export class BatchTranscription {
format?: TranscriptionFormat,
): Promise<RetrieveTranscriptResponse | string> {
if (this.config.apiKey === undefined)
throw new Error('Error: apiKey is undefined');
throw new SpeechmaticsConfigurationError(
'Missing apiKey in configuration',
);

const submitResponse = await this.createTranscriptionJob(input, jobConfig);

Expand Down Expand Up @@ -142,7 +148,9 @@ export class BatchTranscription {
},
): Promise<CreateJobResponse> {
if (this.config.apiKey === undefined)
throw new Error('Error: apiKey is undefined');
throw new SpeechmaticsConfigurationError(
'Missing apiKey in configuration',
);

const config = {
...jobConfig,
Expand All @@ -165,14 +173,26 @@ export class BatchTranscription {
async listJobs(
filters: RetrieveJobsFilters = {},
): Promise<RetrieveJobsResponse> {
return this.get('/v2/jobs', 'application/json', filters);
if (this.config.apiKey === undefined)
throw new SpeechmaticsConfigurationError(
'Missing apiKey in configuration',
);
return this.get('/v2/jobs', 'application/json', { ...filters });
}

async getJob(id: string): Promise<RetrieveJobResponse> {
if (this.config.apiKey === undefined)
throw new SpeechmaticsConfigurationError(
'Missing apiKey in configuration',
);
return this.get(`/v2/jobs/${id}`, 'application/json');
}

async deleteJob(id: string, force = false): Promise<DeleteJobResponse> {
if (this.config.apiKey === undefined)
throw new SpeechmaticsConfigurationError(
'Missing apiKey in configuration',
);
const params = force ? { force } : undefined;
return this.delete(`/v2/jobs/${id}`, params);
}
Expand All @@ -194,13 +214,21 @@ export class BatchTranscription {
jobId: string,
format: TranscriptionFormat = 'json-v2',
): Promise<RetrieveTranscriptResponse | string> {
if (this.config.apiKey === undefined)
throw new SpeechmaticsConfigurationError(
'Missing apiKey in configuration',
);
const params = { format: format === 'text' ? 'txt' : format };
const contentType =
format === 'json-v2' ? 'application/json' : 'text/plain';
return this.get(`/v2/jobs/${jobId}/transcript`, contentType, params);
}

async getDataFile(jobId: string) {
if (this.config.apiKey === undefined)
throw new SpeechmaticsConfigurationError(
'Missing apiKey in configuration',
);
return this.get(`/v2/jobs/${jobId}/data`, 'application/json');
}

Expand Down
1 change: 1 addition & 0 deletions src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@ export * from './client';
export * from './types';
export * from './realtime';
export * from './batch';
export * from './utils/errors';

declare global {
// This gets injected by ESBuild at compile time
Expand Down
3 changes: 2 additions & 1 deletion src/management-platform/auth.ts
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
import { SpeechmaticsNetworkError } from '../utils/errors';
import { request } from '../utils/request';

export default async function getShortLivedToken(
Expand All @@ -23,7 +24,7 @@ export default async function getShortLivedToken(
undefined,
'application/json',
).catch((err) => {
throw new Error(`Error fetching short lived token: ${err}`);
throw new SpeechmaticsNetworkError('Error fetching short lived token', err);
});
return jsonResponse.key_value;
}
5 changes: 4 additions & 1 deletion src/realtime/browser.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@ import {
} from '../utils/request';
import { ISocketWrapper } from '../types';
import { ModelError, RealtimeMessage } from '../types';
import { SpeechmaticsUnsupportedEnvironment } from '../utils/errors';

/**
* Wraps the socket api to be more useful in async/await kind of scenarios
Expand All @@ -23,7 +24,9 @@ export class WebSocketWrapper implements ISocketWrapper {

constructor() {
if (typeof window === 'undefined')
throw new Error('window is undefined - are you running in a browser?');
throw new SpeechmaticsUnsupportedEnvironment(
'window is undefined - are you running in a browser?',
);
}

async connect(
Expand Down
3 changes: 2 additions & 1 deletion src/realtime/client.ts
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,7 @@ import * as webWrapper from '../realtime/browser';
import * as chromeWrapper from '../realtime/extension';

import { EventMap } from '../types/event-map';
import { SpeechmaticsUnsupportedEnvironment } from '../utils/errors';

/**
* A class that represents a single realtime session. It's responsible for handling the connection and the messages.
Expand All @@ -40,7 +41,7 @@ export class RealtimeSession {
} else if (typeof process !== 'undefined') {
socketImplementation = new nodeWrapper.NodeWebSocketWrapper();
} else {
throw new Error('Unsupported environment');
throw new SpeechmaticsUnsupportedEnvironment();
}

this.rtSocketHandler = new RealtimeSocketHandler(
Expand Down
3 changes: 2 additions & 1 deletion src/realtime/extension.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@ import {
} from '../utils/request';
import { ISocketWrapper } from '../types';
import { ModelError, RealtimeMessage } from '../types';
import { SpeechmaticsUnsupportedEnvironment } from '../utils/errors';

/**
* Wraps the socket api to be more useful in async/await kind of scenarios
Expand All @@ -23,7 +24,7 @@ export class WebSocketWrapper implements ISocketWrapper {

constructor() {
if (typeof chrome.runtime === 'undefined')
throw new Error(
throw new SpeechmaticsUnsupportedEnvironment(
'chrome is undefined - are you running in a background script?',
);
}
Expand Down
Loading
Loading