Skip to content

Commit c159a68

Browse files
bsuttonspydon
andauthored
feat: Retry (with backoff) when executing commands against repo (#971)
Completed work on adding retry logic retry (with backup) when executing commands against pub.dev or a private repo. Designed to improve robust operation of melos in the face of transient errors such as rate limits. All tests are passing. Code has been tested against a live repo and works as expected when rate limited. I'm also concerned around what is viewed as appropriate levels of logging when a retry occurs - should this be surfaced via the ui (if a rate limit is triggered there are potentially going to be hundreds of triggers). Closes #970 <!-- Thanks for contributing! Provide a description of your changes below and a general summary in the title Please look at the following checklist to ensure that your PR can be accepted quickly: --> ## Description Added retry logic retry (with backup) when executing commands against pub.dev or a private repo. Designed to improve robust operation of melos in the face of transient errors such as rate limits. ## Type of Change <!--- Put an `x` in all the boxes that apply: --> - [x] ✨ `feat` -- New feature (non-breaking change which adds functionality) - [ ] 🛠️ `fix` -- Bug fix (non-breaking change which fixes an issue) - [ ] ❌ `!` -- Breaking change (fix or feature that would cause existing functionality to change) - [ ] 🧹 `refactor` -- Code refactor - [ ] ✅ `ci` -- Build configuration change - [ ] 📝 `docs` -- Documentation - [ ] 🗑️ `chore` -- Chore --------- Co-authored-by: Lukas Klingsbo <[email protected]>
1 parent a9b375f commit c159a68

File tree

16 files changed

+872
-14
lines changed

16 files changed

+872
-14
lines changed

.gitignore

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -28,4 +28,5 @@ flutter_export_environment.sh
2828
.env.development.local
2929
.env.test.local
3030
.env.production.local
31-
build/
31+
build/
32+
logs/analyzer.txt

docs/configuration/overview.mdx

Lines changed: 44 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -141,6 +141,50 @@ If you prefer the previous behaviour where only the packages directly matched
141141
by `workspace` globs are discovered, leave `discoverNestedWorkspaces` at its
142142
default value `false`.
143143

144+
## pub (timeouts and retries)
145+
146+
Configure how Melos talks to pub (or an alternate registry) when fetching
147+
package metadata. Defaults usually don’t need changing.
148+
By default no timeout is applied (useful for large downloads); set a value if
149+
you need to cap request duration.
150+
151+
```yaml
152+
melos:
153+
pub:
154+
timeoutSeconds: 60 # optional per-request timeout; 0/omit = no timeout
155+
retry:
156+
delayFactorMillis: 200 # base delay; doubled each retry
157+
randomizationFactor: 0.25
158+
maxDelaySeconds: 30
159+
maxAttempts: 8 # includes the first attempt
160+
```
161+
162+
### timeoutSeconds
163+
164+
Total seconds to wait for a registry request. Use 0 or omit to disable the
165+
timeout (default).
166+
167+
### delayFactorMillis
168+
169+
The delay before the first retry, which is then doubled on each subsequent
170+
retry until the maxDelaySeconds is exceeded.
171+
172+
Defaults to 200ms.
173+
174+
175+
### maxDelaySeconds
176+
177+
The maximum time to wait between retry attempts.
178+
179+
Defautls to 30 seconds.
180+
181+
182+
### maxAttempts
183+
184+
The maximum number of retry attemps including the first request.
185+
186+
Defaults to 8.
187+
144188
## ignore
145189

146190
A list of paths to local packages that are excluded from the Melos workspace.

melos.yaml.schema.json

Lines changed: 34 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -67,6 +67,40 @@
6767
"description": "Whether to recursively discover packages in nested workspaces. Defaults to false.",
6868
"default": false
6969
},
70+
"pub": {
71+
"type": "object",
72+
"description": "Configuration for HTTP requests to pub or alternate registries.",
73+
"additionalProperties": false,
74+
"properties": {
75+
"timeoutSeconds": {
76+
"type": "number",
77+
"description": "Optional timeout in seconds applied to registry HTTP requests. Use 0 or omit to disable timeouts (default)."
78+
},
79+
"retry": {
80+
"type": "object",
81+
"description": "Retry/backoff settings applied to registry HTTP requests.",
82+
"additionalProperties": false,
83+
"properties": {
84+
"delayFactorMillis": {
85+
"type": "number",
86+
"description": "Base delay in milliseconds before exponential backoff is applied. Defaults to 200ms."
87+
},
88+
"randomizationFactor": {
89+
"type": "number",
90+
"description": "Jitter factor between 0 and 1 applied to each retry delay. Defaults to 0.25 (25%)."
91+
},
92+
"maxDelaySeconds": {
93+
"type": "number",
94+
"description": "Maximum delay in seconds between retry attempts. Defaults to 30 seconds."
95+
},
96+
"maxAttempts": {
97+
"type": "integer",
98+
"description": "Maximum number of attempts (including the first request) before giving up. Defaults to 8."
99+
}
100+
}
101+
}
102+
}
103+
},
70104
"categories": {
71105
"type": "object",
72106
"description": "Categorize packages in the workspace.",

packages/melos/lib/melos.dart

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -17,6 +17,7 @@ export 'src/common/changelog.dart'
1717
MarkdownStringBufferExtension;
1818
export 'src/common/exception.dart' show CancelledException, MelosException;
1919
export 'src/common/io.dart' show IOException;
20+
export 'src/common/pub_config.dart' show PubClientConfig;
2021
export 'src/common/validation.dart' show MelosConfigException;
2122
export 'src/common/versioning.dart' show ManualVersionChange, SemverReleaseType;
2223
export 'src/global_options.dart' show GlobalOptions;

packages/melos/lib/src/commands/publish.dart

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -136,7 +136,11 @@ mixin _PublishMixin on _ExecMixin {
136136
return;
137137
}
138138

139-
final pubPackage = await package.getPublishedPackage();
139+
final pubPackage = await package.getPublishedPackage(
140+
logger: logger,
141+
backoff: workspace.config.pub.retryBackoff,
142+
timeout: workspace.config.pub.requestTimeout,
143+
);
140144
final versions = pubPackage?.prioritizedVersions.reversed
141145
.map((v) => v.version.toString())
142146
.toList();

packages/melos/lib/src/commands/version.dart

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -715,6 +715,7 @@ mixin _VersionMixin on _RunMixin {
715715

716716
final packages = await workspace.allPackages.applyFilters(
717717
config.packageFilters,
718+
pubConfig: workspace.config.pub,
718719
);
719720
// ignore: parameter_assignments
720721
pendingPackageUpdates = pendingPackageUpdates
Lines changed: 213 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,220 @@
1+
import 'dart:async';
2+
import 'dart:convert';
3+
import 'dart:io';
4+
15
import 'package:http/http.dart' as http;
26
import 'package:meta/meta.dart';
37

8+
import '../logging.dart';
9+
import 'retry_backoff.dart';
10+
import 'retry_notice.dart';
11+
412
@visibleForTesting
513
http.Client internalHttpClient = http.Client();
614

715
http.Client get httpClient => internalHttpClient;
16+
17+
/// Callback invoked before a retry attempt is scheduled.
18+
typedef RetryNoticeCallback = FutureOr<void> Function(RetryNotice notice);
19+
20+
/// Issue a GET request with retry/backoff applied for transient errors.
21+
Future<http.Response> getWithRetry(
22+
Uri url, {
23+
Map<String, String>? headers,
24+
http.Client? client,
25+
Duration? timeout,
26+
RetryNoticeCallback? onRetry,
27+
RetryBackoff backoff = const RetryBackoff(),
28+
MelosLogger? logger,
29+
}) {
30+
return _withRetry(
31+
method: 'GET',
32+
url: url,
33+
timeout: timeout,
34+
onRetry: onRetry,
35+
backoff: backoff,
36+
logger: logger,
37+
request: () => (client ?? httpClient).get(url, headers: headers),
38+
);
39+
}
40+
41+
/// Issue a POST request with retry/backoff applied for transient errors.
42+
Future<http.Response> postWithRetry(
43+
Uri url, {
44+
Map<String, String>? headers,
45+
Object? body,
46+
Encoding? encoding,
47+
http.Client? client,
48+
Duration? timeout,
49+
RetryNoticeCallback? onRetry,
50+
RetryBackoff backoff = const RetryBackoff(),
51+
MelosLogger? logger,
52+
}) {
53+
return _withRetry(
54+
method: 'POST',
55+
url: url,
56+
timeout: timeout,
57+
onRetry: onRetry,
58+
backoff: backoff,
59+
logger: logger,
60+
request: () => (client ?? httpClient).post(
61+
url,
62+
headers: headers,
63+
body: body,
64+
encoding: encoding,
65+
),
66+
);
67+
}
68+
69+
Future<http.Response> _withRetry({
70+
required String method,
71+
required Uri url,
72+
required Future<http.Response> Function() request,
73+
Duration? timeout,
74+
RetryNoticeCallback? onRetry,
75+
RetryBackoff backoff = const RetryBackoff(),
76+
MelosLogger? logger,
77+
}) async {
78+
var attempt = 1;
79+
80+
while (true) {
81+
try {
82+
final pendingResponse = request();
83+
final response = timeout == null
84+
? await pendingResponse
85+
: await pendingResponse.timeout(
86+
timeout,
87+
onTimeout: () => throw TimeoutException(
88+
'$method $url timed out after ${timeout.inSeconds}s',
89+
),
90+
);
91+
92+
if (!_shouldRetryResponse(response) || attempt >= backoff.maxAttempts) {
93+
return response;
94+
}
95+
96+
final notice = _noticeFromResponse(
97+
method: method,
98+
url: url,
99+
attempt: attempt,
100+
backoff: backoff,
101+
response: response,
102+
);
103+
await _handleRetryNotice(notice, onRetry, logger);
104+
await Future.delayed(notice.delay, () {});
105+
} catch (error, stackTrace) {
106+
if (!_shouldRetryError(error) || attempt >= backoff.maxAttempts) {
107+
Error.throwWithStackTrace(error, stackTrace);
108+
}
109+
110+
final notice = _noticeFromError(
111+
method: method,
112+
url: url,
113+
attempt: attempt,
114+
backoff: backoff,
115+
error: error,
116+
);
117+
await _handleRetryNotice(notice, onRetry, logger);
118+
await Future.delayed(notice.delay, () {});
119+
}
120+
121+
attempt += 1;
122+
}
123+
}
124+
125+
Future<void> _handleRetryNotice(
126+
RetryNotice notice,
127+
RetryNoticeCallback? onRetry,
128+
MelosLogger? logger,
129+
) async {
130+
await Future.sync(() => onRetry?.call(notice));
131+
132+
if (logger != null && logger.isVerbose) {
133+
final delayDescription = _formatDelay(notice.delay);
134+
logger.trace(
135+
'[HTTP] Retrying ${notice.method} ${notice.url} '
136+
'(${notice.attempt}/${notice.maxAttempts}) in $delayDescription '
137+
'because ${notice.reason}.',
138+
);
139+
}
140+
}
141+
142+
RetryNotice _noticeFromResponse({
143+
required String method,
144+
required Uri url,
145+
required int attempt,
146+
required RetryBackoff backoff,
147+
required http.Response response,
148+
}) {
149+
return RetryNotice(
150+
url: url,
151+
method: method,
152+
attempt: attempt + 1,
153+
maxAttempts: backoff.maxAttempts,
154+
delay: backoff.delay(attempt),
155+
reason: _reasonForStatusCode(response.statusCode),
156+
statusCode: response.statusCode,
157+
);
158+
}
159+
160+
RetryNotice _noticeFromError({
161+
required String method,
162+
required Uri url,
163+
required int attempt,
164+
required RetryBackoff backoff,
165+
required Object error,
166+
}) {
167+
return RetryNotice(
168+
url: url,
169+
method: method,
170+
attempt: attempt + 1,
171+
maxAttempts: backoff.maxAttempts,
172+
delay: backoff.delay(attempt),
173+
reason: _reasonForError(error),
174+
error: error,
175+
);
176+
}
177+
178+
bool _shouldRetryResponse(http.Response response) {
179+
final status = response.statusCode;
180+
if (status == 408 || status == 429) {
181+
return true;
182+
}
183+
184+
return status >= 500 && status < 600;
185+
}
186+
187+
bool _shouldRetryError(Object error) {
188+
return error is TimeoutException ||
189+
error is SocketException ||
190+
error is HttpException ||
191+
error is TlsException ||
192+
error is http.ClientException ||
193+
error is IOException;
194+
}
195+
196+
String _reasonForStatusCode(int statusCode) {
197+
return switch (statusCode) {
198+
408 => 'request timed out (HTTP 408)',
199+
429 => 'rate limited (HTTP 429)',
200+
_ when statusCode >= 500 && statusCode < 600 =>
201+
'server error (HTTP $statusCode)',
202+
_ => 'HTTP $statusCode',
203+
};
204+
}
205+
206+
String _reasonForError(Object error) {
207+
if (error is TimeoutException) {
208+
return 'request timeout';
209+
}
210+
211+
return error.toString();
212+
}
213+
214+
String _formatDelay(Duration delay) {
215+
if (delay.inSeconds >= 1) {
216+
return '${delay.inSeconds}s';
217+
}
218+
219+
return '${delay.inMilliseconds}ms';
220+
}

0 commit comments

Comments
 (0)