Хабрахабр

[Перевод] Повтор неудачных HTTP-запросов в Angular

Организация доступа к серверным данным — это основа почти любого одностраничного приложения. Весь динамический контент в таких приложениях загружается с бэкенда.

Однако в некоторых ситуациях запросы могут оказываться неудачными. В большинстве случаев HTTP-запросы к серверу работают надёжно и возвращают желаемый результат.

Сетевое соединение при таком раскладе может быть медленным, но запросы к серверу, несмотря на это, делают своё дело. Представьте себе, как кто-то работает с вашим веб-сайтом через точку доступа в поезде, который несётся по стране со скоростью 200 километров в час.

Тут высока вероятность того, что связь с интернетом прервётся и веб-приложение не сможет «достучаться» до сервера. А что если поезд попадёт в туннель? В этом случае пользователю придётся перезагрузить страницу приложения после того, как поезд выедет из туннеля и соединение с интернетом восстановится.

Это значит, что пользователь может, например, потерять данные, которые он ввёл в форму. Перезагрузка страницы способна оказать воздействие на текущее состояние приложения.

При таком подходе, когда пользователь поймёт, что приложение пытается справиться с проблемой, он, скорее всего, не станет перезагружать страницу. Вместо того чтобы просто смириться с тем фактом, что некий запрос оказался неудачным, лучше будет несколько раз его повторить и показать пользователю соответствующее уведомление.

Материал, перевод которого мы сегодня публикуем, посвящён разбору нескольких способов повторения неудачных запросов в Angular-приложениях.

Повтор неудачных запросов

Давайте воспроизведём ситуацию, с которой может столкнуться пользователь, работающий в интернете из поезда. Мы создадим бэкенд, который обрабатывает запрос неправильно в ходе трёх первых попыток обратиться к нему, возвращая данные лишь с четвёртой попытки.
Обычно, пользуясь Angular, мы создаём сервис, подключаем HttpClient и используем его для получения данных с бэкенда.

