Добавьте дружественный HTTP-опрос в ваш API с помощью Retry-After

Опрос — это способ обработки длительной, отложенной, поставленной в очередь, асинхронной работы без блокировки TCP-соединения. Для выполнения (длительного) опроса на http-сервере нам нужно как минимум 2 конечные точки:

  • Конечная точка для запуска работы (например: POST /start_work или POST /work для REST api).
  • Конечная точка для предоставления результата работы, когда он будет готов, или статус «еще не готов», чтобы сказать «повторите попытку позже» (например, GET /work/{work_id}).

Такой подход подразумевает логику для каждой конечной точки 🙁 , которую обрабатывает вызывающая сторона!

  • Как считать work_id из результата POST /start_work? Код статуса 200 или 202?
  • Как преобразовать work_id в url-запрос для GET /work/{work_id} и обработать ответ (готов или не готов)?
  • Каков интервал повторных попыток? Определен ли он в документации, как произвольное значение или как необязательная информация в ответе?

Эволюция концепции: использование http redirect & retry-after

  • Сервер предоставляет не интервал, а оценку того, когда следует попробовать в следующий раз, через http-атрибут Retry-After (нечувствительный к регистру).
  • Сервер предоставляет конечную точку для получения результата через код состояния 303 SEE_OTHER и http-атрибут Location (и косвенно запускает повторную попытку).
  • Информация предоставляется через http код статуса и атрибуты, такие как обработка аутентификации, трассировка, прерывание цепи, ограничение скорости…

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

На стороне сервера переключение между опросом и прямым ответом прозрачно для вызывающей стороны.

✅ Плюсы

  • сервер может регулировать Retry-After, с оценкой, основанной на текущей нагрузке, ходе работы,…
  • сервер может регулировать местоположение ответа, возможно, добавлять дополнительные параметры запроса, …
  • протокол становится независимым от конечной точки (может стать «стандартом»)
  • вызывающая сторона и пользователь-агент могут обрабатывать опрос по своему усмотрению, это может быть как в первом примере (с большей информацией) или более сложным способом с промежуточным состоянием очереди, через sidecar или proxy…
    • user-agent может автоматически следовать за редиректом или нет, и обрабатывать их как блокирующий или неблокирующий способ
    • user-agent обрабатывает retry-after как retries on
    • ограничение скорости: 429 (Слишком много запросов) + повторная попытка после
    • время простоя: 503 (Сервис недоступен) + Retry-After
    • work_id & polling может быть (почти) скрыт для вызывающей стороны, это как обычный POST запрос, который возвращает ответ

❌ Конс

  • вызывающая сторона должна обрабатывать ответ GET /work/{work_id} как ответ POST /start_work (в обоих случаях возможна ошибка, …)
  • часто реализация пользовательского агента о последующем перенаправлении должна быть изменена или обработана оберткой
    • user-agent должен изменить метод с POST на GET при перенаправлении (разрешено для 301 (Move Permanently), 302 (Found), 303 (See Other)), это поведение может быть закодировано на уровне обертки user-agent.
    • некоторые user-agent не обрабатывают Retry-After (помните, что http-заголовки не чувствительны к регистру)
    • некоторые user-agent имеют максимальное количество перенаправлений (например, в curl Maximum (50) redirects followed).

Ссылки

Извлечено из RFC 7231 — Hypertext Transfer Protocol (HTTP/1.1): Семантика и содержание аналогичная информация доступна на сайте M

  • 303 See Other

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

Извлечено из Retry-After — HTTP | MDN

HTTP-заголовок Retry-After ответа указывает, как долго агент пользователя должен ждать, прежде чем сделать повторный запрос. Существует три основных случая использования этого заголовка:

  • Когда он отправляется с ответом 503 (Service Unavailable), он указывает, как долго сервис будет недоступен.
  • При отправке с ответом 429 (Too Many Requests) указывает, как долго следует ждать, прежде чем делать новый запрос.
  • При отправке ответа с перенаправлением, например 301 (Moved Permanently), это указывает минимальное время, которое агент пользователя должен подождать перед отправкой перенаправленного запроса.

