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

mendersoftware / mender / 2278432114

22 Jan 2026 09:24AM UTC coverage: 79.841% (-0.01%) from 79.855%
2278432114

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>

29 of 34 new or added lines in 3 files covered. (85.29%)

153 existing lines in 7 files now uncovered.

7929 of 9931 relevant lines covered (79.84%)

13855.19 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 <ctime>
21
#include <iomanip>
22
#include <string>
23

24
#include <common/common.hpp>
25

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

30
namespace common = mender::common;
31

32
const HttpErrorCategoryClass HttpErrorCategory;
33

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

233
expected::Expected<chrono::seconds> GetRemainingTime(const string &date) {
3✔
234
        if (!date.empty() && std::all_of(date.begin(), date.end(), ::isdigit)) {
3✔
235
                return chrono::seconds(stoi(date));
2✔
236
        }
237

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

483
        return current_interval;
484
}
485

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

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

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

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

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

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

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

547
        return false;
548
}
549

550
} // namespace http
551
} // namespace common
552
} // 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