Repository: cfug/dio

Issue:
Dio Throws Exception on DELETE Request with 204 Response 
### Package

dio

### Version

5.5.0

### Operating-System

Web

### Adapter

Default Dio

### Output of `flutter doctor -v`

```shell
[√] Flutter (Channel stable, 3.19.6, on Microsoft Windows [Version 10.0.22631.3880], locale en-US)
    • Flutter version 3.19.6 on channel stable at C:\src\flutter
    • Upstream repository https://github.com/flutter/flutter.git
    • Framework revision 54e66469a9 (4 months ago), 2024-04-17 13:08:03 -0700
    • Engine revision c4cd48e186
    • Dart version 3.3.4
    • DevTools version 2.31.1

[√] Windows Version (Installed version of Windows is version 10 or higher)

[√] Android toolchain - develop for Android devices (Android SDK version 32.1.0-rc1)
    • Android SDK at C:\Users\andrew\AppData\Local\Android\sdk
    • Platform android-34, build-tools 32.1.0-rc1
    • Java binary at: C:\Program Files\Android\Android Studio\jbr\bin\java
    • Java version OpenJDK Runtime Environment (build 17.0.6+0-b2043.56-10027231)
    • All Android licenses accepted.

[√] Chrome - develop for the web
    • Chrome at C:\Program Files\Google\Chrome\Application\chrome.exe

[X] Visual Studio - develop Windows apps
    X Visual Studio not installed; this is necessary to develop Windows apps.
      Download at https://visualstudio.microsoft.com/downloads/.
      Please install the "Desktop development with C++" workload, including all of its default components

[√] Android Studio (version 2022.3)
    • Android Studio at C:\Program Files\Android\Android Studio
    • Flutter plugin can be installed from:
       https://plugins.jetbrains.com/plugin/9212-flutter
    • Dart plugin can be installed from:
       https://plugins.jetbrains.com/plugin/6351-dart
    • Java version OpenJDK Runtime Environment (build 17.0.6+0-b2043.56-10027231)

[√] VS Code (version 1.92.0)
    • VS Code at C:\Users\andrew\AppData\Local\Programs\Microsoft VS Code
    • Flutter extension version 3.94.0

[√] Connected device (3 available)
    • Windows (desktop) • windows • windows-x64    • Microsoft Windows [Version 10.0.22631.3880]
    • Chrome (web)      • chrome  • web-javascript • Google Chrome 126.0.6478.127
    • Edge (web)        • edge    • web-javascript • Microsoft Edge 127.0.2651.86

[√] Network resources
    • All expected network resources are available.
```


### Dart Version

3.3.4

### Steps to Reproduce

Create a flutter application with dio as a dependency.

Send a DELETE request to an endpoint which returns a 204 "No Content" http status code.

### Expected Result

The request goes through without throwing an exception.

### Actual Result

The request throws a DioException with the message "SyntaxError: Unexpected end of JSON input".


Discussion: Please let me know if there's any more info I can add. Judging by the error message it seems that Dio is attempting to parse the body of the response, but since the response body is null, it throws this error.

The issue does not occur in 5.4.3, so I have downgraded the version for now.

Only tested with DELETE requests, I am not sure if other HTTP requests are affected.

Tested only in flutter web, I am not sure if other platforms are affected.