Реализации (они же PoC)

🚧
ВНИМАНИЕ: код не является оптимальным, и многое может быть улучшено. PR & отзывы приветствуются (улучшения, реализация для других клиентов,…)
🚧

Базовый сервер

Для PoC я создал базовый http-сервис на языке Rust. Код доступен по адресу sandbox_http/polling/server-axum в разработке — davidB/sandbox_http.

async fn start_work(Extension(works): Extension<WorkDb>) -> impl IntoResponse {
    let mut rng: StdRng = SeedableRng::from_entropy();
    let work_id = Uuid::new_v4();
    let duration = Duration::from_secs(rng.gen_range(1..=20));
    let end_at = Instant::now() + duration;

    let get_url = format!("/work/{}", work_id);
    let next_try = duration.as_secs() / 2;

    let mut works = works.lock().expect("acquire works lock to start_work");
    works.insert(
        work_id,
        Work {
            work_id,
            end_at,
            duration,
            nb_get_call: 0,
        },
    );
    (
        StatusCode::SEE_OTHER,
        [
            (http::header::LOCATION, get_url),
            (http::header::RETRY_AFTER, format!("{}", next_try)),
        ],
    )
}

async fn work(Path(work_id): Path<Uuid>, Extension(works): Extension<WorkDb>) -> impl IntoResponse {
    let mut works = works.lock().expect("acquire works lock to get_work");
    tracing::info!(?work_id, "request work result");
    match works.get_mut(&work_id) {
        None => (StatusCode::NOT_FOUND).into_response(),
        Some(work) => {
            if work.end_at > Instant::now() {
                work.nb_get_call += 1;

                let get_url = format!("/work/{}", work.work_id);
                let next_try = 1;
                (
                    StatusCode::SEE_OTHER,
                    [
                        (http::header::LOCATION, get_url),
                        (http::header::RETRY_AFTER, format!("{}", next_try)),
                    ],
                )
                    .into_response()
            } else {
                (StatusCode::OK, Json(work.clone())).into_response()
            }
        }
    }
}
Вход в полноэкранный режим Выход из полноэкранного режима
  • Не забудьте защитить конечную точку (как и другие) своего рода ограничением скорости.

Вызов с помощью curl

curl -v --location "http://localhost:8080/start_work" -d ""
Вход в полноэкранный режим Выход из полноэкранного режима
  • Используйте не -X POST, а -d "", иначе перенаправление 303 не переключается с POST на GET.
  • Неудача из-за того, что curl не поддерживает Retry-After при последующем перенаправлении (см. дату в примере ниже).
  • Цикл останавливается из-за «максимального перенаправления», без этой перегрузки безопасности клиента и сервера.
