• Home
  • Features
  • Pricing
  • Docs
  • Announcements
  • Sign In

mendersoftware / mender / 2276402292

21 Jan 2026 11:56AM UTC coverage: 79.855%. First build
2276402292

push

gitlab-ci

michalkopczan
feat: Handle HTTP 429 Too Many Requests in deployment polling

Ticket: MEN-8850
Changelog: Title

Signed-off-by: Michal Kopczan <michal.kopczan@northern.tech>

30 of 35 new or added lines in 3 files covered. (85.71%)

7932 of 9933 relevant lines covered (79.86%)

13852.16 hits per line

Source File
Press 'n' to go to next uncovered line, 'b' for previous

87.9
/src/common/http/http.cpp
1
// Copyright 2023 Northern.tech AS
2
//
3
//    Licensed under the Apache License, Version 2.0 (the "License");
4
//    you may not use this file except in compliance with the License.
5
//    You may obtain a copy of the License at
6
//
7
//        http://www.apache.org/licenses/LICENSE-2.0
8
//
9
//    Unless required by applicable law or agreed to in writing, software
10
//    distributed under the License is distributed on an "AS IS" BASIS,
11
//    WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
12
//    See the License for the specific language governing permissions and
13
//    limitations under the License.
14

15
#include <common/http.hpp>
16

17
#include <algorithm>
18
#include <cctype>
19
#include <cstdlib>
20
#include <iomanip>
21
#include <string>
22

23
#include <common/common.hpp>
24

