Skip to content

Commit

Permalink
ULMS-2596 Added timeout and retry logic
Browse files Browse the repository at this point in the history
  • Loading branch information
alexkonst committed May 20, 2024
1 parent 16ee2dc commit 6439043
Show file tree
Hide file tree
Showing 8 changed files with 199 additions and 60 deletions.
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": "@ulms/api-clients",
"version": "7.5.0",
"version": "7.5.0-dev.0-ulms-2596",
"description": "JavaScript API clients for ULMS platform",
"keywords": [],
"homepage": "https://github.com/foxford/ulms-api-clients-js#readme",
Expand Down
45 changes: 39 additions & 6 deletions src/basic-client.js
Original file line number Diff line number Diff line change
@@ -1,3 +1,7 @@
import retry, { isErrorRetryable } from './retry'

const onRetry = (error) => !isErrorRetryable(error)

const parseParameter = (key, value) => {
if (Array.isArray(value)) {
// eslint-disable-next-line unicorn/no-array-reduce
Expand All @@ -22,6 +26,8 @@ const parseParameter = (key, value) => {
return `${key}=${value}`
}

const responseTransformer = (_) => _.data

class BasicClient {
constructor(baseUrl, httpClient, tokenProvider) {
this.baseUrl = baseUrl
Expand Down Expand Up @@ -86,14 +92,33 @@ class BasicClient {
}

async get(url, options = {}) {
const { retry: retryEnabled } = options

if (retryEnabled) {
const task = async () => {
const token = await this.tokenProvider.getToken()
const headers = {
...options.headers,
...BasicClient.headers(token, this.labels, this.customHeaders),
}
const requestOptions = { ...options, headers }

return this.httpClient
.get(url, requestOptions)
.then(responseTransformer)
}

return retry(task, onRetry)
}

const token = await this.tokenProvider.getToken()
const headers = {
...options.headers,
...BasicClient.headers(token, this.labels, this.customHeaders),
}
const requestOptions = { ...options, headers }

return this.httpClient.get(url, requestOptions)
return this.httpClient.get(url, requestOptions).then(responseTransformer)
}

async put(url, data, options = {}) {
Expand All @@ -104,7 +129,9 @@ class BasicClient {
}
const requestOptions = { ...options, headers }

return this.httpClient.put(url, data, requestOptions)
return this.httpClient
.put(url, data, requestOptions)
.then(responseTransformer)
}

async post(url, data, options = {}) {
Expand All @@ -122,7 +149,9 @@ class BasicClient {
const expiresAtLocal = this.tokenProvider.tokenData
? this.tokenProvider.tokenData.expires_ts
: undefined
const result = this.httpClient.post(url, data, requestOptions)
const result = this.httpClient
.post(url, data, requestOptions)
.then(responseTransformer)

result.catch((error) => {
const responseEnd = Date.now()
Expand All @@ -143,7 +172,9 @@ class BasicClient {
}
// [debug section] end

return this.httpClient.post(url, data, requestOptions)
return this.httpClient
.post(url, data, requestOptions)
.then(responseTransformer)
}

async patch(url, data, options = {}) {
Expand All @@ -154,7 +185,9 @@ class BasicClient {
}
const requestOptions = { ...options, headers }

return this.httpClient.patch(url, data, requestOptions)
return this.httpClient
.patch(url, data, requestOptions)
.then(responseTransformer)
}

async delete(url, options = {}) {
Expand All @@ -165,7 +198,7 @@ class BasicClient {
}
const requestOptions = { ...options, headers }

return this.httpClient.delete(url, requestOptions)
return this.httpClient.delete(url, requestOptions).then(responseTransformer)
}
}

Expand Down
83 changes: 55 additions & 28 deletions src/http-client.js
Original file line number Diff line number Diff line change
@@ -1,65 +1,92 @@
/* eslint-disable class-methods-use-this */

function createTimeoutSignal(timeout) {
const controller = new AbortController()
const { signal } = controller
const id = setTimeout(() => controller.abort(), timeout)
const cleanup = () => clearTimeout(id)

return { cleanup, signal }
}

class FetchHttpClient {
static handleResponse(response) {
static async handleResponse(response) {
const bodyAsText = await response.text()
let data

try {
data = JSON.parse(bodyAsText)
} catch {
data = { message: bodyAsText }
}

const result = {
data,
status: response.status,
}

if (!response.ok) {
return response
.json()
.catch(() => response.text())
.catch(() => ({
status: response.status,
statusText: response.statusText,
}))
.then((result) => {
throw result
})
throw result
}

return result
}

request(url, config) {
const { timeout, ...requestConfig } = config
const requestOptions = {
...requestConfig,
}
let onFinally

if (timeout !== undefined) {
const { cleanup, signal } = createTimeoutSignal(timeout)

requestOptions.signal = signal
onFinally = cleanup
}

return response
.json()
.catch(() => response.text())
.catch(() => ({
status: response.status,
statusText: response.statusText,
}))
return fetch(url, requestOptions)
.then(FetchHttpClient.handleResponse)
.finally(() => (onFinally ? onFinally() : undefined))
}

get(url, config) {
return fetch(url, {
return this.request(url, {
...config,
method: 'GET',
}).then(FetchHttpClient.handleResponse)
})
}

put(url, data, config) {
return fetch(url, {
return this.request(url, {
...config,
method: 'PUT',
body: JSON.stringify(data),
}).then(FetchHttpClient.handleResponse)
})
}

post(url, data, config) {
return fetch(url, {
return this.request(url, {
...config,
method: 'POST',
body: JSON.stringify(data),
}).then(FetchHttpClient.handleResponse)
})
}

patch(url, data, config) {
return fetch(url, {
return this.request(url, {
...config,
method: 'PATCH',
body: JSON.stringify(data),
}).then(FetchHttpClient.handleResponse)
})
}

delete(url, config) {
return fetch(url, {
return this.request(url, {
...config,
method: 'DELETE',
}).then(FetchHttpClient.handleResponse)
})
}
}

Expand Down
28 changes: 19 additions & 9 deletions src/profile.js
Original file line number Diff line number Diff line change
@@ -1,3 +1,7 @@
/* eslint-disable promise/no-nesting */
const responseTransformer = (_) => _.data

// todo: extend from BasicClient
class HttpProfileResource {
constructor(host, endpoint, httpClient, tokenProvider) {
this.baseUrl = `${host}/${endpoint}`
Expand Down Expand Up @@ -25,29 +29,35 @@ class HttpProfileResource {
}

return this.tokenProvider.getToken().then((token) =>
this.httpClient.get(`${this.baseUrl}/users/${id}${qs}`, {
headers: HttpProfileResource.headers({ token }),
}),
this.httpClient
.get(`${this.baseUrl}/users/${id}${qs}`, {
headers: HttpProfileResource.headers({ token }),
})
.then(responseTransformer),
)
}

listProfiles(ids, scope) {
const qs = `?ids=${ids.join(',')}&scope=${scope}`

return this.tokenProvider.getToken().then((token) =>
this.httpClient.get(`${this.baseUrl}/users${qs}`, {
headers: HttpProfileResource.headers({ token }),
}),
this.httpClient
.get(`${this.baseUrl}/users${qs}`, {
headers: HttpProfileResource.headers({ token }),
})
.then(responseTransformer),
)
}

updateAttributes(id, scope, data) {
const qs = `?scope=${scope}`

return this.tokenProvider.getToken().then((token) =>
this.httpClient.patch(`${this.baseUrl}/users/${id}${qs}`, data, {
headers: HttpProfileResource.headers({ token }),
}),
this.httpClient
.patch(`${this.baseUrl}/users/${id}${qs}`, data, {
headers: HttpProfileResource.headers({ token }),
})
.then(responseTransformer),
)
}
}
Expand Down
70 changes: 70 additions & 0 deletions src/retry.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,70 @@
import Backoff from './backoff'
import { sleep } from './common'

const RETRY_LIMIT = 3

async function retry(task, onRetry, retryLimit = RETRY_LIMIT) {
const backoff = new Backoff()
let reason
let result
let retryCount = 0

while (retryCount < retryLimit) {
if (retryCount > 0) {
if (onRetry) {
// eslint-disable-next-line no-await-in-loop
const stop = onRetry(reason, retryCount)

if (stop) break
}

// eslint-disable-next-line no-await-in-loop
await sleep(backoff.value)

backoff.next()
}

try {
// eslint-disable-next-line no-await-in-loop
result = await task()
} catch (error) {
reason = error
}

if (result) break

retryCount += 1
}

backoff.reset()

if (result) return result

throw reason
}

function isErrorRetryable(error) {
/*
Повторная попытка разрешена для следующих ошибок:
- [+] клиентская сетевая ошибка ("Failed to fetch *")
- [+] таймаут запроса
- [+] HTTP ответ со статус-кодом: 422, 424, 429 или 5xx
*/
const isNetworkError =
error instanceof TypeError && error.message.startsWith('Failed to fetch')
const isTimeoutError =
error instanceof DOMException && error.name === 'AbortError'
const isPassedByStatusCode = error.status
? error.status === 422 ||
error.status === 401 ||
error.status === 424 ||
error.status === 429 ||
error.status >= 500
: false

return isNetworkError || isTimeoutError || isPassedByStatusCode
}

export default retry

export { isErrorRetryable }
Loading

0 comments on commit 6439043

Please sign in to comment.