dart-lang / http

A composable API for making HTTP requests in Dart.
https://pub.dev/packages/http
BSD 3-Clause "New" or "Revised" License
1.03k stars 359 forks source link

Cronet can't handle modest number of JSON file downloads in my app #1308

Open corepuncher opened 2 months ago

corepuncher commented 2 months ago

Note: Assuming the below issues can be fixed (that means, cronet = faster than http), is there someone I can send a monetary donation to in advance for their time?

I had high hopes of using cronet instead of http, but so far, only http will work right for Android.

Two sections of the app attempt to use cronet: A) Normal API requests (JSON files) and B) via flutter_map / dio / NativeAdapter (many hundreds of map tiles).

Case "A"

This issue has less moving parts, so hopefully we can figure this one out:

Background: When app starts, it pulls in API data, about 36 files for each of the 8 data types = 288 JSON files. A timer downloads new data (just the latest of each data type) every 1-2 minutes.

void main() async {
...
    await ApiClient.instance.initializeNativeHttpClient();
...

Then I have:

class BaseApiClient {
  late http.Client client;

  Future<void> initializeNativeHttpClient() async {
    if (!kIsWeb && (Platform.isIOS || Platform.isMacOS)) {
      final config = URLSessionConfiguration.defaultSessionConfiguration();
      client = CupertinoClient.fromSessionConfiguration(config);
    } else if (!kIsWeb && Platform.isAndroid) {
      client = CronetClient.defaultCronetEngine();
    } else {
      client = http.Client();
    }
  }

  Future<http.Response> get(Uri url, {Map<String, String>? headers}) {
    return client.get(url, headers: headers);
  }

  Future<http.Response> post(Uri uri, {Map<String, String>? headers, Object? body}) {
    return client.post(uri, headers: headers, body: body);
  }
}

class ApiClient extends BaseApiClient {
  ApiClient._privateConstructor();

  static final ApiClient _instance = ApiClient._privateConstructor();

  static ApiClient get instance => _instance;
}

In the above code, I had many errors on app startup:

Error fetching WWA: ClientException: Cronet exception: m.mb: Exception in CronetUrlRequest: net::ERR_HTTP2_PROTOCOL_ERROR, ErrorCode=11, InternalErrorCode=-337, Retryable=false, uri=https://site.com/DATA/20240924_2336.json

So some downloaded, but many did not.

So then I attempted to spread out the requests among my 8 data types (2 each) like this:

class ApiClient extends BaseApiClient {
  ApiClient._privateConstructor();

  static final ApiClient _instance = ApiClient._privateConstructor();

  static ApiClient get instance => _instance;
}

...Apiclient2...ApiClient3...

class ApiClient4 extends BaseApiClient {
  ApiClient4._privateConstructor();

  static final ApiClient4 _instance = ApiClient4._privateConstructor();

  static ApiClient4 get instance => _instance;
}

That actually helped, but I still have one data type that always has "Error Fetching" errors, if I go more than about 24 files. These files are the largest, at 4.2 mb each, so ~ 100 mb is where it fails. So for instance, it may download 24 of them, with the remaining 12 erroring.

It seems strange to me that cronet could not download 36 files @ 4 mb each. I'm sure I have something configured wrong? For that matter, I would assume it should be able to handle 300 files at once, being that MOST of them are SMALL JSON files (only WWA is large).

Case "B":

As for the flutter_map tiles, THAT "sorta" works. Meaning, I can download 1000 small 16 kb webp tiles using this code:

class DioSingleton {
  static Dio? _dio;
  static CronetEngine? _cronetEngine; // Single CronetEngine instance for Android

  static Dio get dioInstance {
    if (_dio == null) {
      print('Creating Dio instance');
      _dio = Dio();
    }

    // Reassign the httpClientAdapter on each request (new NativeAdapter for each request)
    if (!(GetPlatform.isWeb || GetPlatform.isWindows)) {
      if (GetPlatform.isIOS) {
        _dio!.httpClientAdapter = NativeAdapter(); // New NativeAdapter for iOS
      } else if (GetPlatform.isAndroid) {
        _dio!.httpClientAdapter = NativeAdapter(
          createCronetEngine: _getCronetEngine, // Reuse the CronetEngine
        );
      }
    }

    return _dio!;
  }

  // Initialize and reuse a single CronetEngine for Android
  static CronetEngine _getCronetEngine() {
    _cronetEngine ??= CronetEngine.build();
    return _cronetEngine!;
  }
}

The above code is called for each tile, so many hundreds of times, so I try to re-use cronet engine, and single Dio instance. Then that leaves each tile with it's own _dio.httpClientAdapter.

However, the above ends up sluggish for Android. Again, if I use normal http, everything works FAST.

mpm_worker.conf:

<IfModule mpm_worker_module>
        ServerLimit             24
        StartServers            12
        MinSpareThreads         256
        MaxSpareThreads         256
        ThreadLimit                512
        ThreadsPerChild         512
        MaxRequestWorkers       9216
        MaxConnectionsPerChild  0
</IfModule>

And inside apache2.conf:

<IfModule http2_module>
    Protocols h2 h2c http/1.1
    H2MaxSessionStreams 3000
    H2MinWorkers 512
    H2MaxWorkers 512
    H2Push on
    H2Direct on
    H2PushDiarySize 256M
    MaxKeepAliveRequests 9000
</IfModule>

Another error I previously came across was: Too many Broadcast Receivers > 1000, which is why I am trying this singleton-type approach. Seems I am hitting limits attempting to download 2000 map tiles at once.

I probably just don't understand how to best set up cronet, but surely it should be faster than http?

I can always fall back and just use http for Android devices, but I really feel cronet would be best, if only we could get it to work.

brianquinlan commented 2 months ago

Do you have many concurrent requests in flight? Do your server logs indicate anything? Because that error usually indicates that the server responded with something that Cronet could not validate.

corepuncher commented 2 months ago

Thank you for your reply!

Interesting, I watched my server logs and all requests came through, even for the items that cronet claimed an error for.

There's a lot going on during app startup, I wonder if CPU could be the culprit for this particular issue.

The strange thing is, using http client NEVER fails. Everything processes. Why would that be?

Well good to know it's not my server (I think). During app startup, there are a couple intensive operations going on at the same time, perhaps it causes cronet to freeze or timeout or something.

As far as 'engine' configuration, I am using CronetClient.defaultCronetEngine(); Do you recommend anything different? I assumed things like cache were for re-serving already-downloaded data, which I would not typically need once it's gotten once.

At any rate, this app is very network (and cpu) intense and I tried to configure my server to the max, and it seems that end of it is working.

corepuncher commented 2 months ago

Here's an example of what is downloaded at app startup:

36 X

4 mb 50 kb 60 kb 150 kb 450 kb 20 kb 8 kb 5 kb

So under 300 files, and total of about 180 mb.

I tried making a single cronet engine at app start and then 4 separate CronetClients to divide up the work. That performed much worse than making 4 separate cronet engines, which seems strange to me because I thought you were optimally supposed to have 1.

In the end (so far), a single client = http.Client(); works perfectly every time.

escamoteur commented 2 weeks ago

from my tests it is best just to use one cronet client for your whole app

mraleph commented 2 weeks ago

@brianquinlan do we have something to do here? Maybe run cronet engine as a singleton always, etc?