| // Copyright (c) 2012, the Dart project authors. Please see the AUTHORS file | |
| // for details. All rights reserved. Use of this source code is governed by a | |
| // BSD-style license that can be found in the LICENSE file. | |
| /// Helpers for dealing with HTTP. | |
| library; | |
| import 'dart:async'; | |
| import 'dart:convert'; | |
| import 'dart:io'; | |
| import 'dart:math' as math; | |
| import 'package:collection/collection.dart'; | |
| import 'package:http/http.dart' as http; | |
| import 'package:pool/pool.dart'; | |
| import 'log.dart' as log; | |
| import 'platform_info.dart'; | |
| import 'sdk.dart'; | |
| import 'utils.dart'; | |
| /// Headers and field names that should be censored in the log output. | |
| const _censoredFields = ['refresh_token', 'authorization']; | |
| /// Headers required for pub.dev API requests. | |
| /// | |
| /// The Accept header tells pub.dev which version of the API we're | |
| /// expecting, so it can either serve that version or give us a 406 error if | |
| /// it's not supported. | |
| const pubApiHeaders = {'Accept': 'application/vnd.pub.v2+json'}; | |
| /// An HTTP client that transforms 40* errors and socket exceptions into more | |
| /// user-friendly error messages. | |
| class _PubHttpClient extends http.BaseClient { | |
| final _requestStopwatches = <http.BaseRequest, Stopwatch>{}; | |
| final http.Client _inner; | |
| /// We manually keep track of whether the client was closed, | |
| /// indicating that no more networking should be done. (And thus we don't need | |
| /// to retry failed requests). | |
| bool _wasClosed = false; | |
| _PubHttpClient(this._inner); | |
| @override | |
| Future<http.StreamedResponse> send(http.BaseRequest request) async { | |
| if (_wasClosed) { | |
| throw StateError('Attempting to send request on closed client'); | |
| } | |
| request.headers[HttpHeaders.userAgentHeader] = 'Dart pub ${sdk.version}'; | |
| if (request.url.host == 'localhost') { | |
| // We always prefer using ipv4 over ipv6 for 'localhost'. | |
| // | |
| // This prevents conflicts where the same port is occupied by the same | |
| // port on localhost. | |
| final resolutions = await InternetAddress.lookup('localhost'); | |
| final ipv4Address = resolutions.firstWhereOrNull( | |
| (a) => a.type == InternetAddressType.IPv4, | |
| ); | |
| if (ipv4Address != null) { | |
| request = _OverrideUrlRequest( | |
| request.url.replace(host: ipv4Address.host), | |
| request, | |
| ); | |
| } | |
| } | |
| _requestStopwatches[request] = Stopwatch()..start(); | |
| _logRequest(request); | |
| final http.StreamedResponse streamedResponse; | |
| try { | |
| streamedResponse = await _inner.send(request); | |
| } on http.ClientException { | |
| if (_wasClosed) { | |
| // Avoid retrying in this case. | |
| throw _ClientClosedException(); | |
| } | |
| rethrow; | |
| } | |
| _logResponse(streamedResponse); | |
| return streamedResponse; | |
| } | |
| /// Logs the fact that [request] was sent, and information about it. | |
| void _logRequest(http.BaseRequest request) { | |
| final requestLog = StringBuffer(); | |
| requestLog.writeln('HTTP ${request.method} ${request.url}'); | |
| request.headers.forEach( | |
| (name, value) => requestLog.writeln(_logField(name, value)), | |
| ); | |
| if (request.method == 'POST') { | |
| final contentTypeString = request.headers[HttpHeaders.contentTypeHeader]; | |
| final contentType = ContentType.parse(contentTypeString ?? ''); | |
| if (request is http.MultipartRequest) { | |
| requestLog.writeln(); | |
| requestLog.writeln('Body fields:'); | |
| request.fields.forEach( | |
| (name, value) => requestLog.writeln(_logField(name, value)), | |
| ); | |
| // TODO(nweiz): make MultipartRequest.files readable, and log them? | |
| } else if (request is http.Request) { | |
| if (contentType.value == 'application/x-www-form-urlencoded') { | |
| requestLog.writeln(); | |
| requestLog.writeln('Body fields:'); | |
| request.bodyFields.forEach( | |
| (name, value) => requestLog.writeln(_logField(name, value)), | |
| ); | |
| } else if (contentType.value == 'text/plain' || | |
| contentType.value == 'application/json') { | |
| requestLog.write(request.body); | |
| } | |
| } | |
| } | |
| log.io(requestLog.toString().trim()); | |
| } | |
| /// Logs the fact that [response] was received, and information about it. | |
| void _logResponse(http.StreamedResponse response) { | |
| // TODO(nweiz): Fork the response stream and log the response body. Be | |
| // careful not to log OAuth2 private data, though. | |
| final responseLog = StringBuffer(); | |
| final request = response.request!; | |
| final stopwatch = _requestStopwatches.remove(request)!..stop(); | |
| responseLog.writeln( | |
| 'HTTP response ${response.statusCode} ' | |
| '${response.reasonPhrase} for ${request.method} ${request.url}', | |
| ); | |
| responseLog.writeln('took ${stopwatch.elapsed}'); | |
| response.headers.forEach( | |
| (name, value) => responseLog.writeln(_logField(name, value)), | |
| ); | |
| log.io(responseLog.toString().trim()); | |
| } | |
| /// Returns a log-formatted string for the HTTP field or header with the given | |
| /// [name] and [value]. | |
| String _logField(String name, String value) { | |
| if (_censoredFields.contains(name.toLowerCase())) { | |
| return '$name: <censored>'; | |
| } else { | |
| return '$name: $value'; | |
| } | |
| } | |
| @override | |
| void close() { | |
| _wasClosed = true; | |
| _inner.close(); | |
| } | |
| } | |
| final _defaultGlobalHttpClient = _PubHttpClient(http.Client()); | |
| /// The HTTP client to use for all HTTP requests. | |
| http.Client get globalHttpClient => | |
| Zone.current[_globalHttpClientKey] as http.Client? ?? | |
| _defaultGlobalHttpClient; | |
| /// The key for the [globalHttpClient] in the current [Zone]. | |
| final _globalHttpClientKey = Object(); | |
| /// Runs [callback] in a [Zone] where [globalHttpClient] wraps [client]. | |
| R withHttpClient<R>(R Function() callback, {required http.Client client}) => | |
| runZoned( | |
| callback, | |
| zoneValues: {_globalHttpClientKey: _PubHttpClient(client)}, | |
| ); | |
| extension AttachHeaders on http.Request { | |
| /// Adds headers required for pub.dev API requests. | |
| void attachPubApiHeaders() { | |
| headers.addAll(pubApiHeaders); | |
| } | |
| } | |
| /// Handles a successful JSON-formatted response from pub.dev. | |
| /// | |
| /// These responses are expected to be of the form `{"success": {"message": | |
| /// "some message"}}`. If the format is correct, the message will be printed; | |
| /// otherwise an error will be raised. | |
| void handleJsonSuccess(http.Response response) { | |
| switch (parseJsonResponse(response)) { | |
| case {'success': {'message': final String message}}: | |
| log.message( | |
| 'Message from server: ${log.green(sanitizeForTerminal(message))}', | |
| ); | |
| default: | |
| invalidServerResponse(response); | |
| } | |
| } | |
| /// Handles an unsuccessful JSON-formatted response from pub.dev. | |
| /// | |
| /// These responses are expected to be of the form `{"error": {"message": "some | |
| /// message"}}`. If the format is correct, the message will be raised as an | |
| /// error; otherwise an [invalidServerResponse] error will be raised. | |
| void handleJsonError(http.BaseResponse response) { | |
| if (response is! http.Response) { | |
| // Not likely to be a common code path, but necessary. | |
| // See https://github.com/dart-lang/pub/pull/3590#discussion_r1012978108 | |
| fail(log.red('Invalid server response')); | |
| } | |
| final errorMap = parseJsonResponse(response); | |
| final error = errorMap['error']; | |
| if (error is! Map || | |
| !error.containsKey('message') || | |
| error['message'] is! String) { | |
| invalidServerResponse(response); | |
| } | |
| final formattedMessage = log.red( | |
| sanitizeForTerminal(error['message'] as String), | |
| ); | |
| fail('Message from server: $formattedMessage'); | |
| } | |
| /// Handles an unsuccessful XML-formatted response from google cloud storage. | |
| /// | |
| /// Assumes messages are of the form in | |
| /// https://cloud.google.com/storage/docs/xml-api/reference-status | |
| /// | |
| /// This is a poor person's XML parsing with regexps, but this should be | |
| /// sufficient for the specified messages. | |
| void handleGCSError(http.BaseResponse response) { | |
| if (response is http.Response) { | |
| final responseBody = response.body; | |
| if (responseBody.contains('<?xml')) { | |
| String? getTagText(String tag) { | |
| final result = RegExp('<$tag>(.*)</$tag>').firstMatch(responseBody)?[1]; | |
| if (result == null) return null; | |
| return sanitizeForTerminal(result); | |
| } | |
| final code = getTagText('Code'); | |
| // TODO(sigurdm): we could hard-code nice error messages for known codes. | |
| final message = getTagText('Message'); | |
| // `Details` are not specified in the doc above, but have been observed in | |
| // actual responses. | |
| final details = getTagText('Details'); | |
| if (code != null) { | |
| log.error('Server error code: ${sanitizeForTerminal(code)}'); | |
| } | |
| if (message != null) { | |
| log.error('Server message: ${sanitizeForTerminal(message)}'); | |
| } | |
| if (details != null) { | |
| log.error('Server details: ${sanitizeForTerminal(details)}'); | |
| } | |
| } | |
| } | |
| } | |
| /// Parses a response body, assuming it's JSON-formatted. | |
| /// | |
| /// Throws a user-friendly error if the response body is invalid JSON, or if | |
| /// it's not a map. | |
| Map parseJsonResponse(http.Response response) { | |
| Object? value; | |
| try { | |
| value = jsonDecode(response.body) as Object?; | |
| } on FormatException { | |
| invalidServerResponse(response); | |
| } | |
| if (value is! Map) invalidServerResponse(response); | |
| return value; | |
| } | |
| /// Throws an error describing an invalid response from the server. | |
| Never invalidServerResponse(http.Response response) => | |
| fail(log.red('Invalid server response:\n${response.body}')); | |
| /// Exception thrown when an HTTP operation fails. | |
| class PubHttpException implements Exception { | |
| final String message; | |
| final bool isIntermittent; | |
| PubHttpException(this.message, {this.isIntermittent = false}); | |
| @override | |
| String toString() { | |
| return 'PubHttpException: $message'; | |
| } | |
| } | |
| /// Exception thrown when an HTTP response is not Ok. | |
| class PubHttpResponseException extends PubHttpException { | |
| final http.BaseResponse response; | |
| PubHttpResponseException( | |
| this.response, { | |
| String message = '', | |
| bool isIntermittent = false, | |
| }) : super(message, isIntermittent: isIntermittent); | |
| @override | |
| String toString() { | |
| var temp = | |
| 'PubHttpResponseException: HTTP error ${response.statusCode} ' | |
| '${response.reasonPhrase}'; | |
| if (message != '') { | |
| temp += ': $message'; | |
| } | |
| return temp; | |
| } | |
| } | |
| /// Whether [e] is one of a few HTTP-related exceptions that subclass | |
| /// [IOException]. Can be used if your try-catch block contains various | |
| /// operations in addition to HTTP calls and so a [IOException] instance check | |
| /// would be too coarse. | |
| bool isHttpIOException(Object e) { | |
| return e is HttpException || | |
| e is TlsException || | |
| e is SocketException || | |
| e is WebSocketException; | |
| } | |
| /// Program-wide limiter for concurrent network requests. | |
| final _httpPool = Pool(16); | |
| /// Runs the provided function [fn] and returns the response. | |
| /// | |
| /// If there is an HTTP-related exception, an intermittent HTTP error response, | |
| /// or an async timeout, [fn] is run repeatedly until there is a successful | |
| /// response or at most seven total attempts have been made. If all attempts | |
| /// fail, the final exception is re-thrown. | |
| /// | |
| /// Each attempt is run within a [Pool] configured with 16 maximum resources. | |
| Future<T> retryForHttp<T>(String operation, FutureOr<T> Function() fn) async { | |
| return await retry( | |
| () async => await _httpPool.withResource(() async => await fn()), | |
| retryIf: | |
| (e) async => | |
| (e is PubHttpException && e.isIntermittent) || | |
| e is TimeoutException || | |
| e is http.ClientException || | |
| isHttpIOException(e), | |
| onRetry: | |
| (exception, attemptNumber) async => | |
| log.io('Attempt #$attemptNumber for $operation'), | |
| maxAttempts: math.max( | |
| 1, // Having less than 1 attempt doesn't make sense. | |
| int.tryParse(platform.environment['PUB_MAX_HTTP_RETRIES'] ?? '') ?? 7, | |
| ), | |
| ); | |
| } | |
| extension Throwing on http.BaseResponse { | |
| /// See https://api.flutter.dev/flutter/dart-io/HttpClientRequest/followRedirects.html | |
| static const _redirectStatusCodes = [ | |
| HttpStatus.movedPermanently, | |
| HttpStatus.movedTemporarily, | |
| HttpStatus.seeOther, | |
| HttpStatus.temporaryRedirect, | |
| HttpStatus.permanentRedirect, | |
| ]; | |
| /// Throws [PubHttpResponseException], calls [fail], or does nothing depending | |
| /// on the status code. | |
| /// | |
| /// If the code is in the 200 range or if its a 300 range redirect code, | |
| /// nothing is done. If the code is 408, 429, or in the 500 range, | |
| /// [PubHttpResponseException] is thrown with "isIntermittent" set to `true`. | |
| /// Otherwise, [PubHttpResponseException] is thrown with "isIntermittent" set | |
| /// to `false`. | |
| void throwIfNotOk() { | |
| if (statusCode >= 200 && statusCode <= 299) { | |
| return; | |
| } else if (statusCode == HttpStatus.notModified || | |
| _redirectStatusCodes.contains(statusCode)) { | |
| return; | |
| } else if (statusCode == HttpStatus.notAcceptable && | |
| request?.headers['Accept'] == pubApiHeaders['Accept']) { | |
| fail( | |
| 'Pub ${sdk.version} is incompatible with the current version of ' | |
| '${request?.url.host}.\n' | |
| 'Upgrade pub to the latest version and try again.', | |
| ); | |
| } else if (statusCode >= 500 || | |
| statusCode == HttpStatus.requestTimeout || | |
| statusCode == HttpStatus.tooManyRequests) { | |
| // Throw if the response indicates a server error or an intermittent | |
| // client error, but mark it as intermittent so it can be retried. | |
| throw PubHttpResponseException(this, isIntermittent: true); | |
| } else { | |
| // Throw for all other status codes. | |
| throw PubHttpResponseException(this); | |
| } | |
| } | |
| } | |
| extension RequestSending on http.Client { | |
| /// Sends an HTTP request, reads the whole response body, validates the | |
| /// response headers, and if validation is successful, and returns it. | |
| /// | |
| /// The send method on [http.Client], which returns a [http.StreamedResponse], | |
| /// is the only method that accepts a request object. This method can be used | |
| /// when you need to send a request object but want a regular response object. | |
| /// | |
| /// If false is passed for [throwIfNotOk], the response will not be validated. | |
| /// See [http.BaseResponse] extension for validation details. | |
| Future<http.Response> fetch( | |
| http.BaseRequest request, { | |
| bool throwIfNotOk = true, | |
| }) async { | |
| final streamedResponse = await send(request); | |
| final response = await http.Response.fromStream(streamedResponse); | |
| if (throwIfNotOk) { | |
| response.throwIfNotOk(); | |
| } | |
| return response; | |
| } | |
| /// Sends an HTTP request, validates the response headers, and if validation | |
| /// is successful, returns a [http.StreamedResponse]. | |
| /// | |
| /// If false is passed for [throwIfNotOk], the response will not be validated. | |
| /// See [Throwing.throwIfNotOk] extension for validation details. | |
| Future<http.StreamedResponse> fetchAsStream( | |
| http.BaseRequest request, { | |
| bool throwIfNotOk = true, | |
| }) async { | |
| final streamedResponse = await send(request); | |
| if (throwIfNotOk) { | |
| streamedResponse.throwIfNotOk(); | |
| } | |
| return streamedResponse; | |
| } | |
| } | |
| /// Thrown by [_PubHttpClient.send] if the client was closed while the request | |
| /// was being processed. Notably it doesn't implement [http.ClientException], | |
| /// and thus does not trigger a retry by [retryForHttp]. | |
| class _ClientClosedException implements Exception { | |
| @override | |
| String toString() => 'Request was made after http client was closed'; | |
| } | |
| class _OverrideUrlRequest implements http.BaseRequest { | |
| @override | |
| final Uri url; | |
| final http.BaseRequest wrapped; | |
| _OverrideUrlRequest(this.url, this.wrapped); | |
| @override | |
| int? get contentLength => wrapped.contentLength; | |
| @override | |
| Map<String, String> get headers => wrapped.headers; | |
| @override | |
| bool get persistentConnection => wrapped.persistentConnection; | |
| @override | |
| bool get followRedirects => wrapped.followRedirects; | |
| @override | |
| set followRedirects(bool value) => wrapped.followRedirects = value; | |
| @override | |
| int get maxRedirects => wrapped.maxRedirects; | |
| @override | |
| set maxRedirects(int value) => wrapped.maxRedirects = value; | |
| @override | |
| String get method => wrapped.method; | |
| @override | |
| set contentLength(int? value) => wrapped.contentLength = value; | |
| @override | |
| http.ByteStream finalize() => wrapped.finalize(); | |
| @override | |
| bool get finalized => wrapped.finalized; | |
| @override | |
| Future<http.StreamedResponse> send() { | |
| throw UnimplementedError(); | |
| } | |
| @override | |
| set persistentConnection(bool value) => wrapped.persistentConnection = value; | |
| } |