|
|
@@ -925,6 +925,42 @@ static CurlResponse performHttpRequest(
|
|
|
return response;
|
|
|
}
|
|
|
|
|
|
+
|
|
|
+// Which failures are worth trying again. A timeout, a refused connection or a
|
|
|
+// name that would not resolve are the network being briefly unwell; a 500 or a
|
|
|
+// 503 is the far end being briefly unwell; a 429 is being told to slow down.
|
|
|
+// A 404 or a 401 is an answer, and repeating the question does not change it.
|
|
|
+static bool isRetryableStatus(long status, const std::vector<long>& extra) {
|
|
|
+ for (long code : extra) {
|
|
|
+ if (code == status) return true;
|
|
|
+ }
|
|
|
+ return false;
|
|
|
+}
|
|
|
+
|
|
|
+// How long to wait before the next attempt: double each time, capped, and
|
|
|
+// honour Retry-After when the server sends one - being told to wait and then
|
|
|
+// not waiting is how a rate limit becomes a ban.
|
|
|
+static long backoffFor(int attempt, long base_ms, long cap_ms, const nlohmann::json& headers) {
|
|
|
+ long delay = base_ms;
|
|
|
+ for (int i = 1; i < attempt && delay < cap_ms; ++i) {
|
|
|
+ delay *= 2;
|
|
|
+ }
|
|
|
+ if (delay > cap_ms) delay = cap_ms;
|
|
|
+
|
|
|
+ if (headers.is_object() && headers.contains("retry-after")) {
|
|
|
+ try {
|
|
|
+ // Seconds only. The HTTP-date form exists but is rare from APIs, and
|
|
|
+ // guessing wrong is worse than falling back to the backoff.
|
|
|
+ const long asked = std::stol(headers["retry-after"].get<std::string>()) * 1000;
|
|
|
+ if (asked > 0) {
|
|
|
+ delay = asked > cap_ms ? cap_ms : asked;
|
|
|
+ }
|
|
|
+ } catch (...) {
|
|
|
+ }
|
|
|
+ }
|
|
|
+ return delay;
|
|
|
+}
|
|
|
+
|
|
|
// JavaScript HTTP request function
|
|
|
static JSValue js_http_request(JSContext* ctx, JSValue this_val, int argc, JSValue* argv) {
|
|
|
if (argc < 1 || !JS_IsObject(argv[0])) {
|
|
|
@@ -1130,9 +1166,102 @@ static JSValue js_http_request(JSContext* ctx, JSValue this_val, int argc, JSVal
|
|
|
}
|
|
|
JS_FreeValue(ctx, follow_val);
|
|
|
|
|
|
+ // Retry settings. Off unless asked for: a request that is not safe to repeat
|
|
|
+ // - a payment, a post - must not start retrying itself because a helper
|
|
|
+ // gained the ability. A node that wants it says so.
|
|
|
+ int retries = 0;
|
|
|
+ JSValue retries_val = JS_GetPropertyStr(ctx, options, "retries");
|
|
|
+ if (JS_IsNumber(retries_val)) {
|
|
|
+ double r;
|
|
|
+ JS_ToFloat64(ctx, &r, retries_val);
|
|
|
+ retries = static_cast<int>(r);
|
|
|
+ if (retries < 0) retries = 0;
|
|
|
+ if (retries > 20) retries = 20; // a runaway loop guard, not a real limit
|
|
|
+ }
|
|
|
+ JS_FreeValue(ctx, retries_val);
|
|
|
+
|
|
|
+ long retry_delay_ms = 1000;
|
|
|
+ JSValue retry_delay_val = JS_GetPropertyStr(ctx, options, "retryDelayMs");
|
|
|
+ if (JS_IsNumber(retry_delay_val)) {
|
|
|
+ double d;
|
|
|
+ JS_ToFloat64(ctx, &d, retry_delay_val);
|
|
|
+ if (d > 0) retry_delay_ms = static_cast<long>(d);
|
|
|
+ }
|
|
|
+ JS_FreeValue(ctx, retry_delay_val);
|
|
|
+
|
|
|
+ long retry_max_delay_ms = 30000;
|
|
|
+ JSValue retry_max_val = JS_GetPropertyStr(ctx, options, "retryMaxDelayMs");
|
|
|
+ if (JS_IsNumber(retry_max_val)) {
|
|
|
+ double d;
|
|
|
+ JS_ToFloat64(ctx, &d, retry_max_val);
|
|
|
+ if (d > 0) retry_max_delay_ms = static_cast<long>(d);
|
|
|
+ }
|
|
|
+ JS_FreeValue(ctx, retry_max_val);
|
|
|
+
|
|
|
+ // Too many requests, and the five ways a server says "not now".
|
|
|
+ std::vector<long> retry_statuses = {408, 425, 429, 500, 502, 503, 504};
|
|
|
+ JSValue retry_status_val = JS_GetPropertyStr(ctx, options, "retryOnStatus");
|
|
|
+ if (JS_IsArray(ctx, retry_status_val)) {
|
|
|
+ retry_statuses.clear();
|
|
|
+ int64_t count = 0;
|
|
|
+ JSValue len_val = JS_GetPropertyStr(ctx, retry_status_val, "length");
|
|
|
+ JS_ToInt64(ctx, &count, len_val);
|
|
|
+ JS_FreeValue(ctx, len_val);
|
|
|
+ for (int64_t i = 0; i < count; ++i) {
|
|
|
+ JSValue entry = JS_GetPropertyUint32(ctx, retry_status_val, static_cast<uint32_t>(i));
|
|
|
+ if (JS_IsNumber(entry)) {
|
|
|
+ double code;
|
|
|
+ JS_ToFloat64(ctx, &code, entry);
|
|
|
+ retry_statuses.push_back(static_cast<long>(code));
|
|
|
+ }
|
|
|
+ JS_FreeValue(ctx, entry);
|
|
|
+ }
|
|
|
+ }
|
|
|
+ JS_FreeValue(ctx, retry_status_val);
|
|
|
+
|
|
|
// Perform the request
|
|
|
try {
|
|
|
- CurlResponse response = performHttpRequest(method, url, headers, body, timeout_ms, follow_redirects);
|
|
|
+ CurlResponse response;
|
|
|
+ const int attempts = retries + 1;
|
|
|
+ std::string last_transport_error;
|
|
|
+
|
|
|
+ for (int attempt = 1; attempt <= attempts; ++attempt) {
|
|
|
+ bool retryable = false;
|
|
|
+ try {
|
|
|
+ response = performHttpRequest(method, url, headers, body, timeout_ms, follow_redirects);
|
|
|
+ last_transport_error.clear();
|
|
|
+ retryable = isRetryableStatus(response.status_code, retry_statuses);
|
|
|
+ if (!retryable) {
|
|
|
+ break;
|
|
|
+ }
|
|
|
+ } catch (const std::exception& transport) {
|
|
|
+ // No response at all - timed out, refused, unresolvable. Worth
|
|
|
+ // another go; if this was the last one it is rethrown below.
|
|
|
+ last_transport_error = transport.what();
|
|
|
+ retryable = true;
|
|
|
+ response.status_code = 0;
|
|
|
+ response.body.clear();
|
|
|
+ response.headers.clear();
|
|
|
+ }
|
|
|
+
|
|
|
+ if (attempt == attempts) {
|
|
|
+ if (!last_transport_error.empty()) {
|
|
|
+ throw std::runtime_error(last_transport_error + " (after " +
|
|
|
+ std::to_string(attempts) + " attempt(s))");
|
|
|
+ }
|
|
|
+ break;
|
|
|
+ }
|
|
|
+
|
|
|
+ const long wait_ms = backoffFor(attempt, retry_delay_ms, retry_max_delay_ms,
|
|
|
+ parseHeaders(response.headers));
|
|
|
+ LOG_WARN("http.request {} {} - {}, waiting {}ms then attempt {} of {}",
|
|
|
+ method, url,
|
|
|
+ last_transport_error.empty()
|
|
|
+ ? ("HTTP " + std::to_string(response.status_code))
|
|
|
+ : last_transport_error,
|
|
|
+ wait_ms, attempt + 1, attempts);
|
|
|
+ std::this_thread::sleep_for(std::chrono::milliseconds(wait_ms));
|
|
|
+ }
|
|
|
|
|
|
// Create response object
|
|
|
JSValue result = JS_NewObject(ctx);
|