Опрос — это способ обработки длительной, отложенной, поставленной в очередь, асинхронной работы без блокировки 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
.