![image](https://github.com/user-attachments/assets/a1ab896c-20d2-4c9e-97f7-6f0306d6a30f)

Please add sample code that shows how you call the delete endpoint. Make sure to use the correct generic response type <void>.
Here's the basic implementation of my HttpProvider class. For brevity, some methods like `sendGet` and `sendPost` are removed. I also cut out irrelevant details (such as a try-catch around the `_dio.delete()` method to throw my own error), but I checked and the "_FormatException: SyntaxError: Unexpected end of JSON input_" error still occurs with the code below.

```
class HttpProvider {
  HttpProvider({this.token}) {
    _dio = Dio(BaseOptions(connectTimeout: const Duration(seconds: 6000)));
    _dio.options.baseUrl = 'https://redacted'
  }
  String? token;

  Future<Options> _getOptions() async {
    Map<String, dynamic> headers = {
      'Strict-Transport-Security': 'max-age=63072000; includeSubDomains; preload',
    };
    if (token != null) {
      headers['Authorization'] = "TOKEN ${token!}";
    }
    return Options(headers: headers);
  }

  Future<Response<dynamic>> sendDelete(String url, [data, String? token]) {
    return _dio.delete(
      url,
      data: data,
      options: await _getOptions(token),
    );
  }
```

Request body:
```
{"ids":[1536]}
```

Response headers:
```
HTTP/1.1 204 No Content
Server: nginx
Date: Mon, 12 Aug 2024 12:31:47 GMT
Content-Type: application/json
Content-Length: 352
Connection: keep-alive
Vary: Accept, Authorization, Cookie, Origin
Allow: PATCH, DELETE, OPTIONS
Cache-Control: no-cache, no-store, must-revalidate
X-Frame-Options: DENY
X-Content-Type-Options: nosniff
Referrer-Policy: same-origin
Cross-Origin-Opener-Policy: same-origin
Access-Control-Allow-Origin: *
Access-Control-Allow-Methods: DELETE
Access-Control-Allow-Headers: X-Device-UID,Strict-Transport-Security,Authorization,Content-Type
Access-Control-Max-Age: 1728000
```
It seems that this might be an issue with the latest transformer from #2239 

You set `ResponseType.json`, and

The server sets
```
Content-Type: application/json
Content-Length: 352
```

so the transformer expects JSON content. 

This is a violation of the HTTP Spec
https://www.rfc-editor.org/rfc/rfc7230

>  A server MUST NOT send a Content-Length header field in any response
   with a status code of 1xx (Informational) or 204 (No Content). 

But the transformer still probably should be adapted to handle this more gracefully.

For now, you can work around this by setting ResponseType to `.bytes`

Fix patch:
diff --git a/dio/CHANGELOG.md b/dio/CHANGELOG.md
index ee8bf11a1..edfc586db 100644
--- a/dio/CHANGELOG.md
+++ b/dio/CHANGELOG.md
@@ -5,7 +5,9 @@ See the [Migration Guide][] for the complete breaking changes list.**
 
 ## Unreleased
 
-*None.*
+- Graceful handling of responses with nonzero `Content-Length`, `Content-Type` json, but empty body
+  - Empty responses are now transformed to `null`
+
 
 ## 5.6.0
 
diff --git a/dio/lib/src/transformers/fused_transformer.dart b/dio/lib/src/transformers/fused_transformer.dart
index 7ce571a90..1ad559b75 100644
--- a/dio/lib/src/transformers/fused_transformer.dart
+++ b/dio/lib/src/transformers/fused_transformer.dart
@@ -1,3 +1,4 @@
+import 'dart:async';
 import 'dart:convert';
 import 'dart:typed_data';
 
@@ -7,6 +8,7 @@ import '../headers.dart';
 import '../options.dart';
 import '../transformer.dart';
 import 'util/consolidate_bytes.dart';
+import 'util/transform_empty_to_null.dart';
 
 /// A [Transformer] that has a fast path for decoding UTF8-encoded JSON.
 /// If the response is utf8-encoded JSON and no custom decoder is specified in the [RequestOptions], this transformer
@@ -118,11 +120,6 @@ class FusedTransformer extends Transformer {
     // of the response or the length of the eagerly decoded response bytes
     final int contentLength;
 
-    // Successful HEAD requests don't have a response body, even if the content-length header
-    // present.
-    final mightNotHaveResponseBodyDespiteContentLength =
-        options.method == 'HEAD';
-
     // The eagerly decoded response bytes
     // which is set if the content length is not specified and
     // null otherwise (we'll feed the stream directly to the decoder in that case)
@@ -131,8 +128,7 @@ class FusedTransformer extends Transformer {
     // If the content length is not specified, we need to consolidate the stream
     // and count the bytes to determine if we should use an isolate
     // otherwise we use the content length header
-    if (!hasContentLengthHeader ||
-        mightNotHaveResponseBodyDespiteContentLength) {
+    if (!hasContentLengthHeader) {
       responseBytes = await consolidateBytes(responseBody.stream);
       contentLength = responseBytes.length;
     } else {
@@ -153,13 +149,7 @@ class FusedTransformer extends Transformer {
         responseBytes ?? await consolidateBytes(responseBody.stream),
       );
     } else {
-      if (!hasContentLengthHeader || contentLength == 0) {
-        // This path is for backwards compatibility.
-        // If content-type indicates a json response,
-        // but the body is empty, null is returned.
-        // _utf8JsonDecoder.bind(responseBody.stream) would throw if the body is empty.
-        // So we need to check if the body is empty and return null in that case
-        responseBytes ??= await consolidateBytes(responseBody.stream);
+      if (responseBytes != null) {
         if (responseBytes.isEmpty) {
           return null;
         }
@@ -168,7 +158,11 @@ class FusedTransformer extends Transformer {
         assert(responseBytes == null);
         // The content length is specified and we can feed the stream directly to the decoder,
         // without eagerly decoding the response bytes first.
-        final decodedStream = _utf8JsonDecoder.bind(responseBody.stream);
+        // If the response is empty, return null;
+        // This is done by the DefaultNullIfEmptyStreamTransformer
+        final streamWithNullFallback = responseBody.stream
+            .transform(const DefaultNullIfEmptyStreamTransformer());
+        final decodedStream = _utf8JsonDecoder.bind(streamWithNullFallback);
         final decoded = await decodedStream.toList();
         if (decoded.isEmpty) {
           return null;
diff --git a/dio/lib/src/transformers/util/transform_empty_to_null.dart b/dio/lib/src/transformers/util/transform_empty_to_null.dart
new file mode 100644
index 000000000..a61d16625
--- /dev/null
+++ b/dio/lib/src/transformers/util/transform_empty_to_null.dart
@@ -0,0 +1,48 @@
+import 'dart:async';
+import 'dart:typed_data';
+
+/// A [StreamTransformer] that replaces an empty stream of Uint8List with a default value
+/// - the utf8-encoded string "null".
+/// Feeding an empty stream to a JSON decoder will throw an exception, so this transformer
+/// is used to prevent that; the JSON decoder will instead return null.
+class DefaultNullIfEmptyStreamTransformer
+    extends StreamTransformerBase<Uint8List, Uint8List> {
+  const DefaultNullIfEmptyStreamTransformer();
+
+  @override
+  Stream<Uint8List> bind(Stream<Uint8List> stream) {
+    return Stream.eventTransformed(
+      stream,
+      (sink) => _DefaultIfEmptyStreamSink(sink),
+    );
+  }
+}
+
+class _DefaultIfEmptyStreamSink implements EventSink<Uint8List> {
+  _DefaultIfEmptyStreamSink(this._outputSink);
+
+  /// Hard-coded constant for replacement value, "null"
+  static final Uint8List _nullUtf8Value =
+      Uint8List.fromList(const [110, 117, 108, 108]);
+
+  final EventSink<Uint8List> _outputSink;
+  bool _hasData = false;
+
+  @override
+  void add(Uint8List data) {
+    _hasData = _hasData || data.isNotEmpty;
+    _outputSink.add(data);
+  }
+
+  @override
+  void addError(e, [st]) => _outputSink.addError(e, st);
+
+  @override
+  void close() {
+    if (!_hasData) {
+      _outputSink.add(_nullUtf8Value);
+    }
+
+    _outputSink.close();
+  }
+}


Test patch:
diff --git a/dio/test/transformer_test.dart b/dio/test/transformer_test.dart
index 0175531f7..eec01e6e0 100644
--- a/dio/test/transformer_test.dart
+++ b/dio/test/transformer_test.dart
@@ -107,6 +107,30 @@ void main() {
       expect(response, {'foo': 'bar'});
     });
 
+    test('transformResponse transforms json array', () async {
+      final transformer = FusedTransformer();
+      const jsonString = '[{"foo": "bar"}]';
+      final response = await transformer.transformResponse(
+        RequestOptions(responseType: ResponseType.json),
+        ResponseBody.fromString(
+          jsonString,
+          200,
+          headers: {
+            Headers.contentTypeHeader: ['application/json'],
+            Headers.contentLengthHeader: [
+              utf8.encode(jsonString).length.toString(),
+            ],
+          },
+        ),
+      );
+      expect(
+        response,
+        [
+          {'foo': 'bar'},
+        ],
+      );
+    });
+
     test('transforms json in background isolate', () async {
       final transformer = FusedTransformer(contentLengthIsolateThreshold: 0);
       final jsonString = '{"foo": "bar"}';
@@ -299,6 +323,25 @@ void main() {
         expect(response, null);
       },
     );
+
+    test(
+      'can handle status 304 responses with content-length but empty body',
+      () async {
+        final transformer = FusedTransformer();
+        final response = await transformer.transformResponse(
+          RequestOptions(responseType: ResponseType.json),
+          ResponseBody(
+            Stream.value(Uint8List(0)),
+            304,
+            headers: {
+              Headers.contentTypeHeader: ['application/json'],
+              Headers.contentLengthHeader: ['123'],
+            },
+          ),
+        );
+        expect(response, null);
+      },
+    );
   });
 
   group('consolidate bytes', () {