Introduction

AWS の API Gateway の API を .NET から叩いた際、 Unable to write content to request stream; content would exceed Content-Length. に遭遇した。
しかし、この現象に再現したメンバーとそうでないメンバーがいて、原因がわからなかった。

再現コードは、互いに「殆ど」似ていた。
違いは、出力するログの有無。

下記は上記の例外が出力するコード。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
using System;
using System.Net.Http;
using System.Threading.Tasks;

namespace Demo
{

internal sealed class Program
{

private static async Task Main()
{
using var multipartFormDataContent = new MultipartFormDataContent();

using var byteArrayContent = new ByteArrayContent([0, 1, 2, 3]);
using var stringContent = new StringContent("hogehoge");

multipartFormDataContent.Add(byteArrayContent, "image", "imageData");
multipartFormDataContent.Add(stringContent, "name");

Console.WriteLine($"{multipartFormDataContent.Headers.ContentLength}");
Console.WriteLine($"{byteArrayContent.Headers.ContentLength}");
Console.WriteLine($"{stringContent.Headers.ContentLength}");

using var client = new HttpClient();
var uri = new Uri("http://httpbin.org/post");
var msg = await client.PostAsync(uri, multipartFormDataContent );

var responseContent = await msg.Content.ReadAsStringAsync();
Console.WriteLine($"{responseContent}");
}

}

}

下記は実行例。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
$ dotnet run
317
4
7
Unhandled exception. System.Net.Http.HttpRequestException: Unable to write content to request stream; content would exceed Content-Length.
at System.Net.Http.MultipartContent.SerializeToStreamAsyncCore(Stream stream, TransportContext context, CancellationToken cancellationToken)
at System.Net.Http.HttpContent.<CopyToAsync>g__WaitAsync|56_0(ValueTask copyTask)
at System.Net.Http.HttpConnection.SendRequestContentAsync(HttpRequestMessage request, HttpContentWriteStream stream, Boolean async, CancellationToken cancellationToken)
at System.Net.Http.HttpConnection.SendAsync(HttpRequestMessage request, Boolean async, CancellationToken cancellationToken)
at System.Net.Http.HttpConnection.SendAsync(HttpRequestMessage request, Boolean async, CancellationToken cancellationToken)
at System.Net.Http.HttpConnectionPool.SendWithVersionDetectionAndRetryAsync(HttpRequestMessage request, Boolean async, Boolean doRequestAuth, CancellationToken cancellationToken)
at System.Net.Http.RedirectHandler.SendAsync(HttpRequestMessage request, Boolean async, CancellationToken cancellationToken)
at System.Net.Http.HttpClient.<SendAsync>g__Core|83_0(HttpRequestMessage request, HttpCompletionOption completionOption, CancellationTokenSource cts, Boolean disposeCts, CancellationTokenSource pendingRequestsCts, CancellationToken
originalCancellationToken)
at Demo.Program.Main() in F:\Demo\Program.cs:line 27
at Demo.Program.<Main>()

How to resolve?

問題は Console.WriteLine の順序変更、あるいはログ出力をなくせばいい。

1
2
3
Console.WriteLine($"{multipartFormDataContent.Headers.ContentLength}");
Console.WriteLine($"{byteArrayContent.Headers.ContentLength}");
Console.WriteLine($"{stringContent.Headers.ContentLength}");

を下記に変更する。

1
2
3
Console.WriteLine($"{byteArrayContent.Headers.ContentLength}");
Console.WriteLine($"{stringContent.Headers.ContentLength}");
Console.WriteLine($"{multipartFormDataContent.Headers.ContentLength}");
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
$ dotnet run
4
7
355
{
"args": {},
"data": "",
"files": {
"image": "\u0000\u0001\u0002\u0003"
},
"form": {
"name": "message"
},
"headers": {
"Content-Length": "355",
"Content-Type": "multipart/form-data; boundary=\"348b1c65-764d-43bb-8982-50089ea37dab\"",
"Host": "httpbin.org",
"X-Amzn-Trace-Id": "Root=1-65b42a48-2ba2145a3328c8064b6ead16"
},
"json": null,
"origin": "AAA.BBB.CCC.DDD",
"url": "http://httpbin.org/post"
}

ContentLength のプロパティの出力が変わっている?

Why?

結論から言えば、.NET のバグである。
しかも、.NET Framework 4.5 から連綿と続くバグである。

‘Content-Length’ header not always returned when enumerating HttpContentHeaders #16162

コメントにあるように

An obvious solution is to adjust MultipartContent to explicitly set the Content-Length header to null before serializing all the parts to a stream, or when adding a part.

とある。
シリアライズ (この場合、リクエストを Stream に渡すこと) 前に Content-Length ヘッダーを初期化すればいいということだ。
.NET のソースを見ると、 ContentLength にアクセスした瞬間に、マルチパート/フォーム データの HttpContent オブジェクトのコレクションがキャッシュされてしまうように見えるため、キャッシュをリセットすることで対処する。

つまり、下記のように一度 MultipartFormDataContent.Headers.ContentLength を null で初期化すればいい。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
using System;
using System.Net.Http;
using System.Threading.Tasks;

namespace Demo
{

internal sealed class Program
{

private static async Task Main()
{
using var multipartFormDataContent = new MultipartFormDataContent();

using var byteArrayContent = new ByteArrayContent([0, 1, 2, 3]);
using var stringContent = new StringContent("message");

multipartFormDataContent.Add(byteArrayContent, "image", "imageData");
multipartFormDataContent.Add(stringContent, "name");

Console.WriteLine($"{multipartFormDataContent.Headers.ContentLength}");
Console.WriteLine($"{byteArrayContent.Headers.ContentLength}");
Console.WriteLine($"{stringContent.Headers.ContentLength}");

multipartFormDataContent.Headers.ContentLength = null;

using var client = new HttpClient();
var uri = new Uri("http://httpbin.org/post");
var msg = await client.PostAsync(uri, multipartFormDataContent );

var responseContent = await msg.Content.ReadAsStringAsync();
Console.WriteLine($"{responseContent}");
}

}

}
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
$ dotnet run
317
4
7
{
"args": {},
"data": "",
"files": {
"image": "\u0000\u0001\u0002\u0003"
},
"form": {
"name": "message"
},
"headers": {
"Content-Length": "355",
"Content-Type": "multipart/form-data; boundary=\"70527807-0931-46b4-aef7-3f8e4b687379\"",
"Host": "httpbin.org",
"X-Amzn-Trace-Id": "Root=1-65b42f8e-2ded46363676b98747572431"
},
"json": null,
"origin": "AAA.BBB.CCC.DDD",
"url": "http://httpbin.org/post"
}

一応、この問題対する Pull Request は提案されている Content-Length header is present in HttpContentHeaders
#33174
がマージされなかった。