25
namespace mender {
26
namespace common {
27
namespace http {
28

29
namespace common = mender::common;
30

31
const HttpErrorCategoryClass HttpErrorCategory;
32

33
const char *HttpErrorCategoryClass::name() const noexcept {
×
34
        return "HttpErrorCategory";
×
35
}
36

37
string HttpErrorCategoryClass::message(int code) const {
41✔
38
        switch (code) {
41✔
39
        case NoError:
40
                return "Success";
×
41
        case NoSuchHeaderError:
42
                return "No such header";
12✔
43
        case InvalidUrlError:
44
                return "Malformed URL";
×
45
        case BodyMissingError:
46
                return "Body is missing";
×
47
        case BodyIgnoredError:
48
                return "HTTP stream contains a body, but a reader has not been created for it";
16✔
49
        case HTTPInitError:
50
                return "Failed to initialize the client";
×
51
        case UnsupportedMethodError:
52
                return "Unsupported HTTP method";
×
53
        case StreamCancelledError:
54
                return "Stream has been cancelled/destroyed";
×
55
        case MaxRetryError:
56
                return "Tried maximum number of times";
4✔
57
        case DownloadResumerError:
58
                return "Resume download error";
7✔
59
        case ProxyError:
60
                return "Proxy error";
2✔
61
        case InvalidDateFormatError:
NEW
62
                return "Invalid date format error";
×
63
        }
64
        // Don't use "default" case. This should generate a warning if we ever add any enums. But
65
        // still assert here for safety.
66
        assert(false);
67
        return "Unknown";
×
68
}
69

70
error::Error MakeError(ErrorCode code, const string &msg) {
107✔
71
        return error::Error(error_condition(code, HttpErrorCategory), msg);
1,039✔
72
}
73

74
string MethodToString(Method method) {
190✔
75
        switch (method) {
190✔
76
        case Method::Invalid:
77
                return "Invalid";
×
78
        case Method::GET:
79
                return "GET";
169✔
80
        case Method::HEAD:
81
                return "HEAD";
×
82
        case Method::POST:
83
                return "POST";
2✔
84
        case Method::PUT:
85
                return "PUT";
15✔
86
        case Method::PATCH:
87
                return "PATCH";
×
88
        case Method::CONNECT:
89
                return "CONNECT";
4✔
90
        }
91
        // Don't use "default" case. This should generate a warning if we ever add any methods. But
92
        // still assert here for safety.
93
        assert(false);
94
        return "INVALID_METHOD";
×
95
}
96

97
error::Error BreakDownUrl(const string &url, BrokenDownUrl &address, bool with_auth) {
557✔
98
        const string url_split {"://"};
557✔
99

100
        auto split_index = url.find(url_split);
557✔
101
        if (split_index == string::npos) {
557✔
102
                return MakeError(InvalidUrlError, url + " is not a valid URL.");
4✔
103
        }
104
        if (split_index == 0) {
555✔
105
                return MakeError(InvalidUrlError, url + ": missing hostname");
×
106
        }
107

108
        address.protocol = url.substr(0, split_index);
1,110✔
109

110
        auto tmp = url.substr(split_index + url_split.size());
555✔
111
        split_index = tmp.find("/");
555✔
112
        if (split_index == string::npos) {
555✔
113
                address.host = tmp;
359✔
114
                address.path = "/";
359✔
115
        } else {
116
                address.host = tmp.substr(0, split_index);
196✔
117
                address.path = tmp.substr(split_index);
392✔
118
        }
119

120
        auto auth_index = address.host.rfind("@");
555✔
121
        if (auth_index != string::npos) {
555✔
122
                if (!with_auth) {
8✔
123
                        address = {};
1✔
124
                        return error::Error(
125
                                make_error_condition(errc::not_supported),
2✔
126
                                "URL Username and password is not supported");
2✔
127
                }
128
                auto user_password = address.host.substr(0, auth_index);
7✔
129
                address.host = address.host.substr(auth_index + 1);
7✔
130
                auto u_pw_sep_index = user_password.find(":");
7✔
131
                if (u_pw_sep_index == string::npos) {
7✔
132
                        // no password
133
                        address.username = std::move(user_password);
1✔
134
                } else {
135
                        address.username = user_password.substr(0, u_pw_sep_index);
6✔
136
                        address.password = user_password.substr(u_pw_sep_index + 1);
12✔
137
                }
138
        }
139

140
        split_index = address.host.find(":");
554✔
141
        if (split_index != string::npos) {
554✔
142
                tmp = std::move(address.host);
543✔
143
                address.host = tmp.substr(0, split_index);
543✔
144

145
                tmp = tmp.substr(split_index + 1);
543✔
146
                auto port = common::StringTo<decltype(address.port)>(tmp);
543✔
147
                if (!port) {
543✔
148
                        address = {};
×
149
                        return port.error().WithContext(url + " contains invalid port number");
×
150
                }
151
                address.port = port.value();
543✔
152
        } else {
153
                if (address.protocol == "http") {
11✔
154
                        address.port = 80;
5✔
155
                } else if (address.protocol == "https") {
6✔
156
                        address.port = 443;
5✔
157
                } else {
158
                        address = {};
1✔
159
                        return error::Error(
160
                                make_error_condition(errc::protocol_not_supported),
2✔
161
                                "Cannot deduce port number from protocol " + address.protocol);
2✔
162
                }
163
        }
164

165
        log::Trace(
553✔
166
                "URL broken down into (protocol: " + address.protocol + "), (host: " + address.host
1,106✔
167
                + "), (port: " + to_string(address.port) + "), (path: " + address.path + "),"
1,106✔
168
                + "(username: " + address.username
1,659✔
169
                + "), (password: " + (address.password == "" ? "" : "OMITTED") + ")");
1,659✔
170

171
        return error::NoError;
553✔
172
}
173

174
string URLEncode(const string &value) {
15✔
175
        stringstream escaped;
30✔
176
        escaped << hex;
15✔
177

178
        for (auto c : value) {
288✔
179
                // Keep alphanumeric and other accepted characters intact
180
                if (isalnum(c) || c == '-' || c == '_' || c == '.' || c == '~') {
273✔
181
                        escaped << c;
251✔
182
                } else {
183
                        // Any other characters are percent-encoded
184
                        escaped << uppercase;
22✔
185
                        escaped << '%' << setw(2) << static_cast<int>(static_cast<unsigned char>(c));
22✔
186
                        escaped << nouppercase;
22✔
187
                }
188
        }
189

190
        return escaped.str();
15✔
191
}
192

193
expected::ExpectedString URLDecode(const string &value) {
14✔
194
        stringstream unescaped;
28✔
195

196
        auto len = value.length();
197
        for (size_t i = 0; i < len; i++) {
181✔
198
                if (value[i] != '%') {
172✔
199
                        unescaped << value[i];
163✔
200
                } else {
201
                        if ((i + 2 >= len) || !isxdigit(value[i + 1]) || !(isxdigit(value[i + 2]))) {
9✔
202
                                return expected::unexpected(
4✔
203
                                        MakeError(InvalidUrlError, "Incomplete % sequence in '" + value + "'"));
12✔
204
                        }
205
                        unsigned int num;
206
                        sscanf(value.substr(i + 1, 2).c_str(), "%x", &num);
5✔
207
                        if (num < 0x20) {
5✔
208
                                return expected::unexpected(
1✔
209
                                        MakeError(InvalidUrlError, "Invalid encoding in '" + value + "'"));
3✔
210
                        }
211
                        unescaped << static_cast<char>(num);
4✔
212
                        i += 2;
213
                }
214
        }
215
        return unescaped.str();
18✔
216
}
217

218
string JoinOneUrl(const string &prefix, const string &suffix) {
174✔
219
        auto prefix_end = prefix.cend();
220
        while (prefix_end != prefix.cbegin() && prefix_end[-1] == '/') {
186✔
221
                prefix_end--;
222
        }
223

224
        auto suffix_start = suffix.cbegin();
225
        while (suffix_start != suffix.cend() && *suffix_start == '/') {
248✔
226
                suffix_start++;
227
        }
228

229
        return string(prefix.cbegin(), prefix_end) + "/" + string(suffix_start, suffix.cend());
348✔
230
}
231

232
// HTTP uses either seconds directly (when it represents a time to wait, e.g. in Retry-After header)
233
// or a date in HTTP format. If it's seconds, we simply return the value converted from string.
234
expected::Expected<chrono::seconds> HTTPDateToUnixTimestampFromNow(const string &date) {
3✔
235
        if (!date.empty() && std::all_of(date.begin(), date.end(), ::isdigit)) {
3✔
236
                return chrono::seconds(stoi(date));
2✔
237
        }
238

239
        struct tm tm_struct = {};
1✔
240
        if (strptime(date.c_str(), "%a, %d %b %Y %H:%M:%S GMT", &tm_struct) == nullptr) {
1✔
NEW
241
                return expected::unexpected(MakeError(InvalidDateFormatError, "Invalid date format"));
×
242
        }
243

244
        time_t expiry_time = timegm(&tm_struct);
1✔
245
        time_t now = time(nullptr);
1✔
246

247
        if (expiry_time < now) {
1✔
248
                return chrono::seconds(0);
249
        }
250

251
        return chrono::seconds(expiry_time - now);
1✔
252
}
253

254
size_t CaseInsensitiveHasher::operator()(const string &str) const {
4,699✔
255
        return hash<string>()(common::StringToLower(str));
4,699✔
256
}
257

258
bool CaseInsensitiveComparator::operator()(const string &str1, const string &str2) const {
1,596✔
259
        return strcasecmp(str1.c_str(), str2.c_str()) == 0;
1,596✔
260
}
261

262
expected::ExpectedString Transaction::GetHeader(const string &name) const {
1,564✔
263
        if (headers_.find(name) == headers_.end()) {
1,564✔
264
                return expected::unexpected(MakeError(NoSuchHeaderError, "No such header: " + name));
2,664✔
265
        }
266
        return headers_.at(name);
676✔
267
}
268

269
string Request::GetHost() const {
330✔
270
        return address_.host;
330✔
271
}
272

273
string Request::GetProtocol() const {
×
274
        return address_.protocol;
×
275
}
276

277
int Request::GetPort() const {
985✔
278
        return address_.port;
985✔
279
}
280

281
Method Request::GetMethod() const {
92✔
282
        return method_;
92✔
283
}
284

285
string Request::GetPath() const {
155✔
286
        return address_.path;
155✔
287
}
288

289
unsigned Response::GetStatusCode() const {
746✔
290
        return status_code_;
746✔
291
}
292

293
string Response::GetStatusMessage() const {
453✔
294
        return status_message_;
453✔
295
}
296

297
void BaseOutgoingRequest::SetMethod(Method method) {
249✔
298
        method_ = method;
249✔
299
}
249✔
300

301
void BaseOutgoingRequest::SetHeader(const string &name, const string &value) {
938✔
302
        headers_[name] = value;
303
}
938✔
304

305
void BaseOutgoingRequest::SetBodyGenerator(BodyGenerator body_gen) {
40✔
306
        async_body_gen_ = nullptr;
40✔
307
        async_body_reader_ = nullptr;
40✔
308
        body_gen_ = body_gen;
40✔
309
}
40✔
310

311
void BaseOutgoingRequest::SetAsyncBodyGenerator(AsyncBodyGenerator body_gen) {
6✔
312
        body_gen_ = nullptr;
6✔
313
        body_reader_ = nullptr;
6✔
314
        async_body_gen_ = body_gen;
6✔
315
}
6✔
316

317
error::Error OutgoingRequest::SetAddress(const string &address) {
231✔
318
        orig_address_ = address;
231✔
319

320
        return BreakDownUrl(address, address_);
231✔
321
}
322

323
IncomingRequest::~IncomingRequest() {
570✔
324
        if (!*cancelled_) {
285✔
325
                stream_.server_.RemoveStream(stream_.shared_from_this());
×
326
        }
327
}
285✔
328

329
void IncomingRequest::Cancel() {
2✔
330
        if (!*cancelled_) {
2✔
331
                stream_.Cancel();
2✔
332
        }
333
}
2✔
334

335
io::ExpectedAsyncReaderPtr IncomingRequest::MakeBodyAsyncReader() {
60✔
336
        if (*cancelled_) {
60✔
337
                return expected::unexpected(MakeError(
×
338
                        StreamCancelledError, "Cannot make reader for a request that doesn't exist anymore"));
×
339
        }
340
        return stream_.server_.MakeBodyAsyncReader(shared_from_this());
120✔
341
}
342

343
void IncomingRequest::SetBodyWriter(io::WriterPtr writer) {
46✔
344
        auto exp_reader = MakeBodyAsyncReader();
46✔
345
        if (!exp_reader) {
46✔
346
                if (exp_reader.error().code != MakeError(BodyMissingError, "").code) {
20✔
347
                        log::Error(exp_reader.error().String());
×
348
                }
349
                return;
350
        }
351
        auto &reader = exp_reader.value();
36✔
352

353
        io::AsyncCopy(writer, reader, [reader](error::Error err) {
2,598✔
354
                if (err != error::NoError) {
36✔
355
                        log::Error("Could not copy HTTP stream: " + err.String());
4✔
356
                }
357
        });
108✔
358
}
359

360
ExpectedOutgoingResponsePtr IncomingRequest::MakeResponse() {
276✔
361
        if (*cancelled_) {
276✔
362
                return expected::unexpected(MakeError(
×
363
                        StreamCancelledError, "Cannot make response for a request that doesn't exist anymore"));
×
364
        }
365
        return stream_.server_.MakeResponse(shared_from_this());
552✔
366
}
367

368
IncomingResponse::IncomingResponse(ClientInterface &client, shared_ptr<bool> cancelled) :
372✔
369
        client_ {client},
370
        cancelled_ {cancelled} {
744✔
371
}
372✔
372

373
void IncomingResponse::Cancel() {
1✔
374
        if (!*cancelled_) {
1✔
375
                client_.Cancel();
1✔
376
        }
377
}
1✔
378

379
io::ExpectedAsyncReaderPtr IncomingResponse::MakeBodyAsyncReader() {
153✔
380
        if (*cancelled_) {
153✔
381
                return expected::unexpected(MakeError(
×
382
                        StreamCancelledError, "Cannot make reader for a response that doesn't exist anymore"));
×
383
        }
384
        return client_.MakeBodyAsyncReader(shared_from_this());
306✔
385
}
386

387
void IncomingResponse::SetBodyWriter(io::WriterPtr writer) {
87✔
388
        auto exp_reader = MakeBodyAsyncReader();
87✔
389
        if (!exp_reader) {
87✔
390
                if (exp_reader.error().code != MakeError(BodyMissingError, "").code) {
26✔
391
                        log::Error(exp_reader.error().String());
×
392
                }
393
                return;
394
        }
395
        auto &reader = exp_reader.value();
74✔
396

397
        io::AsyncCopy(writer, reader, [reader](error::Error err) {
5,905✔
398
                if (err != error::NoError) {
54✔
399
                        log::Error("Could not copy HTTP stream: " + err.String());
8✔
400
                }
401
        });
202✔
402
}
403

404
io::ExpectedAsyncReadWriterPtr IncomingResponse::SwitchProtocol() {
8✔
405
        if (*cancelled_) {
8✔
406
                return expected::unexpected(MakeError(
1✔
407
                        StreamCancelledError, "Cannot switch protocol when the stream doesn't exist anymore"));
3✔
408
        }
409
        return client_.GetHttpClient().SwitchProtocol(shared_from_this());
14✔
410
}
411

412
OutgoingResponse::~OutgoingResponse() {
552✔
413
        if (!*cancelled_) {
276✔
414
                stream_.server_.RemoveStream(stream_.shared_from_this());
8✔
415
        }
416
}
276✔
417

418
void OutgoingResponse::Cancel() {
1✔
419
        if (!*cancelled_) {
1✔
420
                stream_.Cancel();
1✔
421
                stream_.server_.RemoveStream(stream_.shared_from_this());
2✔
422
        }
423
}
1✔
424

425
void OutgoingResponse::SetStatusCodeAndMessage(unsigned code, const string &message) {
272✔
426
        status_code_ = code;
272✔
427
        status_message_ = message;
272✔
428
}
272✔
429

430
void OutgoingResponse::SetHeader(const string &name, const string &value) {
337✔
431
        headers_[name] = value;
432
}
337✔
433

434
void OutgoingResponse::SetBodyReader(io::ReaderPtr body_reader) {
231✔
435
        async_body_reader_ = nullptr;
231✔
436
        body_reader_ = body_reader;
437
}
231✔
438

439
void OutgoingResponse::SetAsyncBodyReader(io::AsyncReaderPtr body_reader) {
4✔
440
        body_reader_ = nullptr;
4✔
441
        async_body_reader_ = body_reader;
442
}
4✔
443

444
error::Error OutgoingResponse::AsyncReply(ReplyFinishedHandler reply_finished_handler) {
263✔
445
        if (*cancelled_) {
263✔
446
                return MakeError(StreamCancelledError, "Cannot reply when response doesn't exist anymore");
2✔
447
        }
448
        return stream_.server_.AsyncReply(shared_from_this(), reply_finished_handler);
786✔
449
}
450

451
error::Error OutgoingResponse::AsyncSwitchProtocol(SwitchProtocolHandler handler) {
9✔
452
        if (*cancelled_) {
9✔
453
                return MakeError(
454
                        StreamCancelledError, "Cannot switch protocol when response doesn't exist anymore");
×
455
        }
456
        return stream_.server_.AsyncSwitchProtocol(shared_from_this(), handler);
27✔
457
}
458

459
ExponentialBackoff::ExpectedInterval ExponentialBackoff::NextInterval() {
179✔
460
        iteration_++;
179✔
461

462
        if (try_count_ > 0 && iteration_ > try_count_) {
179✔
463
                return expected::unexpected(MakeError(MaxRetryError, "Exponential backoff"));
21✔
464
        }
465

466
        chrono::milliseconds current_interval = smallest_interval_;
172✔
467
        // Backoff algorithm: Each interval is returned three times, then it's doubled, and then
468
        // that is returned three times, and so on. But if interval is ever higher than the max
469
        // interval, then return the max interval instead, and once that is returned three times,
470
        // produce MaxRetryError. If try_count_ is set, then that controls the total number of
471
        // retries, but the rest is the same, so then it simply "gets stuck" at max interval for
472
        // many iterations.
473
        for (int count = 3; count < iteration_; count += 3) {
375✔
474
                auto new_interval = current_interval * 2;
475
                if (new_interval > max_interval_) {
476
                        new_interval = max_interval_;
477
                }
478
                if (try_count_ <= 0 && new_interval == current_interval) {
208✔
479
                        return expected::unexpected(MakeError(MaxRetryError, "Exponential backoff"));
15✔
480
                }
481
                current_interval = new_interval;
482
        }
483

484
        return current_interval;
485
}
486

487
static expected::ExpectedString GetProxyStringFromEnvironment(
456✔
488
        const string &primary, const string &secondary) {
489
        bool primary_set = false, secondary_set = false;
490

491
        if (getenv(primary.c_str()) != nullptr && getenv(primary.c_str())[0] != '\0') {
456✔
492
                primary_set = true;
493
        }
494
        if (getenv(secondary.c_str()) != nullptr && getenv(secondary.c_str())[0] != '\0') {
456✔
495
                secondary_set = true;
496
        }
497

498
        if (primary_set && secondary_set) {
456✔
499
                return expected::unexpected(error::Error(
3✔
500
                        make_error_condition(errc::invalid_argument),
6✔
501
                        primary + " and " + secondary
6✔
502
                                + " environment variables can't both be set at the same time"));
9✔
503
        } else if (primary_set) {
453✔
504
                return getenv(primary.c_str());
3✔
505
        } else if (secondary_set) {
450✔
506
                return getenv(secondary.c_str());
×
507
        } else {
508
                return "";
450✔
509
        }
510
}
511

512
// The proxy variables aren't standardized, but this page was useful for the common patterns:
513
// https://superuser.com/questions/944958/are-http-proxy-https-proxy-and-no-proxy-environment-variables-standard
514
expected::ExpectedString GetHttpProxyStringFromEnvironment() {
153✔
515
        if (getenv("REQUEST_METHOD") != nullptr && getenv("HTTP_PROXY") != nullptr) {
153✔
516
                return expected::unexpected(error::Error(
×
517
                        make_error_condition(errc::operation_not_permitted),
×
518
                        "Using REQUEST_METHOD (CGI) together with HTTP_PROXY is insecure. See https://github.com/golang/go/issues/16405"));
×
519
        }
520
        return GetProxyStringFromEnvironment("http_proxy", "HTTP_PROXY");
306✔
521
}
522

523
expected::ExpectedString GetHttpsProxyStringFromEnvironment() {
152✔
524
        return GetProxyStringFromEnvironment("https_proxy", "HTTPS_PROXY");
304✔
525
}
526

527
expected::ExpectedString GetNoProxyStringFromEnvironment() {
151✔
528
        return GetProxyStringFromEnvironment("no_proxy", "NO_PROXY");
302✔
529
}
530

531
// The proxy variables aren't standardized, but this page was useful for the common patterns:
532
// https://superuser.com/questions/944958/are-http-proxy-https-proxy-and-no-proxy-environment-variables-standard
533
bool HostNameMatchesNoProxy(const string &host, const string &no_proxy) {
43✔
534
        auto entries = common::SplitString(no_proxy, " ");
129✔
535
        for (string &entry : entries) {
80✔
536
                if (entry[0] == '.') {
49✔
537
                        // Wildcard.
538
                        ssize_t wildcard_len = entry.size() - 1;
5✔
539
                        if (wildcard_len == 0
540
                                || entry.compare(0, wildcard_len, host, host.size() - wildcard_len)) {
5✔
541
                                return true;
5✔
542
                        }
543
                } else if (host == entry) {
44✔
544
                        return true;
545
                }
546
        }
547

548
        return false;
549
}
550

551
} // namespace http
552
} // namespace common
553
} // namespace mender
STATUS · Troubleshooting · Open an Issue · Sales · Support · CAREERS · ENTERPRISE · START FREE · SCHEDULE DEMO
ANNOUNCEMENTS · TWITTER · TOS & SLA · Supported CI Services · What's a CI service? · Automated Testing

© 2026 Coveralls, Inc