@alteriom/painlessmesh 2.0.3 → 2.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -81,6 +81,55 @@ struct SendOptions {
81
81
  typedef std::function<void(bool success, uint16_t httpStatus, TSTRING error)>
82
82
  internetResultCallback_t;
83
83
 
84
+ /**
85
+ * Defined when sendToInternet() accepts an InternetResult callback, the
86
+ * gateway only retries requests that cannot have reached the server, and
87
+ * each request carries X-Request-Id. Code that must build against older
88
+ * releases too can test for it with #ifdef.
89
+ */
90
+ #define PAINLESSMESH_HAS_INTERNET_RESULT 1
91
+
92
+ /**
93
+ * Everything the library learned about one sendToInternet() call
94
+ *
95
+ * The library applies HTTP's meaning of a reply and nothing more. Whether a
96
+ * particular service's answer means what the application wanted -- a message
97
+ * really queued, a record really stored -- depends on that service, so the
98
+ * application decides, from `httpStatus` and `response`.
99
+ */
100
+ struct InternetResult {
101
+ /** The id sendToInternet() returned. */
102
+ uint32_t messageId = 0;
103
+ /** True for HTTP 200, 201, 202 and 204. */
104
+ bool success = false;
105
+ /** The destination's HTTP status; 0 when no reply arrived. */
106
+ uint16_t httpStatus = 0;
107
+ /** Why it failed, for a log or a user; empty on success. */
108
+ TSTRING error;
109
+ /** The start of the response body as one line, when a body was read. */
110
+ TSTRING response;
111
+ /**
112
+ * True when the request cannot have reached the server, or the server said
113
+ * it did not take it. False when it may have been processed: resending it
114
+ * is then the application's decision, because it could arrive twice.
115
+ */
116
+ bool retryable = false;
117
+ /** How many times the request was issued, retries included. */
118
+ uint8_t attempts = 0;
119
+ };
120
+
121
+ /**
122
+ * Callback type for Internet request results, with the whole result
123
+ *
124
+ * \code
125
+ * mesh.sendToInternet(url, "", [](const InternetResult& result) {
126
+ * Serial.printf("HTTP %u: %s\n", result.httpStatus, result.response.c_str());
127
+ * });
128
+ * \endcode
129
+ */
130
+ typedef std::function<void(const InternetResult& result)>
131
+ internetResponseCallback_t;
132
+
84
133
  /**
85
134
  * Pending Internet request entry
86
135
  *
@@ -98,6 +147,12 @@ struct PendingInternetRequest {
98
147
  TSTRING destination = ""; ///< Internet destination URL
99
148
  TSTRING payload = ""; ///< Request payload
100
149
  internetResultCallback_t callback; ///< User callback for result
150
+ internetResponseCallback_t onResult; ///< Or the whole-result callback
151
+ /// Times the request was handed to a gateway: 0 until the first send
152
+ /// leaves this node, whatever retries were scheduled meanwhile.
153
+ uint8_t attempts = 0;
154
+ /// Drawn once for the call, sent on every attempt (GatewayDataPackage).
155
+ uint32_t requestNonce = 0;
101
156
  /// Why the last attempt failed, when retries were spent waiting for a
102
157
  /// gateway; the final callback reports it instead of a bare retry count.
103
158
  TSTRING lastError = "";
@@ -1721,6 +1776,61 @@ class Mesh : public ntp::MeshTime, public plugin::PackageHandler<T> {
1721
1776
  TSTRING payload,
1722
1777
  internetResultCallback_t callback,
1723
1778
  uint8_t priority = static_cast<uint8_t>(gateway::GatewayPriority::PRIORITY_NORMAL)) {
1779
+ return startInternetRequest(destination, payload, callback, nullptr, priority);
1780
+ }
1781
+
1782
+ /**
1783
+ * Send data to an Internet destination, receiving the whole result
1784
+ *
1785
+ * The same request as the (success, httpStatus, error) form, but the
1786
+ * callback also gets the start of the response body, whether the request
1787
+ * may be resent safely, and how many times it was issued. Use it when a
1788
+ * service's status alone does not say whether it did what was asked -- the
1789
+ * sendToInternet example judges CallMeBot's replies this way.
1790
+ *
1791
+ * @return Unique message ID for tracking, or 0 if immediate failure
1792
+ */
1793
+ uint32_t sendToInternet(
1794
+ TSTRING destination,
1795
+ TSTRING payload,
1796
+ internetResponseCallback_t onResult,
1797
+ uint8_t priority = static_cast<uint8_t>(gateway::GatewayPriority::PRIORITY_NORMAL)) {
1798
+ return startInternetRequest(destination, payload, nullptr, onResult, priority);
1799
+ }
1800
+
1801
+ private:
1802
+ /** Hand a finished request's result to whichever callback it was given. */
1803
+ static void deliverInternetResult(const PendingInternetRequest& request,
1804
+ InternetResult result) {
1805
+ result.messageId = request.messageId;
1806
+ result.attempts = request.attempts;
1807
+ // A request that never left this node -- refused for want of a mesh or a
1808
+ // gateway, or whose every routing attempt failed to send -- cannot have
1809
+ // reached a server, whatever ended it: resending it cannot duplicate.
1810
+ if (!result.success && request.attempts == 0) {
1811
+ result.retryable = true;
1812
+ }
1813
+ if (request.onResult) {
1814
+ request.onResult(result);
1815
+ } else if (request.callback) {
1816
+ request.callback(result.success, result.httpStatus, result.error);
1817
+ }
1818
+ }
1819
+
1820
+ static InternetResult failedInternetResult(const TSTRING& error,
1821
+ uint16_t httpStatus = 0) {
1822
+ InternetResult result;
1823
+ result.httpStatus = httpStatus;
1824
+ result.error = error;
1825
+ return result;
1826
+ }
1827
+
1828
+ uint32_t startInternetRequest(
1829
+ TSTRING destination,
1830
+ TSTRING payload,
1831
+ internetResultCallback_t callback,
1832
+ internetResponseCallback_t onResult,
1833
+ uint8_t priority) {
1724
1834
  using namespace logger;
1725
1835
 
1726
1836
  // Generate unique message ID
@@ -1745,20 +1855,32 @@ class Mesh : public ntp::MeshTime, public plugin::PackageHandler<T> {
1745
1855
  request.destination = destination;
1746
1856
  request.payload = payload;
1747
1857
  request.callback = callback;
1858
+ request.onResult = onResult;
1859
+ request.requestNonce = gateway::newRequestNonce();
1860
+ // Handed to this node's own handler below, unconditionally.
1861
+ request.attempts = 1;
1862
+ const uint32_t nonce = request.requestNonce;
1748
1863
  pendingInternetRequests[messageId] = request;
1749
1864
 
1750
1865
  dispatchInternetRequestLocally(messageId, priority, 0, destination,
1751
- payload);
1866
+ payload, nonce);
1752
1867
  return messageId;
1753
1868
  }
1754
1869
 
1870
+ // A request refused before it is tracked still reports through the
1871
+ // callback it was given, on the scheduler rather than inside this call.
1872
+ PendingInternetRequest refused;
1873
+ refused.callback = callback;
1874
+ refused.onResult = onResult;
1875
+ refused.attempts = 0;
1876
+
1755
1877
  // Validate mesh connectivity before attempting to send
1756
1878
  if (!hasActiveMeshConnections()) {
1757
1879
  Log(ERROR, "sendToInternet(): No active mesh connections\n");
1758
- if (callback) {
1759
- // Schedule callback to avoid blocking
1760
- this->addTask([callback]() {
1761
- callback(false, 0, "No mesh connections - cannot route to gateway");
1880
+ if (callback || onResult) {
1881
+ this->addTask([refused]() {
1882
+ deliverInternetResult(refused, failedInternetResult(
1883
+ "No mesh connections - cannot route to gateway"));
1762
1884
  });
1763
1885
  }
1764
1886
  return 0;
@@ -1768,10 +1890,9 @@ class Mesh : public ntp::MeshTime, public plugin::PackageHandler<T> {
1768
1890
  BridgeInfo* gateway = getPrimaryBridge();
1769
1891
  if (gateway == nullptr) {
1770
1892
  Log(ERROR, "sendToInternet(): No gateway available\n");
1771
- if (callback) {
1772
- // Schedule callback to avoid blocking
1773
- this->addTask([callback]() {
1774
- callback(false, 0, "No gateway available");
1893
+ if (callback || onResult) {
1894
+ this->addTask([refused]() {
1895
+ deliverInternetResult(refused, failedInternetResult("No gateway available"));
1775
1896
  });
1776
1897
  }
1777
1898
  return 0;
@@ -1790,6 +1911,8 @@ class Mesh : public ntp::MeshTime, public plugin::PackageHandler<T> {
1790
1911
  request.destination = destination;
1791
1912
  request.payload = payload;
1792
1913
  request.callback = callback;
1914
+ request.onResult = onResult;
1915
+ request.requestNonce = gateway::newRequestNonce();
1793
1916
 
1794
1917
  // Store pending request
1795
1918
  pendingInternetRequests[messageId] = request;
@@ -1807,6 +1930,7 @@ class Mesh : public ntp::MeshTime, public plugin::PackageHandler<T> {
1807
1930
  pkg.contentType = "application/json";
1808
1931
  pkg.retryCount = 0;
1809
1932
  pkg.requiresAck = true;
1933
+ pkg.requestNonce = request.requestNonce;
1810
1934
 
1811
1935
  // Send the package with priority
1812
1936
  bool sent = false;
@@ -1829,6 +1953,8 @@ class Mesh : public ntp::MeshTime, public plugin::PackageHandler<T> {
1829
1953
  scheduleInternetRetry(messageId);
1830
1954
  } else {
1831
1955
  Log(COMMUNICATION, "sendToInternet(): Sent to gateway %u\n", gateway->nodeId);
1956
+ // Only a send that left this node is an attempt.
1957
+ pendingInternetRequests[messageId].attempts = 1;
1832
1958
  // Schedule timeout check
1833
1959
  scheduleInternetTimeout(messageId);
1834
1960
  }
@@ -1836,6 +1962,7 @@ class Mesh : public ntp::MeshTime, public plugin::PackageHandler<T> {
1836
1962
  return messageId;
1837
1963
  }
1838
1964
 
1965
+ public:
1839
1966
  /**
1840
1967
  * Configure Internet request timeout
1841
1968
  *
@@ -1907,11 +2034,11 @@ class Mesh : public ntp::MeshTime, public plugin::PackageHandler<T> {
1907
2034
  bool cancelInternetRequest(uint32_t messageId) {
1908
2035
  auto it = pendingInternetRequests.find(messageId);
1909
2036
  if (it != pendingInternetRequests.end()) {
1910
- auto callback = it->second.callback;
2037
+ const PendingInternetRequest cancelled = it->second;
1911
2038
  pendingInternetRequests.erase(it);
1912
- if (callback) {
1913
- this->addTask([callback]() {
1914
- callback(false, 0, "Request cancelled");
2039
+ if (cancelled.callback || cancelled.onResult) {
2040
+ this->addTask([cancelled]() {
2041
+ deliverInternetResult(cancelled, failedInternetResult("Request cancelled"));
1915
2042
  });
1916
2043
  }
1917
2044
  Log(logger::GENERAL, "cancelInternetRequest(): Cancelled msgId=%u\n", messageId);
@@ -1973,9 +2100,7 @@ class Mesh : public ntp::MeshTime, public plugin::PackageHandler<T> {
1973
2100
 
1974
2101
  // Cancel all pending requests
1975
2102
  for (auto& pair : pendingInternetRequests) {
1976
- if (pair.second.callback) {
1977
- pair.second.callback(false, 0, "API disabled");
1978
- }
2103
+ deliverInternetResult(pair.second, failedInternetResult("API disabled"));
1979
2104
  }
1980
2105
  pendingInternetRequests.clear();
1981
2106
 
@@ -2012,7 +2137,9 @@ class Mesh : public ntp::MeshTime, public plugin::PackageHandler<T> {
2012
2137
  */
2013
2138
  void sendGatewayAck(const gateway::GatewayDataPackage& request, bool success,
2014
2139
  uint16_t httpStatus, const TSTRING& error,
2015
- std::shared_ptr<T> ingressConnection = nullptr) {
2140
+ std::shared_ptr<T> ingressConnection = nullptr,
2141
+ const TSTRING& response = TSTRING(), int8_t retryable = -1,
2142
+ uint32_t retryAfterMs = 0) {
2016
2143
  using namespace logger;
2017
2144
 
2018
2145
  gateway::GatewayAckPackage ack;
@@ -2024,13 +2151,25 @@ class Mesh : public ntp::MeshTime, public plugin::PackageHandler<T> {
2024
2151
  ack.httpStatus = httpStatus;
2025
2152
  ack.error = error;
2026
2153
  ack.timestamp = this->getNodeTime();
2154
+ ack.response = response;
2155
+ ack.retryable = retryable;
2156
+ ack.retryAfterMs = retryAfterMs;
2027
2157
 
2028
2158
  if (request.originNode == this->nodeId) {
2029
- protocol::Variant variant(&ack);
2030
- this->callbackList.execute(protocol::GATEWAY_ACK, variant, nullptr, 0);
2159
+ // Completed from the scheduler, not from inside the gateway handler
2160
+ // that called this. There the handler's HTTPClient, its WiFiClient
2161
+ // and the response buffers are all still allocated, and the
2162
+ // application's callback runs on what is left: on the HIL rig an
2163
+ // ESP8266 shared gateway with ~11 KB free delivered its own result
2164
+ // into a callback that could no longer allocate the event it built.
2165
+ this->addTask([this, ack]() {
2166
+ gateway::GatewayAckPackage local = ack;
2167
+ protocol::Variant variant(&local);
2168
+ this->callbackList.execute(protocol::GATEWAY_ACK, variant, nullptr, 0);
2169
+ });
2031
2170
  Log(COMMUNICATION,
2032
- "Completed local GATEWAY_ACK (success=%d, http=%d)\n", success,
2033
- httpStatus);
2171
+ "Completing local GATEWAY_ACK from the scheduler (success=%d, http=%d)\n",
2172
+ success, httpStatus);
2034
2173
  return;
2035
2174
  }
2036
2175
 
@@ -2067,13 +2206,17 @@ class Mesh : public ntp::MeshTime, public plugin::PackageHandler<T> {
2067
2206
 
2068
2207
  PendingInternetRequest& request = it->second;
2069
2208
 
2209
+ InternetResult result;
2210
+ result.success = ack.success;
2211
+ result.httpStatus = ack.httpStatus;
2212
+ result.error = ack.error;
2213
+ result.response = ack.response;
2214
+
2070
2215
  // Check if this is a success response
2071
2216
  if (ack.success) {
2072
- // Success - call callback and remove request
2073
- if (request.callback) {
2074
- request.callback(ack.success, ack.httpStatus, ack.error);
2075
- }
2217
+ const PendingInternetRequest done = request;
2076
2218
  pendingInternetRequests.erase(it);
2219
+ deliverInternetResult(done, result);
2077
2220
  return;
2078
2221
  }
2079
2222
 
@@ -2117,86 +2260,75 @@ class Mesh : public ntp::MeshTime, public plugin::PackageHandler<T> {
2117
2260
  }
2118
2261
  }
2119
2262
 
2120
- // Failure response - determine if retryable
2263
+ // Failure: may the identical request be sent again?
2264
+ //
2265
+ // A gateway that says so decides (2.1.0 and later): it saw the transport
2266
+ // error or the status, and it knows whether the request can have reached
2267
+ // the server. Resending one that may have been processed delivers it
2268
+ // twice -- on the rig one timed-out send was issued four times -- so only
2269
+ // a request that cannot have arrived, or one the server told us to bring
2270
+ // back (429, 503), is retried.
2121
2271
  bool isRetryable = false;
2122
-
2123
- // HTTP 203 (Non-Authoritative Information) indicates cached/proxied response
2124
- // This is often temporary and retrying may succeed when cache expires
2125
- if (ack.httpStatus == 203) {
2126
- isRetryable = true;
2127
- Log(COMMUNICATION, "handleGatewayAck(): HTTP 203 detected, marking as retryable\n");
2128
- }
2129
- // HTTP 5xx server errors are typically transient
2130
- else if (ack.httpStatus >= 500 && ack.httpStatus < 600) {
2131
- isRetryable = true;
2132
- Log(COMMUNICATION, "handleGatewayAck(): HTTP 5xx server error, marking as retryable\n");
2133
- }
2134
- // HTTP 429 (Too Many Requests) should be retried with backoff
2135
- else if (ack.httpStatus == 429) {
2136
- isRetryable = true;
2137
- Log(COMMUNICATION, "handleGatewayAck(): HTTP 429 rate limit, marking as retryable\n");
2138
- }
2139
- // Network errors (httpStatus == 0) EXCEPT gateway connectivity errors
2140
- // Gateway connectivity errors are infrastructure issues (not transient)
2141
- else if (ack.httpStatus == 0) {
2142
- // Check if this is a gateway-level connectivity error (non-retryable)
2143
- bool isGatewayConnectivityError = false;
2144
-
2145
- // These errors indicate infrastructure issues that won't be fixed by retrying:
2146
- // - "Router has no internet access" - WAN connection down
2147
- // - "Gateway WiFi not connected" - ESP not associated with WiFi
2148
- // - "Captive portal detected" - Router requires web authentication
2149
- // Use find() for std::string (test env) or indexOf() for Arduino String
2150
- bool routerError = false, wifiError = false, captivePortalError = false;
2151
- #if defined(PAINLESSMESH_BOOST)
2152
- // Test environment: TSTRING is std::string, use find()
2153
- routerError = (ack.error.find("Router has no internet") != std::string::npos);
2154
- wifiError = (ack.error.find("Gateway WiFi not connected") != std::string::npos);
2155
- captivePortalError = (ack.error.find("Captive portal detected") != std::string::npos);
2156
- #else
2157
- // Arduino environment: TSTRING is String, use indexOf()
2158
- routerError = (ack.error.indexOf("Router has no internet") >= 0);
2159
- wifiError = (ack.error.indexOf("Gateway WiFi not connected") >= 0);
2160
- captivePortalError = (ack.error.indexOf("Captive portal detected") >= 0);
2161
- #endif
2162
-
2163
- if (routerError || wifiError || captivePortalError) {
2164
- isGatewayConnectivityError = true;
2165
- Log(COMMUNICATION, "handleGatewayAck(): Gateway connectivity error detected (non-retryable): %s\n",
2166
- ack.error.c_str());
2167
- }
2168
-
2169
- // Only mark as retryable if it's NOT a gateway connectivity error
2170
- if (!isGatewayConnectivityError) {
2171
- isRetryable = true;
2172
- Log(COMMUNICATION, "handleGatewayAck(): Network error, marking as retryable\n");
2272
+ uint32_t minDelayMs = 0;
2273
+ if (ack.retryable >= 0) {
2274
+ isRetryable = ack.retryable == 1;
2275
+ if (isRetryable && ack.retryAfterMs > gateway::GATEWAY_RETRY_AFTER_MAX_MS) {
2276
+ // The server asked to wait longer than a retry will: say when, and
2277
+ // leave the resend to the application.
2278
+ isRetryable = false;
2279
+ char errorBuf[224];
2280
+ snprintf(errorBuf, sizeof(errorBuf), "%s (the server asked to retry after %lu s)",
2281
+ ack.error.c_str(),
2282
+ static_cast<unsigned long>(ack.retryAfterMs / 1000));
2283
+ result.error = TSTRING(errorBuf);
2284
+ } else if (isRetryable) {
2285
+ minDelayMs = ack.retryAfterMs;
2173
2286
  }
2287
+ } else {
2288
+ // A gateway that predates the field. Keep its behaviour -- it has
2289
+ // retried network errors and 5xx since 1.x -- except for a 2xx: the
2290
+ // server answered, so a resend is a second copy.
2291
+ isRetryable = legacyAckIsRetryable(ack);
2174
2292
  }
2175
- // HTTP 4xx client errors (except 429) are NOT retryable
2176
- // HTTP 3xx redirects are NOT retryable (should be followed by HTTPClient)
2177
- // Other status codes are NOT retryable
2293
+ result.retryable = isRetryable;
2178
2294
 
2179
2295
  // If retryable and have retries left, schedule retry
2180
2296
  if (isRetryable && request.retryCount < request.maxRetries) {
2181
2297
  Log(COMMUNICATION, "handleGatewayAck(): Scheduling retry for msgId=%u (attempt %u/%u)\n",
2182
2298
  ack.messageId, request.retryCount + 1, request.maxRetries);
2183
- scheduleInternetRetry(ack.messageId);
2299
+ request.lastError = result.error;
2300
+ scheduleInternetRetry(ack.messageId, minDelayMs);
2184
2301
  } else {
2185
- // Not retryable or max retries reached - call callback and remove
2186
- if (request.retryCount >= request.maxRetries) {
2302
+ if (isRetryable) {
2187
2303
  Log(ERROR, "handleGatewayAck(): Max retries reached for msgId=%u\n", ack.messageId);
2188
2304
  } else {
2189
- Log(COMMUNICATION, "handleGatewayAck(): Non-retryable failure for msgId=%u (HTTP %u)\n",
2305
+ Log(COMMUNICATION, "handleGatewayAck(): Final failure for msgId=%u (HTTP %u)\n",
2190
2306
  ack.messageId, ack.httpStatus);
2191
2307
  }
2192
-
2193
- if (request.callback) {
2194
- request.callback(ack.success, ack.httpStatus, ack.error);
2195
- }
2308
+ const PendingInternetRequest done = request;
2196
2309
  pendingInternetRequests.erase(it);
2310
+ deliverInternetResult(done, result);
2197
2311
  }
2198
2312
  }
2199
2313
 
2314
+ /**
2315
+ * How an ack from a gateway that does not say whether to retry was read
2316
+ * before 2.1.0, minus retrying a 2xx.
2317
+ */
2318
+ static bool legacyAckIsRetryable(const gateway::GatewayAckPackage& ack) {
2319
+ if (ack.httpStatus >= 500 && ack.httpStatus < 600) return true;
2320
+ if (ack.httpStatus == 429) return true;
2321
+ if (ack.httpStatus != 0) return false;
2322
+ // Gateway-level connectivity failures are infrastructure, not transient.
2323
+ static const char* const FINAL[] = {"Router has no internet",
2324
+ "Gateway WiFi not connected",
2325
+ "Captive portal detected"};
2326
+ for (const char* phrase : FINAL) {
2327
+ if (gateway::responseContains(ack.error, phrase)) return false;
2328
+ }
2329
+ return true;
2330
+ }
2331
+
2200
2332
  /**
2201
2333
  * Run a gateway request against this node's own uplink
2202
2334
  *
@@ -2204,14 +2336,16 @@ class Mesh : public ntp::MeshTime, public plugin::PackageHandler<T> {
2204
2336
  * acknowledgment, pending-request tracking and callback semantics stay
2205
2337
  * identical to a request served by a peer gateway.
2206
2338
  *
2207
- * The handler runs synchronously and its acknowledgment can erase the
2208
- * pending entry, so callers must not hold a reference into
2209
- * pendingInternetRequests across this call -- pass copies.
2339
+ * The handler runs synchronously. sendGatewayAck() completes a local
2340
+ * acknowledgment from the scheduler, but a handler is free to do otherwise,
2341
+ * so callers must not hold a reference into pendingInternetRequests across
2342
+ * this call -- pass copies.
2210
2343
  */
2211
2344
  void dispatchInternetRequestLocally(uint32_t messageId, uint8_t priority,
2212
2345
  uint8_t retryCount,
2213
2346
  const TSTRING& destination,
2214
- const TSTRING& payload) {
2347
+ const TSTRING& payload,
2348
+ uint32_t requestNonce) {
2215
2349
  gateway::GatewayDataPackage pkg;
2216
2350
  pkg.from = this->nodeId;
2217
2351
  pkg.dest = this->nodeId;
@@ -2224,6 +2358,7 @@ class Mesh : public ntp::MeshTime, public plugin::PackageHandler<T> {
2224
2358
  pkg.contentType = "application/json";
2225
2359
  pkg.retryCount = retryCount;
2226
2360
  pkg.requiresAck = true;
2361
+ pkg.requestNonce = requestNonce;
2227
2362
  protocol::Variant variant(&pkg);
2228
2363
  this->callbackList.execute(protocol::GATEWAY_DATA, variant, nullptr, 0);
2229
2364
  }
@@ -2231,7 +2366,7 @@ class Mesh : public ntp::MeshTime, public plugin::PackageHandler<T> {
2231
2366
  /**
2232
2367
  * Schedule retry for a failed Internet request
2233
2368
  */
2234
- void scheduleInternetRetry(uint32_t messageId) {
2369
+ void scheduleInternetRetry(uint32_t messageId, uint32_t minDelayMs = 0) {
2235
2370
  auto it = pendingInternetRequests.find(messageId);
2236
2371
  if (it == pendingInternetRequests.end()) {
2237
2372
  return;
@@ -2244,19 +2379,28 @@ class Mesh : public ntp::MeshTime, public plugin::PackageHandler<T> {
2244
2379
  // were spent on when there is one
2245
2380
  Log(logger::ERROR, "scheduleInternetRetry(): Max retries reached for msgId=%u\n",
2246
2381
  messageId);
2247
- if (request.callback) {
2248
- request.callback(false, 0,
2249
- request.lastError.length() > 0 ? request.lastError
2250
- : TSTRING("Max retries exceeded"));
2251
- }
2382
+ const PendingInternetRequest done = request;
2252
2383
  pendingInternetRequests.erase(it);
2384
+ deliverInternetResult(done, failedInternetResult(
2385
+ done.lastError.length() > 0 ? done.lastError : TSTRING("Max retries exceeded")));
2253
2386
  return;
2254
2387
  }
2255
2388
 
2256
- // Calculate exponential backoff delay
2389
+ // Exponential backoff, never shorter than what the server asked for
2257
2390
  uint32_t delay = request.retryDelayMs * (1 << request.retryCount);
2391
+ if (delay < minDelayMs) delay = minDelayMs;
2258
2392
  request.retryCount++;
2259
2393
 
2394
+ // A wait the server asked for moves the request's deadline with it:
2395
+ // otherwise a Retry-After longer than what is left of the request
2396
+ // timeout (30 s by default) times the request out before the retry the
2397
+ // server invited can run. The retry gets a full timeout of its own.
2398
+ if (minDelayMs > 0) {
2399
+ const uint32_t elapsed = static_cast<uint32_t>(millis()) - request.timestamp;
2400
+ const uint32_t needed = elapsed + delay + internetRequestTimeout;
2401
+ if (needed > request.timeoutMs) request.timeoutMs = needed;
2402
+ }
2403
+
2260
2404
  Log(logger::COMMUNICATION, "scheduleInternetRetry(): Retry %u for msgId=%u in %u ms\n",
2261
2405
  request.retryCount, messageId, delay);
2262
2406
 
@@ -2291,11 +2435,13 @@ class Mesh : public ntp::MeshTime, public plugin::PackageHandler<T> {
2291
2435
  const uint8_t retryCount = request.retryCount;
2292
2436
  const TSTRING destination = request.destination;
2293
2437
  const TSTRING payload = request.payload;
2438
+ const uint32_t nonce = request.requestNonce;
2439
+ request.attempts++;
2294
2440
  Log(logger::COMMUNICATION,
2295
2441
  "retryInternetRequest(): Retrying msgId=%u on the local uplink\n",
2296
2442
  messageId);
2297
2443
  dispatchInternetRequestLocally(messageId, priority, retryCount,
2298
- destination, payload);
2444
+ destination, payload, nonce);
2299
2445
  return;
2300
2446
  }
2301
2447
 
@@ -2333,6 +2479,7 @@ class Mesh : public ntp::MeshTime, public plugin::PackageHandler<T> {
2333
2479
  pkg.contentType = "application/json";
2334
2480
  pkg.retryCount = request.retryCount;
2335
2481
  pkg.requiresAck = true;
2482
+ pkg.requestNonce = request.requestNonce;
2336
2483
 
2337
2484
  // Send the package
2338
2485
  auto conn = painlessmesh::router::findRoute<T>((*this), gateway->nodeId);
@@ -2346,6 +2493,7 @@ class Mesh : public ntp::MeshTime, public plugin::PackageHandler<T> {
2346
2493
  Log(logger::ERROR, "retryInternetRequest(): Retry send failed msgId=%u\n", messageId);
2347
2494
  scheduleInternetRetry(messageId);
2348
2495
  } else {
2496
+ request.attempts++;
2349
2497
  Log(logger::COMMUNICATION, "retryInternetRequest(): Retry sent msgId=%u to gateway %u\n",
2350
2498
  messageId, gateway->nodeId);
2351
2499
  }
@@ -2381,10 +2529,9 @@ class Mesh : public ntp::MeshTime, public plugin::PackageHandler<T> {
2381
2529
  if (request.isTimedOut()) {
2382
2530
  Log(logger::ERROR, "checkInternetRequestTimeout(): Request timed out msgId=%u\n",
2383
2531
  messageId);
2384
- if (request.callback) {
2385
- request.callback(false, 0, "Request timed out");
2386
- }
2532
+ const PendingInternetRequest done = request;
2387
2533
  pendingInternetRequests.erase(it);
2534
+ deliverInternetResult(done, failedInternetResult("Request timed out"));
2388
2535
  }
2389
2536
  }
2390
2537
 
@@ -2397,9 +2544,7 @@ class Mesh : public ntp::MeshTime, public plugin::PackageHandler<T> {
2397
2544
  if (it->second.isTimedOut()) {
2398
2545
  Log(logger::GENERAL, "cleanupTimedOutRequests(): Cleaning up msgId=%u\n",
2399
2546
  it->first);
2400
- if (it->second.callback) {
2401
- it->second.callback(false, 0, "Request timed out");
2402
- }
2547
+ deliverInternetResult(it->second, failedInternetResult("Request timed out"));
2403
2548
  it = pendingInternetRequests.erase(it);
2404
2549
  } else {
2405
2550
  ++it;