import from '@angular/core';
import {HttpClient} from '@angular/common/http';
import {EMPTY, Observable} from 'rxjs';
import {catchError} from 'rxjs/operators'; @Injectable()
export class GreetingService { private GREET_ENDPOINT = 'http://localhost:3000'; constructor(private httpClient: HttpClient) { } greet(): Observable<string> { return this.httpClient.get<string>(`${this.GREET_ENDPOINT}/greet`).pipe( catchError(() => { // Выполняем обработку ошибок return EMPTY; }) ); }
}

Тут нет ничего особенного. Мы подключаем модуль Angular HttpClient и выполняем простой GET-запрос. Если запрос вернёт ошибку — мы выполняем некий код для её обработки и возвращаем пустой Observable (наблюдаемый объект) для того чтобы проинформировать об этом то, что инициировало запрос. Этот код как бы говорит: «Возникла ошибка, но всё в порядке, я с этим справлюсь».

В вышеприведённом коде запрос выполняется лишь один раз. Большинство приложений выполняет HTTP-запросы именно так. После этого он либо возвращает данные, полученные с сервера, либо оказывается неудачным.

Может быть, существует подходящий оператор RxJS? Как повторить запрос в том случае, если конечная точка /greet недоступна или возвращает ошибку? В RxJS есть операторы для всего чего угодно. Конечно, он существует.

Посмотрим на его определение: «Возвращает Observable, который воспроизводит исходный Observable за исключением error. Первое, что в этой ситуации может прийти в голову, это — оператор retry. Если исходный Observable вызывает error, то этот метод, вместо распространения ошибки, выполнит повторную подписку на исходный Observable.

Максимальное число повторных подписок ограничено count (это — числовой параметр, передаваемый методу)».

Поэтому давайте встроим его в нашу цепочку. Оператор retry очень похож на то, что нам нужно.

import {Injectable} from '@angular/core';
import {HttpClient} from '@angular/common/http';
import {EMPTY, Observable} from 'rxjs';
import {catchError, retry, shareReplay} from 'rxjs/operators'; @Injectable()
export class GreetingService { private GREET_ENDPOINT = 'http://localhost:3000'; constructor(private httpClient: HttpClient) { } greet(): Observable<string> { return this.httpClient.get<string>(`${this.GREET_ENDPOINT}/greet`).pipe( retry(3), catchError(() => { // Выполняем обработку ошибок return EMPTY; }), shareReplay() ); }
}

Мы успешно воспользовались оператором retry. Посмотрим на то, как это повлияло на поведение HTTP-запроса, который выполняется в экспериментальном приложении. Вот GIF-файл большого размера, который демонстрирует экран этого приложения и вкладку Network инструментов разработчика браузера. Вы встретите здесь ещё несколько таких демонстраций.

Оно просто выполняет HTTP-запрос при нажатии на кнопку PING THE SERVER. Наше приложение устроено предельно просто.

Как уже было сказано, бэкенд возвращает ошибку при выполнении первых трёх попыток выполнить к нему запрос, а при поступлении к нему четвёртого запроса возвращает обычный ответ.

Последняя попытка оказывается удачной, приложение получает ответ, на странице появляется соответствующее сообщение. На вкладке инструментов разработчика Network можно видеть, что оператор retry решает поставленную перед ним задачу и трижды повторяет выполнение неудавшегося запроса.

Теперь приложение умеет повторять неудавшиеся запросы. Всё это очень хорошо.

Обратите внимание на то, что сейчас повторные запросы выполняются немедленно после выполнения запросов, которые оказываются неудачными. Однако этот пример ещё можно улучшать. Такое поведение системы не принесёт особой пользы в нашей ситуации — тогда, когда поезд попадает в туннель и интернет-соединение на некоторое время теряется.

Отложенный повтор неудачных запросов

Поезд, который попал в туннель, не выезжает из него мгновенно. Он проводит там некоторое время. Поэтому нужно «растянуть» период, в течение которого мы выполняем повторные запросы к серверу. Сделать это можно, отложив выполнение повторных попыток.

Нам нужно, чтобы мы могли бы принимать решения о том, когда именно нужно выполнять повторные запросы. Для этого нам нужно лучше контролировать процесс выполнения повторных запросов. Поэтому снова обратимся к документации по RxJS. Это значит, что возможностей оператора retry нам уже недостаточно.

В документации он описан так: «Возвращает Observable, который воспроизводит исходный Observable за исключением error. В документации имеется описание оператора retryWhen, который, как кажется, нас устроит. Если этот Observable вызовет complete или error, тогда этот метод вызовет complete или error на дочерней подписке. Если исходный Observable вызывает error, то этот метод выдаст Throwable, который вызвал ошибку, Observable, возвращённому из notifier. В противном случае этот метод повторно подпишется на исходный Observable».

Давайте опишем то же самое более доступным языком. Да уж, определение не из простых.

Возвращённый Observable принимает решение о том, как будет вести себя оператор retryWhen, основываясь на некоторых правилах. Оператор retryWhen принимает коллбэк, который возвращает Observable. А именно, вот как ведёт себя оператор retryWhen:

  • Он прекращает работу и выдаёт ошибку в том случае, если возвращённый Observable выдаёт ошибку.
  • Он завершает работу в том случае, если возвращённый Observable сообщает о завершении работы.
  • В других случаях, при успешном возврате Observable, он повторяет выполнение исходного Observable

Коллбэк вызывается лишь в том случае, когда исходный Observable выдаёт ошибку в первый раз.

Теперь мы можем воспользоваться этими знаниями для того чтобы создать механизм отложенного повтора неудачного запроса с помощью оператора RxJS retryWhen.

retryWhen((errors: Observable<any>) => errors.pipe( delay(delayMs), mergeMap(error => retries-- > 0 ? of(error) : throwError(getErrorMessage(maxEntry)) ))
)

Если исходный Observable, который является нашим HTTP-запросом, вернёт ошибку, тогда вызывается оператор retryWhen. В коллбэке у нас есть доступ к ошибке, которая вызвала неудачу. Мы откладываем errors, уменьшаем число повторных попыток и возвращаем новый Observable, который выдаёт ошибку.

Если повтор несколько раз оказывается неудачным и значение переменной retries уменьшается до 0, тогда мы завершаем работу с выдачей возникшей при выполнении запроса ошибки. Основываясь на правилах оператора retryWhen, этот Observable, так как он выдаёт значение, выполняет повтор запроса.

Видимо, мы можем взять код, приведённый выше и заменить им оператор retry, который имеется в нашей цепочке. Замечательно! Но тут мы немного притормозим.

Это переменная содержит текущее состояние системы повтора неудачных запросов. Как быть с переменной retries? Когда состояние сбрасывается? Где она объявляется? Состоянием нужно управлять внутри потока, а не вне его.

▍Создание собственного оператора delayedRetry

Мы можем решить проблему управления состоянием и улучшить читабельность кода, оформив вышеприведённый код в виде отдельного оператора RxJS.

То, каким именно методом воспользоваться, сильно зависит от того, как устроен тот или иной оператор. Существуют разные способы создания собственных RxJS-операторов.

В результате мы можем воспользоваться самым простым способом создания собственных операторов. Наш оператор построен на базе существующих операторов RxJS. В нашем случае RxJs-оператор — это всего лишь функция со следующей сигнатурой:

const customOperator = (src: Observable<A>) => Observable<B>

Этот оператор принимает исходный Observable и возвращает ещё один Observable.

Так как наш оператор позволяет пользователю указать то, как часто должны выполняться повторные запросы, и то, сколько раз их нужно выполнять, нам нужно обернуть вышеприведённое объявление функции в фабричную функцию, которая принимает в качестве параметров значения delayMs (задержка между повторами) и maxRetry (максимальное количество повторов).

const customOperator = (delayMs: number, maxRetry: number) => { return (src: Observable<A>) => Observable<B>
}

Если вы хотите создать оператор, в основе которого не лежат существующие операторы, вам нужно обратить внимание на обработку ошибок и подписок. Более того, вам понадобится расширить класс Observable и реализовать функцию lift.

Если вам это интересно — загляните сюда.

Итак, давайте, основываясь на вышеприведённых фрагментах кода, напишем собственный RxJs-оператор.

import {Observable, of, throwError} from 'rxjs';
import {delay, mergeMap, retryWhen} from 'rxjs/operators'; const getErrorMessage = (maxRetry: number) => `Tried to load Resource over XHR for ${maxRetry} times without success. Giving up`; const DEFAULT_MAX_RETRIES = 5; export function delayedRetry(delayMs: number, maxRetry = DEFAULT_MAX_RETRIES) { let retries = maxRetry; return (src: Observable<any>) => src.pipe( retryWhen((errors: Observable<any>) => errors.pipe( delay(delayMs), mergeMap(error => retries-- > 0 ? of(error) : throwError(getErrorMessage(maxRetry)) )) ) );
}

Отлично. Теперь мы можем импортировать этот оператор в клиентский код. Воспользуемся им при выполнении HTTP-запроса.

return this.httpClient.get<string>(`${this.GREET_ENDPOINT}/greet`).pipe( delayedRetry(1000, 3), catchError(error => { console.log(error); // Выполняем обработку ошибок return EMPTY; }), shareReplay() );

Мы поместили оператор delayedRetry в цепочку и передали ему, в качестве параметров, числа 1000 и 3. Первый параметр задаёт задержку в миллисекундах между попытками выполнения повторных запросов. Второй параметр определяет максимальное количество повторных запросов.

Перезапустим приложение и посмотрим на то, как работает новый оператор.

После получения правильного ответа на запрос в окне приложения появится соответствующее сообщение. Проанализировав поведение программы с помощью инструментов разработчика браузера, мы можем увидеть, что выполнение повторных попыток выполнения запроса откладывается на секунду.

Экспоненциальное откладывание запроса

Давайте разовьём идею отложенного повтора неудачных запросов. Ранее мы всегда откладывали выполнение каждого из повторных запросов на одно и то же время.

Первая попытка повтора запроса выполняется через секунду, вторая — через две секунды, третья — через три. Здесь же мы поговорим о том, как увеличивать задержку после каждой попытки.

Создадим новый оператор, retryWithBackoff, который реализует это поведение.

import {Observable, of, throwError} from 'rxjs';
import {delay, mergeMap, retryWhen} from 'rxjs/operators'; const getErrorMessage = (maxRetry: number) => `Tried to load Resource over XHR for ${maxRetry} times without success. Giving up.`; const DEFAULT_MAX_RETRIES = 5;
const DEFAULT_BACKOFF = 1000; export function retryWithBackoff(delayMs: number, maxRetry = DEFAULT_MAX_RETRIES, backoffMs = DEFAULT_BACKOFF) { let retries = maxRetry; return (src: Observable<any>) => src.pipe( retryWhen((errors: Observable<any>) => errors.pipe( mergeMap(error => { if (retries-- > 0) { const backoffTime = delayMs + (maxRetry - retries) * backoffMs; return of(error).pipe(delay(backoffTime)); } return throwError(getErrorMessage(maxRetry)); } ))));
}

Если воспользоваться этим оператором в приложении и испытать его — можно увидеть, как задержка выполнения повторного запроса растёт после каждой новой попытки.

Тут, как обычно, после того, как сервер вернёт правильный ответ на запрос, мы выводим сообщение в окне приложения. После каждой попытки мы определённое время выжидаем, повторяем запрос и увеличиваем время ожидания.

Итоги

Повтор неудачных HTTP-запросов делает приложения стабильнее. Это особенно значимо при выполнении очень важных запросов, без данных, получаемых посредством которых, приложение не может нормально работать. Например, это могут быть конфигурационные данные, содержащие адреса серверов, с которыми нужно взаимодействовать приложению.

Оператор retryWhen даёт разработчику более высокий уровень контроля над повторными запросами. В большинстве сценариев оператора RxJs retry недостаточно для организации надёжной системы повтора неудачных запросов. Благодаря возможностям этого оператора можно реализовать схему отложенных повторов или повторов с экспоненциальным откладыванием. Он позволяет настраивать интервал выполнения повторных запросов.

Вот репозиторий, код из которого использовался в этом материале. При реализации в цепочках RxJS шаблонов поведения, которые подходят для повторного использования, рекомендуется оформлять их в виде новых операторов.

Уважаемые читатели! Как вы решаете задачу повтора неудачных HTTP-запросов?

Теги
Показать больше

Похожие статьи

Добавить комментарий

Ваш e-mail не будет опубликован. Обязательные поля помечены *

Кнопка «Наверх»
Закрыть