*   Trying 127.0.0.1:8080...
* Connected to localhost (127.0.0.1) port 8080 (#0)
> POST /start_work HTTP/1.1
> Host: localhost:8080
> User-Agent: curl/7.82.0
> Accept: */*
> Content-Length: 0
> Content-Type: application/x-www-form-urlencoded
> 
* Mark bundle as not supporting multiuse
< HTTP/1.1 303 See Other
< location: /work/20913b17-1df3-40ed-b26a-df50414ecc1c
< retry-after: 8
< access-control-allow-origin: *
< vary: origin
< vary: access-control-request-method
< vary: access-control-request-headers
< content-length: 0
< date: Sun, 15 May 2022 13:07:56 GMT
< 
* Connection #0 to host localhost left intact
* Issue another request to this URL: 'http://localhost:8080/work/20913b17-1df3-40ed-b26a-df50414ecc1c'
* Switch to GET
* Found bundle for host localhost: 0x5586add25af0 [serially]
* Can not multiplex, even if we wanted to!
* Re-using existing connection! (#0) with host localhost
* Connected to localhost (127.0.0.1) port 8080 (#0)
> GET /work/20913b17-1df3-40ed-b26a-df50414ecc1c HTTP/1.1
> Host: localhost:8080
> User-Agent: curl/7.82.0
> Accept: */*
> 
* Mark bundle as not supporting multiuse
< HTTP/1.1 303 See Other
< location: /work/20913b17-1df3-40ed-b26a-df50414ecc1c
< retry-after: 1
< access-control-allow-origin: *
< vary: origin
< vary: access-control-request-method
< vary: access-control-request-headers
< content-length: 0
< date: Sun, 15 May 2022 13:07:56 GMT
< 

...

* Connection #0 to host localhost left intact
* Issue another request to this URL: 'http://localhost:8080/work/20913b17-1df3-40ed-b26a-df50414ecc1c'
* Found bundle for host localhost: 0x5586add25af0 [serially]
* Can not multiplex, even if we wanted to!
* Re-using existing connection! (#0) with host localhost
* Connected to localhost (127.0.0.1) port 8080 (#0)
> GET /work/20913b17-1df3-40ed-b26a-df50414ecc1c HTTP/1.1
> Host: localhost:8080
> User-Agent: curl/7.82.0
> Accept: */*
> 
* Mark bundle as not supporting multiuse
< HTTP/1.1 303 See Other
< location: /work/20913b17-1df3-40ed-b26a-df50414ecc1c
< retry-after: 1
< access-control-allow-origin: *
< vary: origin
< vary: access-control-request-method
< vary: access-control-request-headers
< content-length: 0
< date: Sun, 15 May 2022 13:07:56 GMT
< 
* Connection #0 to host localhost left intact
* Maximum (50) redirects followed
curl: (47) Maximum (50) redirects followed
Вход в полноэкранный режим Выход из полноэкранного режима

Вызов с помощью вашего любимого языка программирования

  • Есть много шансов, что Retry-After не очень хорошо поддерживается вашим http-клиентом / user-agent. Поэтому
    • Протестируйте его, или сообщите своим потребителям API & клиентам, чтобы они протестировали его
    • откройте тикет/проблему в проекте, чтобы запросить поддержку или сделать PR
    • предоставьте обходное решение (до официальной поддержки):
    • Отключите перенаправление follow по умолчанию,
    • Реализовать перенаправление следования с поддержкой Retry-After в вашей обертке.
  • Способ обработки задержки может быть использован совместно с retry для «rate-limit», «downtime», «circuit-breaker».

В качестве демонстрации я сделал пример с reqwest — одним из самых используемых http-клиентов в Rust.
Вы можете посмотреть на него в sandbox_http/polling_with_reqwest.rs на development — davidB/sandbox_http

Результат теста:

running 1 test
110ns : check info, then continue, retry or follow
4.002665744s : check info, then continue, retry or follow
5.004217149s : check info, then continue, retry or follow
6.007647326s : check info, then continue, retry or follow
7.010080187s : check info, then continue, retry or follow
8.012471894s : check info, then continue, retry or follow
[tests/polling_with_reqwest.rs:152] &body = WorkOutput {
    nb_get_call: 4,
    duration: 8s,
}
test polling_with_reqwest ... ok

test result: ok. 1 passed; 0 failed; 0 ignored; 0 measured; 0 filtered out; finished in 8.03s
Вход в полноэкранный режим Выход из полноэкранного режима

🎉 Успех:

  • Первая повторная попытка происходит через 4с, так как мы определили на сервере половину длительности работы,
  • Следующий вызов длительностью около 1с
  • http-клиент не включает правила выделения конечной точки (нет разбора тела, нет построения url, …)
  • Поддержка другой конечной точки с опросом не требует дополнительного кода
  • Бонус: поддержка времени простоя, разрыва круга и ограничения скорости, возвращение Retry-After.

Оцените статью
Procodings.ru
Добавить комментарий