From d3b266b7cf6d5c45beec47595a8b1b31ad73710a Mon Sep 17 00:00:00 2001 From: Ales Pour Date: Thu, 30 Apr 2026 17:43:14 +0200 Subject: [PATCH 01/22] feat: support partial writes --- Client.Test/Config/ClientConfigTest.cs | 8 +- Client.Test/InfluxDBApiExceptionTest.cs | 4 +- Client.Test/InfluxDBClientHttpsTest.cs | 4 +- Client.Test/InfluxDBClientWriteTest.cs | 146 +++++++------ Client.Test/Internal/RestClientTest.cs | 92 +++++++- Client/Config/ClientConfig.cs | 38 +++- Client/Config/WriteOptions.cs | 25 +++ Client/InfluxDBClient.cs | 53 ++--- Client/InfluxDBPartialWriteException.cs | 39 ++++ Client/Internal/RestClient.cs | 265 +++++++++++++++++------- 10 files changed, 501 insertions(+), 173 deletions(-) create mode 100644 Client/InfluxDBPartialWriteException.cs diff --git a/Client.Test/Config/ClientConfigTest.cs b/Client.Test/Config/ClientConfigTest.cs index ed3baedc..1a389d10 100644 --- a/Client.Test/Config/ClientConfigTest.cs +++ b/Client.Test/Config/ClientConfigTest.cs @@ -69,7 +69,7 @@ public void CreateFromConnectionStringWithAuthScheme() [Test] public void CreateFromConnectionStringWithWriteOptions() { - var cfg = new ClientConfig("http://localhost:8086?token=my-token&org=my-org&database=my-database&precision=s&gzipThreshold=64&writeNoSync=true"); + var cfg = new ClientConfig("http://localhost:8086?token=my-token&org=my-org&database=my-database&precision=s&gzipThreshold=64&writeNoSync=true&writeAcceptPartial=false&writeUseV2Api=true"); Assert.That(cfg, Is.Not.Null); cfg.Validate(); Assert.Multiple(() => @@ -82,6 +82,8 @@ public void CreateFromConnectionStringWithWriteOptions() Assert.That(cfg.WriteOptions.Precision, Is.EqualTo(WritePrecision.S)); Assert.That(cfg.WriteOptions.GzipThreshold, Is.EqualTo(64)); Assert.That(cfg.WriteOptions.NoSync, Is.EqualTo(true)); + Assert.That(cfg.WriteOptions.AcceptPartial, Is.EqualTo(false)); + Assert.That(cfg.WriteOptions.UseV2Api, Is.EqualTo(true)); }); } @@ -221,6 +223,8 @@ public void CreateFromEnvWithWriteOptions() {"INFLUX_PRECISION", "s"}, {"INFLUX_GZIP_THRESHOLD", "64"}, {"INFLUX_WRITE_NO_SYNC", "true"}, + {"INFLUX_WRITE_ACCEPT_PARTIAL", "false"}, + {"INFLUX_WRITE_USE_V2_API", "true"}, }; TestUtils.SetEnv(env); var cfg = new ClientConfig(env); @@ -236,6 +240,8 @@ public void CreateFromEnvWithWriteOptions() Assert.That(cfg.WriteOptions.Precision, Is.EqualTo(WritePrecision.S)); Assert.That(cfg.WriteOptions.GzipThreshold, Is.EqualTo(64)); Assert.That(cfg.WriteOptions.NoSync, Is.EqualTo(true)); + Assert.That(cfg.WriteOptions.AcceptPartial, Is.EqualTo(false)); + Assert.That(cfg.WriteOptions.UseV2Api, Is.EqualTo(true)); }); } diff --git a/Client.Test/InfluxDBApiExceptionTest.cs b/Client.Test/InfluxDBApiExceptionTest.cs index 3ee58982..1088618a 100644 --- a/Client.Test/InfluxDBApiExceptionTest.cs +++ b/Client.Test/InfluxDBApiExceptionTest.cs @@ -35,7 +35,7 @@ public async Task GeneratedInfluxDbException() var requestId = Guid.NewGuid().ToString(); MockServer - .Given(Request.Create().WithPath("/api/v2/write").UsingPost()) + .Given(Request.Create().WithPath("/api/v3/write_lp").UsingPost()) .RespondWith(Response.Create() .WithStatusCode(400) .WithBody("{ \"message\": \"just testing\", \"statusCode\": \"bad request\" }") @@ -83,4 +83,4 @@ public async Task GeneratedInfluxDbException() } } } -} \ No newline at end of file +} diff --git a/Client.Test/InfluxDBClientHttpsTest.cs b/Client.Test/InfluxDBClientHttpsTest.cs index 33fe2525..bd1405b9 100644 --- a/Client.Test/InfluxDBClientHttpsTest.cs +++ b/Client.Test/InfluxDBClientHttpsTest.cs @@ -242,7 +242,7 @@ public Task QueryWithOtherSslRootCertificate() private async Task WriteData() { MockHttpsServer - .Given(Request.Create().WithPath("/api/v2/write").UsingPost()) + .Given(Request.Create().WithPath("/api/v3/write_lp").UsingPost()) .RespondWith(Response.Create().WithStatusCode(204)); await _client.WriteRecordAsync("mem,tag=a field=1"); @@ -262,4 +262,4 @@ private async Task QueryData() var query = "SELECT 1"; await _client.Query(query).ToListAsync(); } -} \ No newline at end of file +} diff --git a/Client.Test/InfluxDBClientWriteTest.cs b/Client.Test/InfluxDBClientWriteTest.cs index b643a417..81ce2ea1 100644 --- a/Client.Test/InfluxDBClientWriteTest.cs +++ b/Client.Test/InfluxDBClientWriteTest.cs @@ -39,7 +39,7 @@ public async Task Body() public async Task BodyConcat() { MockServer - .Given(Request.Create().WithPath("/api/v2/write").UsingPost()) + .Given(Request.Create().WithPath("/api/v3/write_lp").UsingPost()) .RespondWith(Response.Create().WithStatusCode(204)); _client = new InfluxDBClient(MockServerUrl, token: "my-token", organization: "my-org", database: "my-database"); @@ -55,7 +55,7 @@ public async Task BodyConcat() public async Task BodyPoint() { MockServer - .Given(Request.Create().WithPath("/api/v2/write").UsingPost()) + .Given(Request.Create().WithPath("/api/v3/write_lp").UsingPost()) .RespondWith(Response.Create().WithStatusCode(204)); _client = new InfluxDBClient(MockServerUrl, token: "my-token", organization: "my-org", database: "my-database"); @@ -70,7 +70,7 @@ public async Task BodyPoint() public async Task BodyNull() { MockServer - .Given(Request.Create().WithPath("/api/v2/write").UsingPost()) + .Given(Request.Create().WithPath("/api/v3/write_lp").UsingPost()) .RespondWith(Response.Create().WithStatusCode(204)); _client = new InfluxDBClient(MockServerUrl, token: "my-token", organization: "my-org", database: "my-database"); @@ -85,7 +85,7 @@ public async Task BodyNull() public async Task BodyNonDefaultGzipped() { MockServer - .Given(Request.Create().WithPath("/api/v2/write").WithHeader("Content-Encoding", "gzip").UsingPost()) + .Given(Request.Create().WithPath("/api/v3/write_lp").WithHeader("Content-Encoding", "gzip").UsingPost()) .RespondWith(Response.Create().WithStatusCode(204)); _client = new InfluxDBClient(new ClientConfig @@ -109,7 +109,7 @@ public async Task BodyNonDefaultGzipped() public async Task BodyDefaultNotGzipped() { MockServer - .Given(Request.Create().WithPath("/api/v2/write").WithHeader("Content-Encoding", ".*", MatchBehaviour.RejectOnMatch).UsingPost()) + .Given(Request.Create().WithPath("/api/v3/write_lp").WithHeader("Content-Encoding", ".*", MatchBehaviour.RejectOnMatch).UsingPost()) .RespondWith(Response.Create().WithStatusCode(204)); _client = new InfluxDBClient(MockServerUrl, token: "my-token", organization: "my-org", database: "my-database"); @@ -137,7 +137,7 @@ public void AlreadyDisposed() public async Task NotSpecifiedOrg() { MockServer - .Given(Request.Create().WithPath("/api/v2/write").UsingPost()) + .Given(Request.Create().WithPath("/api/v3/write_lp").UsingPost()) .RespondWith(Response.Create().WithStatusCode(204)); _client = new InfluxDBClient(MockServerUrl, token: "my-token", organization: null, database: "my-database"); @@ -151,7 +151,7 @@ public async Task NotSpecifiedOrg() public async Task DefaultTags() { MockServer - .Given(Request.Create().WithPath("/api/v2/write").UsingPost()) + .Given(Request.Create().WithPath("/api/v3/write_lp").UsingPost()) .RespondWith(Response.Create().WithStatusCode(204)); _client = new InfluxDBClient(new ClientConfig @@ -185,7 +185,7 @@ await _client.WritePointAsync(PointData public async Task TagOrder() { MockServer - .Given(Request.Create().WithPath("/api/v2/write").UsingPost()) + .Given(Request.Create().WithPath("/api/v3/write_lp").UsingPost()) .RespondWith(Response.Create().WithStatusCode(204)); _client = new InfluxDBClient(new ClientConfig @@ -222,13 +222,13 @@ public async Task DatabaseCustom() { _client = new InfluxDBClient(MockServerUrl, token: "my-token", organization: "my-org", database: "my-database"); MockServer - .Given(Request.Create().WithPath("/api/v2/write").UsingPost()) + .Given(Request.Create().WithPath("/api/v3/write_lp").UsingPost()) .RespondWith(Response.Create().WithStatusCode(204)); await _client.WriteRecordAsync("mem,tag=a field=1", database: "x-database"); var requests = MockServer.LogEntries.ToList(); - Assert.That(requests[0].RequestMessage.Query?["bucket"].First(), Is.EqualTo("x-database")); + Assert.That(requests[0].RequestMessage.Query?["db"].First(), Is.EqualTo("x-database")); } [Test] @@ -251,13 +251,13 @@ public async Task PrecisionDefault() { _client = new InfluxDBClient(MockServerUrl, token: "my-token", organization: "my-org", database: "my-database"); MockServer - .Given(Request.Create().WithPath("/api/v2/write").UsingPost()) + .Given(Request.Create().WithPath("/api/v3/write_lp").UsingPost()) .RespondWith(Response.Create().WithStatusCode(204)); await _client.WriteRecordAsync("mem,tag=a field=1", database: "my-database"); var requests = MockServer.LogEntries.ToList(); - Assert.That(requests[0].RequestMessage.Query?["precision"].First(), Is.EqualTo("ns")); + Assert.That(requests[0].RequestMessage.Query?["precision"].First(), Is.EqualTo("nanosecond")); } [Test] @@ -275,13 +275,13 @@ public async Task PrecisionOptions() } }); MockServer - .Given(Request.Create().WithPath("/api/v2/write").UsingPost()) + .Given(Request.Create().WithPath("/api/v3/write_lp").UsingPost()) .RespondWith(Response.Create().WithStatusCode(204)); await _client.WriteRecordAsync("mem,tag=a field=1"); var requests = MockServer.LogEntries.ToList(); - Assert.That(requests[0].RequestMessage.Query?["precision"].First(), Is.EqualTo("ms")); + Assert.That(requests[0].RequestMessage.Query?["precision"].First(), Is.EqualTo("millisecond")); } [Test] @@ -289,13 +289,13 @@ public async Task PrecisionCustom() { _client = new InfluxDBClient(MockServerUrl, token: "my-token", organization: "my-org", database: "my-database"); MockServer - .Given(Request.Create().WithPath("/api/v2/write").UsingPost()) + .Given(Request.Create().WithPath("/api/v3/write_lp").UsingPost()) .RespondWith(Response.Create().WithStatusCode(204)); await _client.WriteRecordAsync("mem,tag=a field=1", precision: WritePrecision.S); var requests = MockServer.LogEntries.ToList(); - Assert.That(requests[0].RequestMessage.Query?["precision"].First(), Is.EqualTo("s")); + Assert.That(requests[0].RequestMessage.Query?["precision"].First(), Is.EqualTo("second")); } [Test] @@ -303,7 +303,7 @@ public async Task PrecisionBody() { _client = new InfluxDBClient(MockServerUrl, token: "my-token", organization: "my-org", database: "my-database"); MockServer - .Given(Request.Create().WithPath("/api/v2/write").UsingPost()) + .Given(Request.Create().WithPath("/api/v3/write_lp").UsingPost()) .RespondWith(Response.Create().WithStatusCode(204)); var point = PointData.Measurement("h2o") @@ -333,7 +333,7 @@ public async Task Proxy() } }); MockProxy - .Given(Request.Create().WithPath("/api/v2/write").UsingPost()) + .Given(Request.Create().WithPath("/api/v3/write_lp").UsingPost()) .RespondWith(Response.Create().WithStatusCode(204)); var point = PointData.Measurement("h2o") @@ -362,7 +362,7 @@ public async Task CustomHeader() } }); MockServer - .Given(Request.Create().WithPath("/api/v2/write").WithHeader("X-device", "ab-01").UsingPost()) + .Given(Request.Create().WithPath("/api/v3/write_lp").WithHeader("X-device", "ab-01").UsingPost()) .RespondWith(Response.Create().WithStatusCode(204)); var point = PointData.Measurement("h2o") @@ -392,7 +392,7 @@ public async Task CustomHeaderFromRequest() Database = "my-database" }); MockServer - .Given(Request.Create().WithPath("/api/v2/write").WithHeader("X-Tracing-ID", "123").UsingPost()) + .Given(Request.Create().WithPath("/api/v3/write_lp").WithHeader("X-Tracing-ID", "123").UsingPost()) .RespondWith(Response.Create().WithStatusCode(204)); var point = PointData.Measurement("h2o") @@ -425,7 +425,7 @@ public async Task CustomHeaderFromRequestArePreferred() } }); MockServer - .Given(Request.Create().WithPath("/api/v2/write").WithHeader("X-Client-ID", "456").UsingPost()) + .Given(Request.Create().WithPath("/api/v3/write_lp").WithHeader("X-Client-ID", "456").UsingPost()) .RespondWith(Response.Create().WithStatusCode(204)); var point = PointData.Measurement("h2o") @@ -446,37 +446,62 @@ public async Task CustomHeaderFromRequestArePreferred() private async Task WriteData() { MockServer - .Given(Request.Create().WithPath("/api/v2/write").UsingPost()) + .Given(Request.Create().WithPath("/api/v3/write_lp").UsingPost()) .RespondWith(Response.Create().WithStatusCode(204)); await _client.WriteRecordAsync("mem,tag=a field=1"); } - [Test] - public async Task WriteNoSyncFalse() + private static IEnumerable WriteV3OptionCases() + { + yield return new TestCaseData(new WriteOptions { NoSync = false }, false, false) + .SetName("WriteToV3_NoSyncFalse_OmitsNoSyncAndAcceptPartial"); + yield return new TestCaseData(new WriteOptions { AcceptPartial = false }, false, true) + .SetName("WriteToV3_AcceptPartialFalse_AddsAcceptPartialQueryParam"); + yield return new TestCaseData(new WriteOptions { NoSync = true }, true, false) + .SetName("WriteToV3_NoSyncTrue_AddsNoSyncQueryParam"); + } + + [TestCaseSource(nameof(WriteV3OptionCases))] + public async Task WriteToV3OptionCases(WriteOptions writeOptions, bool expectNoSync, bool expectAcceptPartialFalse) { _client = new InfluxDBClient(new ClientConfig { Host = MockServerUrl, Token = "my-token", Database = "my-database", - WriteOptions = new WriteOptions - { - NoSync = false - } + WriteOptions = writeOptions }); MockServer - .Given(Request.Create().WithPath("/api/v2/write").UsingPost()) + .Given(Request.Create().WithPath("/api/v3/write_lp").UsingPost()) .RespondWith(Response.Create().WithStatusCode(204)); await _client.WriteRecordAsync("mem,tag=a field=1"); - var requests = MockServer.LogEntries.ToList(); - Assert.That(requests[0].RequestMessage.Path, Is.EqualTo("/api/v2/write")); + using (Assert.EnterMultipleScope()) + { + Assert.That(requests[0].RequestMessage.Path, Is.EqualTo("/api/v3/write_lp")); + if (expectNoSync) + { + Assert.That(requests[0].RequestMessage.Query?["no_sync"].First(), Is.EqualTo("true")); + } + else + { + Assert.That(requests[0].RequestMessage.Query, Does.Not.ContainKey("no_sync")); + } + if (expectAcceptPartialFalse) + { + Assert.That(requests[0].RequestMessage.Query?["accept_partial"].First(), Is.EqualTo("false")); + } + else + { + Assert.That(requests[0].RequestMessage.Query, Does.Not.ContainKey("accept_partial")); + } + } } [Test] - public async Task WriteNoSyncTrueSupported() + public void WriteNoSyncTrueWithUseV2ApiValidationError() { _client = new InfluxDBClient(new ClientConfig { @@ -485,21 +510,26 @@ public async Task WriteNoSyncTrueSupported() Database = "my-database", WriteOptions = new WriteOptions { - NoSync = true + NoSync = true, + UseV2Api = true } }); - MockServer - .Given(Request.Create().WithPath("/api/v3/write_lp").UsingPost()) - .RespondWith(Response.Create().WithStatusCode(204)); - await _client.WriteRecordAsync("mem,tag=a field=1"); + var ae = Assert.ThrowsAsync(async () => + { + await _client.WriteRecordAsync("mem,tag=a field=1"); + }); - var requests = MockServer.LogEntries.ToList(); - Assert.That(requests[0].RequestMessage.Query?["no_sync"].First(), Is.EqualTo("true")); + Assert.Multiple(() => + { + Assert.That(ae, Is.Not.Null); + Assert.That(ae.Message, Is.EqualTo("invalid write options: NoSync cannot be used in V2 API")); + }); } - [Test] - public void WriteNoSyncTrueNotSupported() + [TestCase(true)] + [TestCase(false)] + public async Task WriteUseV2ApiRoutesToV2AndIgnoresAcceptPartial(bool acceptPartial) { _client = new InfluxDBClient(new ClientConfig { @@ -508,34 +538,32 @@ public void WriteNoSyncTrueNotSupported() Database = "my-database", WriteOptions = new WriteOptions { - NoSync = true + UseV2Api = true, + AcceptPartial = acceptPartial } }); MockServer - .Given(Request.Create().WithPath("/api/v3/write_lp").UsingPost()) - .RespondWith(Response.Create().WithStatusCode(HttpStatusCode.MethodNotAllowed)); - + .Given(Request.Create().WithPath("/api/v2/write").UsingPost()) + .RespondWith(Response.Create().WithStatusCode(204)); - var ae = Assert.ThrowsAsync(async () => - { - await _client.WriteRecordAsync("mem,tag=a field=1"); - }); + await _client.WriteRecordAsync("mem,tag=a field=1"); - Assert.Multiple(() => + var requests = MockServer.LogEntries.ToList(); + using (Assert.EnterMultipleScope()) { - Assert.That(ae, Is.Not.Null); - Assert.That(ae.HttpResponseMessage, Is.Not.Null); - Assert.That(ae.Message, - Does.Contain( - "Server doesn't support write with NoSync=true (supported by InfluxDB 3 Core/Enterprise servers only).")); - }); + Assert.That(requests[0].RequestMessage.Path, Is.EqualTo("/api/v2/write")); + Assert.That(requests[0].RequestMessage.Query?["bucket"].First(), Is.EqualTo("my-database")); + Assert.That(requests[0].RequestMessage.Query?["precision"].First(), Is.EqualTo("ns")); + Assert.That(requests[0].RequestMessage.Query, Does.Not.ContainKey("accept_partial")); + Assert.That(requests[0].RequestMessage.Query, Does.Not.ContainKey("no_sync")); + } } [Test] public void TimeoutExceededByTimeout() { MockServer - .Given(Request.Create().WithPath("/api/v2/write").UsingPost()) + .Given(Request.Create().WithPath("/api/v3/write_lp").UsingPost()) .RespondWith(Response.Create().WithStatusCode(204).WithDelay(TimeSpan.FromSeconds(2))); _client = new InfluxDBClient(new ClientConfig @@ -555,7 +583,7 @@ public void TimeoutExceededByTimeout() public void TimeoutExceededByWriteTimeout() { MockServer - .Given(Request.Create().WithPath("/api/v2/write").UsingPost()) + .Given(Request.Create().WithPath("/api/v3/write_lp").UsingPost()) .RespondWith(Response.Create().WithStatusCode(204).WithDelay(TimeSpan.FromSeconds(2))); _client = new InfluxDBClient(new ClientConfig @@ -578,7 +606,7 @@ public void TimeoutExceededByWriteTimeout() public void TimeoutExceededByToken() { MockServer - .Given(Request.Create().WithPath("/api/v2/write").UsingPost()) + .Given(Request.Create().WithPath("/api/v3/write_lp").UsingPost()) .RespondWith(Response.Create().WithStatusCode(204).WithDelay(TimeSpan.FromSeconds(2))); _client = new InfluxDBClient(new ClientConfig diff --git a/Client.Test/Internal/RestClientTest.cs b/Client.Test/Internal/RestClientTest.cs index e20db9aa..18303449 100644 --- a/Client.Test/Internal/RestClientTest.cs +++ b/Client.Test/Internal/RestClientTest.cs @@ -238,7 +238,7 @@ public void ErrorJsonBodyV3WithDataObject() { Assert.That(ae, Is.Not.Null); Assert.That(ae.HttpResponseMessage, Is.Not.Null); - Assert.That(ae.Message, Is.EqualTo("invalid field value in line protocol for field 'value' on line 0")); + Assert.That(ae.Message, Is.EqualTo("parsing failed")); }); } @@ -287,7 +287,7 @@ public void ErrorJsonBodyV3WithDataArray() .WithBody("{\"error\":\"partial write of line protocol occurred\",\"data\":[{\"error_message\":\"invalid column type for column 'v', expected iox::column_type::field::integer, got iox::column_type::field::float\",\"line_number\":2,\"original_line\":\"testa6a3ad v=1 17702\"}]}") .WithStatusCode(400)); - var ae = Assert.ThrowsAsync(async () => + var ae = Assert.ThrowsAsync(async () => { await _client.Request("api", HttpMethod.Post); }); @@ -295,11 +295,99 @@ public void ErrorJsonBodyV3WithDataArray() Assert.Multiple(() => { Assert.That(ae, Is.Not.Null); + Assert.That(ae.LineErrors, Has.Count.EqualTo(1)); + Assert.That(ae.LineErrors[0].LineNumber, Is.EqualTo(2)); + Assert.That(ae.LineErrors[0].ErrorMessage, Is.EqualTo("invalid column type for column 'v', expected iox::column_type::field::integer, got iox::column_type::field::float")); + Assert.That(ae.LineErrors[0].OriginalLine, Is.EqualTo("testa6a3ad v=1 17702")); Assert.That(ae.HttpResponseMessage, Is.Not.Null); Assert.That(ae.Message, Is.EqualTo("partial write of line protocol occurred:\n\tline 2: invalid column type for column 'v', expected iox::column_type::field::integer, got iox::column_type::field::float (testa6a3ad v=1 17702)")); }); } + [Test] + public void ErrorJsonBodyV3WithDataArrayUntypedFallback() + { + CreateAndConfigureRestClient(new ClientConfig + { + Host = MockServerUrl, + }); + + MockServer + .Given(Request.Create().WithPath("/api").UsingPost()) + .RespondWith(Response.Create() + .WithHeader("Content-Type", "application/json") + .WithBody("{\"error\":\"partial write of line protocol occurred\",\"data\":[\"bad line\",true,3]}") + .WithStatusCode(400)); + + var ae = Assert.ThrowsAsync(async () => + { + await _client.Request("api", HttpMethod.Post); + }); + + Assert.That(ae.Message, Is.EqualTo("partial write of line protocol occurred:\n\tbad line\n\ttrue\n\t3")); + } + + [Test] + public void ErrorJsonBodyV3ParsingFailedWriteLpWithDataObject() + { + CreateAndConfigureRestClient(new ClientConfig + { + Host = MockServerUrl, + }); + + MockServer + .Given(Request.Create().WithPath("/api").UsingPost()) + .RespondWith(Response.Create() + .WithHeader("Content-Type", "application/json") + .WithBody("{\"error\":\"parsing failed for write_lp endpoint\",\"data\":{\"error_message\":\"invalid field value\",\"line_number\":2,\"original_line\":\"m,t=a f=bad\"}}") + .WithStatusCode(400)); + + var ae = Assert.ThrowsAsync(async () => + { + await _client.Request("api", HttpMethod.Post); + }); + + Assert.Multiple(() => + { + Assert.That(ae, Is.Not.Null); + Assert.That(ae.LineErrors, Has.Count.EqualTo(1)); + Assert.That(ae.LineErrors[0].LineNumber, Is.EqualTo(2)); + Assert.That(ae.LineErrors[0].ErrorMessage, Is.EqualTo("invalid field value")); + Assert.That(ae.LineErrors[0].OriginalLine, Is.EqualTo("m,t=a f=bad")); + Assert.That(ae.Message, Is.EqualTo("parsing failed for write_lp endpoint:\n\tline 2: invalid field value (m,t=a f=bad)")); + }); + } + + [Test] + public void ErrorJsonBodyV3PartialWriteWithDataObjectErrorMessageOnly() + { + CreateAndConfigureRestClient(new ClientConfig + { + Host = MockServerUrl, + }); + + MockServer + .Given(Request.Create().WithPath("/api").UsingPost()) + .RespondWith(Response.Create() + .WithHeader("Content-Type", "application/json") + .WithBody("{\"error\":\"partial write of line protocol occurred\",\"data\":{\"error_message\":\"invalid field value\"}}") + .WithStatusCode(400)); + + var ae = Assert.ThrowsAsync(async () => + { + await _client.Request("api", HttpMethod.Post); + }); + + Assert.Multiple(() => + { + Assert.That(ae, Is.Not.Null); + Assert.That(ae.LineErrors, Has.Count.EqualTo(1)); + Assert.That(ae.LineErrors[0].LineNumber, Is.EqualTo(0)); + Assert.That(ae.LineErrors[0].ErrorMessage, Is.EqualTo("invalid field value")); + Assert.That(ae.LineErrors[0].OriginalLine, Is.Null); + Assert.That(ae.Message, Is.EqualTo("partial write of line protocol occurred:\n\tinvalid field value")); + }); + } [Test] public void ErrorReason() diff --git a/Client/Config/ClientConfig.cs b/Client/Config/ClientConfig.cs index 7808de51..ebd0ccbd 100644 --- a/Client/Config/ClientConfig.cs +++ b/Client/Config/ClientConfig.cs @@ -70,6 +70,8 @@ public class ClientConfig internal const string EnvInfluxPrecision = "INFLUX_PRECISION"; internal const string EnvInfluxGzipThreshold = "INFLUX_GZIP_THRESHOLD"; internal const string EnvInfluxWriteNoSync = "INFLUX_WRITE_NO_SYNC"; + internal const string EnvInfluxWriteAcceptPartial = "INFLUX_WRITE_ACCEPT_PARTIAL"; + internal const string EnvInfluxWriteUseV2Api = "INFLUX_WRITE_USE_V2_API"; internal const string EnvInfluxDisableGrpcCompression = "INFLUX_DISABLE_GRPC_COMPRESSION"; private string _host = ""; @@ -98,6 +100,8 @@ public ClientConfig(string connectionString) ParsePrecision(values.Get("precision")); ParseGzipThreshold(values.Get("gzipThreshold")); ParseWriteNoSync(values.Get("writeNoSync")); + ParseWriteAcceptPartial(values.Get("writeAcceptPartial")); + ParseWriteUseV2Api(values.Get("writeUseV2Api")); ParseDisableGrpcCompression(values.Get("disableGrpcCompression")); } @@ -115,6 +119,8 @@ public ClientConfig(IDictionary env) ParsePrecision(env[EnvInfluxPrecision] as string); ParseGzipThreshold(env[EnvInfluxGzipThreshold] as string); ParseWriteNoSync(env[EnvInfluxWriteNoSync] as string); + ParseWriteAcceptPartial(env[EnvInfluxWriteAcceptPartial] as string); + ParseWriteUseV2Api(env[EnvInfluxWriteUseV2Api] as string); ParseDisableGrpcCompression(env[EnvInfluxDisableGrpcCompression] as string); } @@ -243,6 +249,16 @@ internal bool WriteNoSync get => (WriteOptions ?? WriteOptions.DefaultOptions).NoSync; } + internal bool WriteAcceptPartial + { + get => (WriteOptions ?? WriteOptions.DefaultOptions).AcceptPartial; + } + + internal bool WriteUseV2Api + { + get => (WriteOptions ?? WriteOptions.DefaultOptions).UseV2Api; + } + private void ParsePrecision(string? precision) { if (precision != null) @@ -284,6 +300,26 @@ private void ParseWriteNoSync(string? strVal) } } + private void ParseWriteAcceptPartial(string? strVal) + { + if (strVal != null) + { + var acceptPartial = bool.Parse(strVal); + WriteOptions ??= (WriteOptions)WriteOptions.DefaultOptions.Clone(); + WriteOptions.AcceptPartial = acceptPartial; + } + } + + private void ParseWriteUseV2Api(string? strVal) + { + if (strVal != null) + { + var useV2Api = bool.Parse(strVal); + WriteOptions ??= (WriteOptions)WriteOptions.DefaultOptions.Clone(); + WriteOptions.UseV2Api = useV2Api; + } + } + private void ParseDisableGrpcCompression(string? strVal) { if (strVal != null) @@ -292,4 +328,4 @@ private void ParseDisableGrpcCompression(string? strVal) QueryOptions.DisableGrpcCompression = disable; } } -} \ No newline at end of file +} diff --git a/Client/Config/WriteOptions.cs b/Client/Config/WriteOptions.cs index ed706d57..8b123de1 100644 --- a/Client/Config/WriteOptions.cs +++ b/Client/Config/WriteOptions.cs @@ -12,6 +12,8 @@ namespace InfluxDB3.Client.Config; /// - GzipThreshold: The threshold in bytes for gzipping the body. The default value is 1000. /// - TagOrder: Preferred tag order for line protocol serialization. /// - NoSync: Bool value whether to skip waiting for WAL persistence on write. The default value is false. +/// - AcceptPartial: Allow partial writes on v3 write endpoint. The default value is true. +/// - UseV2Api: Route writes to v2 compatibility endpoint (/api/v2/write). The default value is false. /// /// If you want create client with custom options, you can use the following code: /// @@ -88,15 +90,38 @@ public class WriteOptions : ICloneable /// public bool NoSync { get; set; } + /// + /// Controls partial-write behavior on the v3 write endpoint. + /// true (default): allow partial writes (server default behavior) + /// false: reject the full batch when any line fails. + /// + public bool AcceptPartial { get; set; } = true; + + /// + /// Routes writes to the v2 compatibility endpoint (/api/v2/write). + /// Intended for InfluxDB Clustered/v2-compatible backends. + /// + public bool UseV2Api { get; set; } = false; + public object Clone() { return this.MemberwiseClone(); } + internal void Validate() + { + if (UseV2Api && NoSync) + { + throw new InvalidOperationException("invalid write options: NoSync cannot be used in V2 API"); + } + } + internal static readonly WriteOptions DefaultOptions = new() { Precision = WritePrecision.Ns, GzipThreshold = 1000, NoSync = false, + AcceptPartial = true, + UseV2Api = false, }; } diff --git a/Client/InfluxDBClient.cs b/Client/InfluxDBClient.cs index 2f5b5773..42e1f69c 100644 --- a/Client/InfluxDBClient.cs +++ b/Client/InfluxDBClient.cs @@ -352,7 +352,9 @@ public InfluxDBClient(ClientConfig config) /// precision - timestamp precision when writing data (ns (default), us, ms, s) /// /// - /// gzipThreshold - threshold for gzip data when writing (default is 1000) + /// gzipThreshold - threshold for gzip data when writing (default is 1000). + /// writeAcceptPartial - allow partial writes on v3 write endpoint (default is true) + /// writeUseV2Api - route writes to v2 compatibility endpoint (default is false) /// /// /// writeNoSync - bool value whether to skip waiting for WAL persistence on write (default is false) @@ -394,6 +396,8 @@ public InfluxDBClient(string connectionString) : this(new ClientConfig(connectio /// /// INFLUX_GZIP_THRESHOLD - threshold for gzipping data when writing (default is 1000) /// + /// INFLUX_WRITE_ACCEPT_PARTIAL - allow partial writes on v3 write endpoint (default is true) + /// INFLUX_WRITE_USE_V2_API - route writes to v2 compatibility endpoint (default is false) /// /// INFLUX_WRITE_NO_SYNC - bool value whether to skip waiting for WAL persistence on write (default is false) /// @@ -765,32 +769,41 @@ private async Task WriteData(IEnumerable data, string? database = null, var body = sb.ToString(); var content = _gzipHandler.Process(body) ?? new StringContent(body, Encoding.UTF8, "text/plain"); + var writeOptions = _config.WriteOptions ?? WriteOptions.DefaultOptions; + writeOptions.Validate(); + string path; Dictionary queryParams; var databaseNotNull = (database ?? _config.Database) ?? throw new InvalidOperationException(OptionMessage("database")); - if (_config.WriteNoSync) + if (writeOptions.UseV2Api) { - // Setting no_sync=true is supported only in the v3 API. - path = "api/v3/write_lp"; + path = "api/v2/write"; queryParams = new Dictionary() { { "org", _config.Organization }, - { "db", databaseNotNull }, - { "precision", WritePrecisionConverter.ToV3ApiString(precisionNotNull) }, - { "no_sync", "true" } + { "bucket", databaseNotNull }, + { "precision", WritePrecisionConverter.ToV2ApiString(precisionNotNull) }, }; } else { - // By default, use the v2 API. - path = "api/v2/write"; + path = "api/v3/write_lp"; queryParams = new Dictionary() { { "org", _config.Organization }, - { "bucket", databaseNotNull }, - { "precision", WritePrecisionConverter.ToV2ApiString(precisionNotNull) }, + { "db", databaseNotNull }, + { "precision", WritePrecisionConverter.ToV3ApiString(precisionNotNull) }, }; + + if (writeOptions.NoSync) + { + queryParams["no_sync"] = "true"; + } + if (!writeOptions.AcceptPartial) + { + queryParams["accept_partial"] = "false"; + } } var cancelToken = new CancellationTokenSource(_config.Timeout).Token; // Just for compatibility with the old API @@ -803,21 +816,9 @@ var databaseNotNull cancelToken = new CancellationTokenSource(_config.WriteTimeout.Value).Token; } - try - { - await _restClient - .Request(path, HttpMethod.Post, content, queryParams, headers, cancelToken) - .ConfigureAwait(false); - } - catch (InfluxDBApiException e) - { - if (_config.WriteNoSync && e.StatusCode == HttpStatusCode.MethodNotAllowed) - { - // Server does not support the v3 write API, can't use the NoSync option. - throw new InfluxDBApiException("Server doesn't support write with NoSync=true (supported by InfluxDB 3 Core/Enterprise servers only).", e.HttpResponseMessage!); - } - throw; - } + await _restClient + .Request(path, HttpMethod.Post, content, queryParams, headers, cancelToken) + .ConfigureAwait(false); } /// diff --git a/Client/InfluxDBPartialWriteException.cs b/Client/InfluxDBPartialWriteException.cs new file mode 100644 index 00000000..0b1d4c0f --- /dev/null +++ b/Client/InfluxDBPartialWriteException.cs @@ -0,0 +1,39 @@ +using System.Collections.Generic; +using System.Net.Http; + +namespace InfluxDB3.Client; + +/// +/// API exception with structured per-line details for v3 partial-write errors. +/// +public class InfluxDBPartialWriteException : InfluxDBApiException +{ + public sealed class LineError + { + public LineError(int lineNumber, string errorMessage, string? originalLine) + { + LineNumber = lineNumber; + ErrorMessage = errorMessage; + OriginalLine = originalLine; + } + + public int LineNumber { get; } + + public string ErrorMessage { get; } + + public string? OriginalLine { get; } + } + + internal InfluxDBPartialWriteException( + string message, + HttpResponseMessage httpResponseMessage, + IReadOnlyList lineErrors) : base(message, httpResponseMessage) + { + LineErrors = lineErrors; + } + + /// + /// Structured line-level errors returned by the v3 write endpoint. + /// + public IReadOnlyList LineErrors { get; } +} diff --git a/Client/Internal/RestClient.cs b/Client/Internal/RestClient.cs index 32046aa2..b14dae19 100644 --- a/Client/Internal/RestClient.cs +++ b/Client/Internal/RestClient.cs @@ -1,12 +1,11 @@ using System; using System.Collections.Generic; using System.Diagnostics; -using System.IO; using System.Linq; using System.Net.Http; using System.Runtime.Serialization; -using System.Runtime.Serialization.Json; using System.Text; +using System.Text.Json; using System.Threading; using System.Threading.Tasks; using System.Web; @@ -82,10 +81,11 @@ internal async Task Request(string path, HttpMethod method, var result = await _httpClient.SendAsync(request, cancellationToken).ConfigureAwait(false); if (!result.IsSuccessStatusCode) { - string? message = null; var body = await result.Content.ReadAsStringAsync().ConfigureAwait(false); var contentType = result.Content?.Headers?.ContentType?.ToString(); - message = FormatErrorMessage(body, contentType); + var parsed = ParseErrorMessage(body, contentType); + var message = parsed.Message; + var partialLineErrors = parsed.PartialLineErrors; // from header if (string.IsNullOrEmpty(message)) @@ -108,138 +108,243 @@ internal async Task Request(string path, HttpMethod method, message = result.ReasonPhrase; } + if (partialLineErrors?.Count > 0) + { + throw new InfluxDBPartialWriteException(message ?? "Cannot write data to InfluxDB.", result, partialLineErrors); + } + throw new InfluxDBApiException(message ?? "Cannot write data to InfluxDB.", result); } return result; } - private static string? FormatErrorMessage(string body, string? contentType) + private static ParsedErrorMessage ParseErrorMessage(string body, string? contentType) { - if (string.IsNullOrEmpty(body)) + if (string.IsNullOrWhiteSpace(body)) { - return null; + return ParsedErrorMessage.Empty; } + if (!string.IsNullOrEmpty(contentType) && !contentType.StartsWith("application/json", StringComparison.OrdinalIgnoreCase)) { - return null; + return ParsedErrorMessage.Empty; } - string? message = null; try { - using var memoryStream = new MemoryStream(Encoding.UTF8.GetBytes(body)); - if (new DataContractJsonSerializer(typeof(ErrorBody)).ReadObject(memoryStream) is ErrorBody errorBody) + using var document = JsonDocument.Parse(body); + var root = document.RootElement; + + var cloudMessage = GetStringProperty(root, "message"); + var topLevelError = GetStringProperty(root, "error"); + var data = GetProperty(root, "data"); + + var legacyDataMessage = GetLegacyDataErrorMessage(data); + var message = cloudMessage ?? topLevelError ?? legacyDataMessage; + var partial = ParsePartialWrite(topLevelError, data); + if (partial is not null) { - if (!string.IsNullOrEmpty(errorBody.Message)) // Cloud - { - message = errorBody.Message; - } - else if ((errorBody.Data is not null) && !string.IsNullOrEmpty(errorBody.Data.ErrorMessage)) // v3/Core/Enterprise (legacy object form) - { - message = errorBody.Data.ErrorMessage; - } - else if (!string.IsNullOrEmpty(errorBody.Error)) // v3/Core/Enterprise - { - message = errorBody.Error; - } + return partial; } + + return new ParsedErrorMessage(message, null); } - catch (SerializationException se) + catch (JsonException se) { - Debug.WriteLine($"Cannot parse error response as legacy JSON format: {body}. {se}"); + Debug.WriteLine($"Cannot parse error response as JSON format: {body}. {se}"); + return ParsedErrorMessage.Empty; } + } - try + private static ParsedErrorMessage? ParsePartialWrite(string? topLevelError, JsonElement? data) + { + if (string.IsNullOrEmpty(topLevelError) || !IsPartialWriteError(topLevelError)) { - using var memoryStream = new MemoryStream(Encoding.UTF8.GetBytes(body)); - if (new DataContractJsonSerializer(typeof(V3ErrorBody)).ReadObject(memoryStream) is V3ErrorBody v3ErrorBody) - { - var v3Message = BuildV3ErrorMessage(v3ErrorBody); - var hasV3Details = v3ErrorBody.Data?.Any(detail => !string.IsNullOrEmpty(detail.ErrorMessage)) == true; - if (!string.IsNullOrEmpty(v3Message) && (string.IsNullOrEmpty(message) || hasV3Details)) - { - message = v3Message; - } - } + return null; } - catch (SerializationException se) + if (data is null) { - Debug.WriteLine($"Cannot parse error response as v3 JSON format: {body}. {se}"); + return null; } - return message; - } + if (data.Value.ValueKind == JsonValueKind.Array) + { + if (TryParseTypedLineErrors(data.Value, out var lineErrors)) + { + return new ParsedErrorMessage(BuildPartialWriteMessage(topLevelError, lineErrors), lineErrors); + } - private static string? BuildV3ErrorMessage(V3ErrorBody v3ErrorBody) - { - if (string.IsNullOrEmpty(v3ErrorBody.Error)) + var details = data.Value.EnumerateArray() + .Where(item => item.ValueKind != JsonValueKind.Null) + .Select(ToDetailString) + .Where(item => !string.IsNullOrEmpty(item)) + .ToList(); + + if (details.Count > 0) + { + return new ParsedErrorMessage($"{topLevelError}:\n\t{string.Join("\n\t", details)}", null); + } + + return null; + } + + if (data.Value.ValueKind == JsonValueKind.Object) { + if (TryParseTypedLineError(data.Value, out var lineError)) + { + var lineErrors = new List { lineError }; + return new ParsedErrorMessage(BuildPartialWriteMessage(topLevelError, lineErrors), lineErrors); + } + return null; } - var message = new StringBuilder(v3ErrorBody.Error); + return null; + } + + private static string BuildPartialWriteMessage( + string baseMessage, + IEnumerable lineErrors) + { + var message = new StringBuilder(baseMessage); var hasDetails = false; - foreach (var detail in (v3ErrorBody.Data ?? Enumerable.Empty()) - .Where(detail => !string.IsNullOrEmpty(detail.ErrorMessage))) + foreach (var detail in lineErrors) { if (!hasDetails) { message.Append(':'); hasDetails = true; } - var lineNumber = detail.LineNumber?.ToString() ?? "?"; - message.Append($"\n\tline {lineNumber}: {detail.ErrorMessage}"); - if (!string.IsNullOrEmpty(detail.OriginalLine)) + if (detail.LineNumber != 0 && !string.IsNullOrEmpty(detail.OriginalLine)) + { + message.Append($"\n\tline {detail.LineNumber}: {detail.ErrorMessage} ({detail.OriginalLine})"); + } + else if (!string.IsNullOrEmpty(detail.ErrorMessage)) + { + message.Append($"\n\t{detail.ErrorMessage}"); + } + else { - message.Append($" ({detail.OriginalLine})"); + message.Append($"\n\tline {detail.LineNumber}: {detail.ErrorMessage}"); } } + return message.ToString(); } -} -[DataContract] -internal class ErrorBody -{ - [DataMember(Name = "message")] - public string? Message { get; set; } + private static bool IsPartialWriteError(string topLevelError) + { + return topLevelError.IndexOf("partial write of line protocol occurred", StringComparison.OrdinalIgnoreCase) >= 0 || + topLevelError.IndexOf("parsing failed for write_lp endpoint", StringComparison.OrdinalIgnoreCase) >= 0; + } - [DataMember(Name = "error")] - public string? Error { get; set; } + private static bool TryParseTypedLineErrors( + JsonElement data, + out List lineErrors) + { + lineErrors = new List(); + if (data.ValueKind != JsonValueKind.Array) + { + return false; + } - [DataMember(Name = "data")] - public ErrorData? Data { get; set; } + foreach (var item in data.EnumerateArray()) + { + if (!TryParseTypedLineError(item, out var lineError)) + { + lineErrors.Clear(); + return false; + } + + lineErrors.Add(lineError); + } + + return lineErrors.Count > 0; + } - [DataContract] - internal class ErrorData + private static bool TryParseTypedLineError(JsonElement item, out InfluxDBPartialWriteException.LineError lineError) { - [DataMember(Name = "error_message")] - public string? ErrorMessage { get; set; } + lineError = null!; + if (item.ValueKind != JsonValueKind.Object) + { + return false; + } + + var errorMessage = GetProperty(item, "error_message"); + var lineNumber = GetProperty(item, "line_number"); + var originalLine = GetProperty(item, "original_line"); + if (errorMessage?.ValueKind != JsonValueKind.String || string.IsNullOrEmpty(errorMessage.Value.GetString())) + { + return false; + } + + var number = 0; + if (lineNumber is not null) + { + if (lineNumber.Value.ValueKind != JsonValueKind.Number || !lineNumber.Value.TryGetInt32(out number)) + { + return false; + } + } + + string? original = null; + if (originalLine is not null) + { + if (originalLine.Value.ValueKind != JsonValueKind.String) + { + return false; + } + + original = originalLine.Value.GetString(); + } + + lineError = new InfluxDBPartialWriteException.LineError( + number, + errorMessage.Value.GetString()!, + original); + return true; } -} -[DataContract] -internal class V3ErrorBody -{ - [DataMember(Name = "error")] - public string? Error { get; set; } + private static JsonElement? GetProperty(JsonElement element, string propertyName) + { + return element.TryGetProperty(propertyName, out var value) ? value : null; + } + + private static string? GetStringProperty(JsonElement element, string propertyName) + { + return GetProperty(element, propertyName) is { ValueKind: JsonValueKind.String } value ? value.GetString() : null; + } + + private static string? GetLegacyDataErrorMessage(JsonElement? data) + { + if (data is null || data.Value.ValueKind != JsonValueKind.Object) + { + return null; + } - [DataMember(Name = "data")] - public List? Data { get; set; } + var errorMessage = GetProperty(data.Value, "error_message"); + return errorMessage?.ValueKind == JsonValueKind.String ? errorMessage?.GetString() : null; + } + + private static string ToDetailString(JsonElement element) + { + return element.ValueKind == JsonValueKind.String ? (element.GetString() ?? "") : element.GetRawText(); + } - [DataContract] - internal class V3ErrorData + private sealed class ParsedErrorMessage { - [DataMember(Name = "error_message")] - public string? ErrorMessage { get; set; } + internal static readonly ParsedErrorMessage Empty = new(null, null); - [DataMember(Name = "line_number")] - public int? LineNumber { get; set; } + internal ParsedErrorMessage(string? message, List? partialLineErrors) + { + Message = message; + PartialLineErrors = partialLineErrors; + } - [DataMember(Name = "original_line")] - public string? OriginalLine { get; set; } + internal string? Message { get; } + internal List? PartialLineErrors { get; } } } From 5b698d149c9d0ececa57db8b32f9c212456f3032 Mon Sep 17 00:00:00 2001 From: Ales Pour Date: Thu, 30 Apr 2026 17:43:30 +0200 Subject: [PATCH 02/22] feat: support partial writes --- Client.Test.Integration/WriteTest.cs | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/Client.Test.Integration/WriteTest.cs b/Client.Test.Integration/WriteTest.cs index dc59a67d..1a28c449 100644 --- a/Client.Test.Integration/WriteTest.cs +++ b/Client.Test.Integration/WriteTest.cs @@ -25,7 +25,7 @@ public async Task WriteWithError() } catch (Exception ex) { - if (ex is InfluxDBApiException) + if (ex is InfluxDBApiException iaex) { var iaex = (InfluxDBApiException)ex; Assert.Multiple((Action)(() => @@ -45,4 +45,4 @@ public async Task WriteWithError() } } } -} \ No newline at end of file +} From 4e656f49d5f9382173bf2e1389a0b15abbb6afa2 Mon Sep 17 00:00:00 2001 From: Ales Pour Date: Thu, 30 Apr 2026 17:49:58 +0200 Subject: [PATCH 03/22] docs: partial write feature and v3 write endpoint --- README.md | 59 +++++++++++++++++++++++++++++++++++++++++++++++++++++-- 1 file changed, 57 insertions(+), 2 deletions(-) diff --git a/README.md b/README.md index 10534cdf..6918bf5e 100644 --- a/README.md +++ b/README.md @@ -65,7 +65,9 @@ public class IOxExample } ``` -to insert data, you can use code like this: +### Write + +To write data, you can use code like this: ```csharp // @@ -101,7 +103,60 @@ const string record = "temperature,location=north value=60.0"; await client.WriteRecordAsync(record: record); ``` -to query your data, you can use code like this: +#### Partial Writes + +Use `WriteOptions.AcceptPartial` to control whether a v3 write can partially succeed when some lines fail. +Default is `true` (server default). When `false`, the full batch is rejected. + +```csharp +using var client = new InfluxDBClient(new ClientConfig +{ + Host = host, + Token = token, + Database = database, + WriteOptions = new WriteOptions() +}); + +try +{ + await client.WriteRecordAsync(lp); +} +catch (InfluxDBPartialWriteException e) +{ + foreach (var lineErr in e.LineErrors) + { + Console.WriteLine( + $"line {lineErr.LineNumber} failed: {lineErr.ErrorMessage} ({lineErr.OriginalLine})"); + } +} +catch (InfluxDBApiException e) +{ + Console.WriteLine(e.Message); +} +``` + +See [Partial writes](https://docs.influxdata.com/influxdb3/core/write-data/http-api/v3-write-lp/#partial-writes) for more. + +#### Use V2 API Compatibility Mode + +By default, writes use `/api/v3/write_lp`. +For InfluxDB Clustered/v2-compatible backends, set `UseV2Api=true` to route writes to `/api/v2/write`. + +`UseV2Api` can be configured in three ways: + +1. `WriteOptions.UseV2Api` +2. Connection string `writeUseV2Api` +3. Environment variable `INFLUX_WRITE_USE_V2_API` + +`AcceptPartial` can be configured in three ways: + +1. `WriteOptions.AcceptPartial` +2. Connection string `writeAcceptPartial` +3. Environment variable `INFLUX_WRITE_ACCEPT_PARTIAL` + +### Query + +To query your data, you can use code like this: ```csharp // From 4d95c1e4bba7d16a404888954d4cdad10cd947c4 Mon Sep 17 00:00:00 2001 From: Ales Pour Date: Thu, 30 Apr 2026 18:13:07 +0200 Subject: [PATCH 04/22] docs: update CHANGELOG --- CHANGELOG.md | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index 8fc24af2..035a047f 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,11 @@ ## 1.9.0 [unreleased] +### Breaking Changes + +1. [#258](https://github.com/InfluxCommunity/influxdb3-csharp/pull/258): Adds partial writes support and aligns write routing with v3 defaults. + See [Partial writes](https://docs.influxdata.com/influxdb3/core/write-data/http-api/v3-write-lp/#partial-writes) for more. + For InfluxDB Clustered version, set `UseV2Api=true` for writing. + ## 1.8.0 [2026-04-23] ### Features From 420f369e8e00a1a88bd20bfdd36e1b600e3f24e6 Mon Sep 17 00:00:00 2001 From: Ales Pour Date: Mon, 4 May 2026 08:12:00 +0200 Subject: [PATCH 05/22] fix: XML doc list structure for new write options/env vars --- Client/InfluxDBClient.cs | 8 ++++++++ 1 file changed, 8 insertions(+) diff --git a/Client/InfluxDBClient.cs b/Client/InfluxDBClient.cs index 42e1f69c..d0e8c78f 100644 --- a/Client/InfluxDBClient.cs +++ b/Client/InfluxDBClient.cs @@ -353,7 +353,11 @@ public InfluxDBClient(ClientConfig config) /// /// /// gzipThreshold - threshold for gzip data when writing (default is 1000). + /// + /// /// writeAcceptPartial - allow partial writes on v3 write endpoint (default is true) + /// + /// /// writeUseV2Api - route writes to v2 compatibility endpoint (default is false) /// /// @@ -396,8 +400,12 @@ public InfluxDBClient(string connectionString) : this(new ClientConfig(connectio /// /// INFLUX_GZIP_THRESHOLD - threshold for gzipping data when writing (default is 1000) /// + /// /// INFLUX_WRITE_ACCEPT_PARTIAL - allow partial writes on v3 write endpoint (default is true) + /// + /// /// INFLUX_WRITE_USE_V2_API - route writes to v2 compatibility endpoint (default is false) + /// /// /// INFLUX_WRITE_NO_SYNC - bool value whether to skip waiting for WAL persistence on write (default is false) /// From 6ebf84998cb5e7bf5207a628c0133271cd363068 Mon Sep 17 00:00:00 2001 From: Ales Pour Date: Fri, 15 May 2026 15:54:59 +0200 Subject: [PATCH 06/22] fix: use V2 API for writes by default --- CHANGELOG.md | 8 +-- Client.Test/InfluxDBApiExceptionTest.cs | 2 +- Client.Test/InfluxDBClientHttpsTest.cs | 2 +- Client.Test/InfluxDBClientWriteTest.cs | 96 +++++++++++++++++-------- Client/Config/WriteOptions.cs | 19 ++--- Client/InfluxDBClient.cs | 37 ++++++++-- README.md | 10 ++- 7 files changed, 120 insertions(+), 54 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 035a047f..3fc8c7d6 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,10 +1,10 @@ ## 1.9.0 [unreleased] -### Breaking Changes +### Features -1. [#258](https://github.com/InfluxCommunity/influxdb3-csharp/pull/258): Adds partial writes support and aligns write routing with v3 defaults. - See [Partial writes](https://docs.influxdata.com/influxdb3/core/write-data/http-api/v3-write-lp/#partial-writes) for more. - For InfluxDB Clustered version, set `UseV2Api=true` for writing. +1. [#258](https://github.com/InfluxCommunity/influxdb3-csharp/pull/258): Add partial writes support and default writes to the V2 API endpoint. + - `NoSync` requires `UseV2Api=false` and the V3 API endpoint. + - `AcceptPartial` applies only when writes are sent to the V3 API endpoint and is ignored when using the V2 API endpoint. ## 1.8.0 [2026-04-23] diff --git a/Client.Test/InfluxDBApiExceptionTest.cs b/Client.Test/InfluxDBApiExceptionTest.cs index 1088618a..b511fb0d 100644 --- a/Client.Test/InfluxDBApiExceptionTest.cs +++ b/Client.Test/InfluxDBApiExceptionTest.cs @@ -35,7 +35,7 @@ public async Task GeneratedInfluxDbException() var requestId = Guid.NewGuid().ToString(); MockServer - .Given(Request.Create().WithPath("/api/v3/write_lp").UsingPost()) + .Given(Request.Create().WithPath("/api/v2/write").UsingPost()) .RespondWith(Response.Create() .WithStatusCode(400) .WithBody("{ \"message\": \"just testing\", \"statusCode\": \"bad request\" }") diff --git a/Client.Test/InfluxDBClientHttpsTest.cs b/Client.Test/InfluxDBClientHttpsTest.cs index bd1405b9..4e9f61c9 100644 --- a/Client.Test/InfluxDBClientHttpsTest.cs +++ b/Client.Test/InfluxDBClientHttpsTest.cs @@ -242,7 +242,7 @@ public Task QueryWithOtherSslRootCertificate() private async Task WriteData() { MockHttpsServer - .Given(Request.Create().WithPath("/api/v3/write_lp").UsingPost()) + .Given(Request.Create().WithPath("/api/v2/write").UsingPost()) .RespondWith(Response.Create().WithStatusCode(204)); await _client.WriteRecordAsync("mem,tag=a field=1"); diff --git a/Client.Test/InfluxDBClientWriteTest.cs b/Client.Test/InfluxDBClientWriteTest.cs index 81ce2ea1..b7640b18 100644 --- a/Client.Test/InfluxDBClientWriteTest.cs +++ b/Client.Test/InfluxDBClientWriteTest.cs @@ -39,7 +39,7 @@ public async Task Body() public async Task BodyConcat() { MockServer - .Given(Request.Create().WithPath("/api/v3/write_lp").UsingPost()) + .Given(Request.Create().WithPath("/api/v2/write").UsingPost()) .RespondWith(Response.Create().WithStatusCode(204)); _client = new InfluxDBClient(MockServerUrl, token: "my-token", organization: "my-org", database: "my-database"); @@ -55,7 +55,7 @@ public async Task BodyConcat() public async Task BodyPoint() { MockServer - .Given(Request.Create().WithPath("/api/v3/write_lp").UsingPost()) + .Given(Request.Create().WithPath("/api/v2/write").UsingPost()) .RespondWith(Response.Create().WithStatusCode(204)); _client = new InfluxDBClient(MockServerUrl, token: "my-token", organization: "my-org", database: "my-database"); @@ -70,7 +70,7 @@ public async Task BodyPoint() public async Task BodyNull() { MockServer - .Given(Request.Create().WithPath("/api/v3/write_lp").UsingPost()) + .Given(Request.Create().WithPath("/api/v2/write").UsingPost()) .RespondWith(Response.Create().WithStatusCode(204)); _client = new InfluxDBClient(MockServerUrl, token: "my-token", organization: "my-org", database: "my-database"); @@ -85,7 +85,7 @@ public async Task BodyNull() public async Task BodyNonDefaultGzipped() { MockServer - .Given(Request.Create().WithPath("/api/v3/write_lp").WithHeader("Content-Encoding", "gzip").UsingPost()) + .Given(Request.Create().WithPath("/api/v2/write").WithHeader("Content-Encoding", "gzip").UsingPost()) .RespondWith(Response.Create().WithStatusCode(204)); _client = new InfluxDBClient(new ClientConfig @@ -109,7 +109,7 @@ public async Task BodyNonDefaultGzipped() public async Task BodyDefaultNotGzipped() { MockServer - .Given(Request.Create().WithPath("/api/v3/write_lp").WithHeader("Content-Encoding", ".*", MatchBehaviour.RejectOnMatch).UsingPost()) + .Given(Request.Create().WithPath("/api/v2/write").WithHeader("Content-Encoding", ".*", MatchBehaviour.RejectOnMatch).UsingPost()) .RespondWith(Response.Create().WithStatusCode(204)); _client = new InfluxDBClient(MockServerUrl, token: "my-token", organization: "my-org", database: "my-database"); @@ -137,7 +137,7 @@ public void AlreadyDisposed() public async Task NotSpecifiedOrg() { MockServer - .Given(Request.Create().WithPath("/api/v3/write_lp").UsingPost()) + .Given(Request.Create().WithPath("/api/v2/write").UsingPost()) .RespondWith(Response.Create().WithStatusCode(204)); _client = new InfluxDBClient(MockServerUrl, token: "my-token", organization: null, database: "my-database"); @@ -151,7 +151,7 @@ public async Task NotSpecifiedOrg() public async Task DefaultTags() { MockServer - .Given(Request.Create().WithPath("/api/v3/write_lp").UsingPost()) + .Given(Request.Create().WithPath("/api/v2/write").UsingPost()) .RespondWith(Response.Create().WithStatusCode(204)); _client = new InfluxDBClient(new ClientConfig @@ -185,7 +185,7 @@ await _client.WritePointAsync(PointData public async Task TagOrder() { MockServer - .Given(Request.Create().WithPath("/api/v3/write_lp").UsingPost()) + .Given(Request.Create().WithPath("/api/v2/write").UsingPost()) .RespondWith(Response.Create().WithStatusCode(204)); _client = new InfluxDBClient(new ClientConfig @@ -222,13 +222,13 @@ public async Task DatabaseCustom() { _client = new InfluxDBClient(MockServerUrl, token: "my-token", organization: "my-org", database: "my-database"); MockServer - .Given(Request.Create().WithPath("/api/v3/write_lp").UsingPost()) + .Given(Request.Create().WithPath("/api/v2/write").UsingPost()) .RespondWith(Response.Create().WithStatusCode(204)); await _client.WriteRecordAsync("mem,tag=a field=1", database: "x-database"); var requests = MockServer.LogEntries.ToList(); - Assert.That(requests[0].RequestMessage.Query?["db"].First(), Is.EqualTo("x-database")); + Assert.That(requests[0].RequestMessage.Query?["bucket"].First(), Is.EqualTo("x-database")); } [Test] @@ -251,13 +251,13 @@ public async Task PrecisionDefault() { _client = new InfluxDBClient(MockServerUrl, token: "my-token", organization: "my-org", database: "my-database"); MockServer - .Given(Request.Create().WithPath("/api/v3/write_lp").UsingPost()) + .Given(Request.Create().WithPath("/api/v2/write").UsingPost()) .RespondWith(Response.Create().WithStatusCode(204)); await _client.WriteRecordAsync("mem,tag=a field=1", database: "my-database"); var requests = MockServer.LogEntries.ToList(); - Assert.That(requests[0].RequestMessage.Query?["precision"].First(), Is.EqualTo("nanosecond")); + Assert.That(requests[0].RequestMessage.Query?["precision"].First(), Is.EqualTo("ns")); } [Test] @@ -275,13 +275,13 @@ public async Task PrecisionOptions() } }); MockServer - .Given(Request.Create().WithPath("/api/v3/write_lp").UsingPost()) + .Given(Request.Create().WithPath("/api/v2/write").UsingPost()) .RespondWith(Response.Create().WithStatusCode(204)); await _client.WriteRecordAsync("mem,tag=a field=1"); var requests = MockServer.LogEntries.ToList(); - Assert.That(requests[0].RequestMessage.Query?["precision"].First(), Is.EqualTo("millisecond")); + Assert.That(requests[0].RequestMessage.Query?["precision"].First(), Is.EqualTo("ms")); } [Test] @@ -289,13 +289,13 @@ public async Task PrecisionCustom() { _client = new InfluxDBClient(MockServerUrl, token: "my-token", organization: "my-org", database: "my-database"); MockServer - .Given(Request.Create().WithPath("/api/v3/write_lp").UsingPost()) + .Given(Request.Create().WithPath("/api/v2/write").UsingPost()) .RespondWith(Response.Create().WithStatusCode(204)); await _client.WriteRecordAsync("mem,tag=a field=1", precision: WritePrecision.S); var requests = MockServer.LogEntries.ToList(); - Assert.That(requests[0].RequestMessage.Query?["precision"].First(), Is.EqualTo("second")); + Assert.That(requests[0].RequestMessage.Query?["precision"].First(), Is.EqualTo("s")); } [Test] @@ -303,7 +303,7 @@ public async Task PrecisionBody() { _client = new InfluxDBClient(MockServerUrl, token: "my-token", organization: "my-org", database: "my-database"); MockServer - .Given(Request.Create().WithPath("/api/v3/write_lp").UsingPost()) + .Given(Request.Create().WithPath("/api/v2/write").UsingPost()) .RespondWith(Response.Create().WithStatusCode(204)); var point = PointData.Measurement("h2o") @@ -333,7 +333,7 @@ public async Task Proxy() } }); MockProxy - .Given(Request.Create().WithPath("/api/v3/write_lp").UsingPost()) + .Given(Request.Create().WithPath("/api/v2/write").UsingPost()) .RespondWith(Response.Create().WithStatusCode(204)); var point = PointData.Measurement("h2o") @@ -362,7 +362,7 @@ public async Task CustomHeader() } }); MockServer - .Given(Request.Create().WithPath("/api/v3/write_lp").WithHeader("X-device", "ab-01").UsingPost()) + .Given(Request.Create().WithPath("/api/v2/write").WithHeader("X-device", "ab-01").UsingPost()) .RespondWith(Response.Create().WithStatusCode(204)); var point = PointData.Measurement("h2o") @@ -392,7 +392,7 @@ public async Task CustomHeaderFromRequest() Database = "my-database" }); MockServer - .Given(Request.Create().WithPath("/api/v3/write_lp").WithHeader("X-Tracing-ID", "123").UsingPost()) + .Given(Request.Create().WithPath("/api/v2/write").WithHeader("X-Tracing-ID", "123").UsingPost()) .RespondWith(Response.Create().WithStatusCode(204)); var point = PointData.Measurement("h2o") @@ -425,7 +425,7 @@ public async Task CustomHeaderFromRequestArePreferred() } }); MockServer - .Given(Request.Create().WithPath("/api/v3/write_lp").WithHeader("X-Client-ID", "456").UsingPost()) + .Given(Request.Create().WithPath("/api/v2/write").WithHeader("X-Client-ID", "456").UsingPost()) .RespondWith(Response.Create().WithStatusCode(204)); var point = PointData.Measurement("h2o") @@ -446,7 +446,7 @@ public async Task CustomHeaderFromRequestArePreferred() private async Task WriteData() { MockServer - .Given(Request.Create().WithPath("/api/v3/write_lp").UsingPost()) + .Given(Request.Create().WithPath("/api/v2/write").UsingPost()) .RespondWith(Response.Create().WithStatusCode(204)); await _client.WriteRecordAsync("mem,tag=a field=1"); @@ -454,11 +454,11 @@ private async Task WriteData() private static IEnumerable WriteV3OptionCases() { - yield return new TestCaseData(new WriteOptions { NoSync = false }, false, false) + yield return new TestCaseData(new WriteOptions { UseV2Api = false, NoSync = false }, false, false) .SetName("WriteToV3_NoSyncFalse_OmitsNoSyncAndAcceptPartial"); - yield return new TestCaseData(new WriteOptions { AcceptPartial = false }, false, true) + yield return new TestCaseData(new WriteOptions { UseV2Api = false, AcceptPartial = false }, false, true) .SetName("WriteToV3_AcceptPartialFalse_AddsAcceptPartialQueryParam"); - yield return new TestCaseData(new WriteOptions { NoSync = true }, true, false) + yield return new TestCaseData(new WriteOptions { UseV2Api = false, NoSync = true }, true, false) .SetName("WriteToV3_NoSyncTrue_AddsNoSyncQueryParam"); } @@ -523,7 +523,7 @@ public void WriteNoSyncTrueWithUseV2ApiValidationError() Assert.Multiple(() => { Assert.That(ae, Is.Not.Null); - Assert.That(ae.Message, Is.EqualTo("invalid write options: NoSync cannot be used in V2 API")); + Assert.That(ae.Message, Is.EqualTo("invalid write options: NoSync requires UseV2Api=false")); }); } @@ -560,10 +560,46 @@ public async Task WriteUseV2ApiRoutesToV2AndIgnoresAcceptPartial(bool acceptPart } [Test] - public void TimeoutExceededByTimeout() + public async Task WriteDefaultV2ToV3OnlyBackendReturnsGuidance() { + _client = new InfluxDBClient(MockServerUrl, token: "my-token", database: "my-database"); + MockServer + .Given(Request.Create().WithPath("/api/v2/write").UsingPost()) + .RespondWith(Response.Create().WithStatusCode(405)); + + var ae = Assert.ThrowsAsync(async () => { await _client.WriteRecordAsync("mem,tag=a field=1"); }); + Assert.That(ae, Is.Not.Null); + Assert.That(ae!.Message, Is.EqualTo( + "server doesn't support the V2 API endpoint (/api/v2/write) " + + "(set UseV2Api=false; write options: {UseV2Api:true,NoSync:false,AcceptPartial:true})")); + } + + [Test] + public async Task WriteV3ToV2OnlyBackendReturnsGuidance() + { + _client = new InfluxDBClient(new ClientConfig + { + Host = MockServerUrl, + Token = "my-token", + Database = "my-database", + WriteOptions = new WriteOptions { UseV2Api = false } + }); MockServer .Given(Request.Create().WithPath("/api/v3/write_lp").UsingPost()) + .RespondWith(Response.Create().WithStatusCode(405)); + + var ae = Assert.ThrowsAsync(async () => { await _client.WriteRecordAsync("mem,tag=a field=1"); }); + Assert.That(ae, Is.Not.Null); + Assert.That(ae!.Message, Is.EqualTo( + "server doesn't support the V3 API endpoint (/api/v3/write_lp) " + + "(set UseV2Api=true; write options: {UseV2Api:false,NoSync:false,AcceptPartial:true})")); + } + + [Test] + public void TimeoutExceededByTimeout() + { + MockServer + .Given(Request.Create().WithPath("/api/v2/write").UsingPost()) .RespondWith(Response.Create().WithStatusCode(204).WithDelay(TimeSpan.FromSeconds(2))); _client = new InfluxDBClient(new ClientConfig @@ -583,7 +619,7 @@ public void TimeoutExceededByTimeout() public void TimeoutExceededByWriteTimeout() { MockServer - .Given(Request.Create().WithPath("/api/v3/write_lp").UsingPost()) + .Given(Request.Create().WithPath("/api/v2/write").UsingPost()) .RespondWith(Response.Create().WithStatusCode(204).WithDelay(TimeSpan.FromSeconds(2))); _client = new InfluxDBClient(new ClientConfig @@ -606,7 +642,7 @@ public void TimeoutExceededByWriteTimeout() public void TimeoutExceededByToken() { MockServer - .Given(Request.Create().WithPath("/api/v3/write_lp").UsingPost()) + .Given(Request.Create().WithPath("/api/v2/write").UsingPost()) .RespondWith(Response.Create().WithStatusCode(204).WithDelay(TimeSpan.FromSeconds(2))); _client = new InfluxDBClient(new ClientConfig @@ -647,6 +683,7 @@ public async Task WriteFromUrl() parameters.Add("org", org); parameters.Add("precision", precision); parameters.Add("writeNoSync", writeNoSync.ToString().ToLower()); + parameters.Add("writeUseV2Api", "false"); parameters.Add("gzipThreshold", gzipThreshold.ToString()); ; var uriBuilder = new UriBuilder(MockServerUrl); uriBuilder.Query = parameters.ToString()!; @@ -701,6 +738,7 @@ public async Task WriteFromEnvVars() { ClientConfig.EnvInfluxPrecision, precision }, { ClientConfig.EnvInfluxAuthScheme, authSchema }, { ClientConfig.EnvInfluxWriteNoSync, writeNoSync.ToString().ToLower() }, + { ClientConfig.EnvInfluxWriteUseV2Api, "false" }, { ClientConfig.EnvInfluxGzipThreshold, gzipThreshold.ToString() } }; diff --git a/Client/Config/WriteOptions.cs b/Client/Config/WriteOptions.cs index 8b123de1..e0c69463 100644 --- a/Client/Config/WriteOptions.cs +++ b/Client/Config/WriteOptions.cs @@ -12,8 +12,8 @@ namespace InfluxDB3.Client.Config; /// - GzipThreshold: The threshold in bytes for gzipping the body. The default value is 1000. /// - TagOrder: Preferred tag order for line protocol serialization. /// - NoSync: Bool value whether to skip waiting for WAL persistence on write. The default value is false. -/// - AcceptPartial: Allow partial writes on v3 write endpoint. The default value is true. -/// - UseV2Api: Route writes to v2 compatibility endpoint (/api/v2/write). The default value is false. +/// - AcceptPartial: Allow partial writes on the V3 API endpoint. The default value is true. +/// - UseV2Api: Route writes to the V2 API endpoint (/api/v2/write). The default value is true. /// /// If you want create client with custom options, you can use the following code: /// @@ -83,7 +83,7 @@ public class WriteOptions : ICloneable /// NoSync=true means faster write but without the confirmation that the data was persisted. /// /// Note: This option is supported by InfluxDB 3 Core and Enterprise servers only. - /// For other InfluxDB 3 server types (InfluxDB Clustered, InfluxDB Clould Serverless/Dedicated) + /// For other InfluxDB 3 server types (InfluxDB Clustered, InfluxDB Cloud Dedicated/Serverless) /// the write operation will fail with an error. /// /// Default value: false. @@ -91,17 +91,18 @@ public class WriteOptions : ICloneable public bool NoSync { get; set; } /// - /// Controls partial-write behavior on the v3 write endpoint. + /// Controls partial-write behavior on the V3 API endpoint. /// true (default): allow partial writes (server default behavior) /// false: reject the full batch when any line fails. + /// This option is ignored for writes sent to the V2 API endpoint (UseV2Api=true). /// public bool AcceptPartial { get; set; } = true; /// - /// Routes writes to the v2 compatibility endpoint (/api/v2/write). - /// Intended for InfluxDB Clustered/v2-compatible backends. + /// Routes writes to the V2 API endpoint (/api/v2/write). + /// Default value: true. /// - public bool UseV2Api { get; set; } = false; + public bool UseV2Api { get; set; } = true; public object Clone() { @@ -112,7 +113,7 @@ internal void Validate() { if (UseV2Api && NoSync) { - throw new InvalidOperationException("invalid write options: NoSync cannot be used in V2 API"); + throw new InvalidOperationException("invalid write options: NoSync requires UseV2Api=false"); } } @@ -122,6 +123,6 @@ internal void Validate() GzipThreshold = 1000, NoSync = false, AcceptPartial = true, - UseV2Api = false, + UseV2Api = true, }; } diff --git a/Client/InfluxDBClient.cs b/Client/InfluxDBClient.cs index d0e8c78f..c6b25815 100644 --- a/Client/InfluxDBClient.cs +++ b/Client/InfluxDBClient.cs @@ -355,10 +355,10 @@ public InfluxDBClient(ClientConfig config) /// gzipThreshold - threshold for gzip data when writing (default is 1000). /// /// - /// writeAcceptPartial - allow partial writes on v3 write endpoint (default is true) + /// writeAcceptPartial - allow partial writes on V3 API endpoint (default is true) /// /// - /// writeUseV2Api - route writes to v2 compatibility endpoint (default is false) + /// writeUseV2Api - route writes to V2 API endpoint (default is true) /// /// /// writeNoSync - bool value whether to skip waiting for WAL persistence on write (default is false) @@ -401,10 +401,10 @@ public InfluxDBClient(string connectionString) : this(new ClientConfig(connectio /// INFLUX_GZIP_THRESHOLD - threshold for gzipping data when writing (default is 1000) /// /// - /// INFLUX_WRITE_ACCEPT_PARTIAL - allow partial writes on v3 write endpoint (default is true) + /// INFLUX_WRITE_ACCEPT_PARTIAL - allow partial writes on V3 API endpoint (default is true) /// /// - /// INFLUX_WRITE_USE_V2_API - route writes to v2 compatibility endpoint (default is false) + /// INFLUX_WRITE_USE_V2_API - route writes to V2 API endpoint (default is true) /// /// /// INFLUX_WRITE_NO_SYNC - bool value whether to skip waiting for WAL persistence on write (default is false) @@ -824,9 +824,32 @@ var databaseNotNull cancelToken = new CancellationTokenSource(_config.WriteTimeout.Value).Token; } - await _restClient - .Request(path, HttpMethod.Post, content, queryParams, headers, cancelToken) - .ConfigureAwait(false); + try + { + await _restClient + .Request(path, HttpMethod.Post, content, queryParams, headers, cancelToken) + .ConfigureAwait(false); + } + catch (InfluxDBApiException ex) when (ex.StatusCode == HttpStatusCode.MethodNotAllowed) + { + if (writeOptions.UseV2Api && path == "api/v2/write") + { + throw new InfluxDBApiException( + $"server doesn't support the V2 API endpoint (/api/v2/write) " + + $"(set UseV2Api=false; write options: {{UseV2Api:true,NoSync:{writeOptions.NoSync.ToString().ToLowerInvariant()},AcceptPartial:{writeOptions.AcceptPartial.ToString().ToLowerInvariant()}}})", + ex.HttpResponseMessage!); + } + + if (!writeOptions.UseV2Api && path == "api/v3/write_lp") + { + throw new InfluxDBApiException( + $"server doesn't support the V3 API endpoint (/api/v3/write_lp) " + + $"(set UseV2Api=true; write options: {{UseV2Api:false,NoSync:{writeOptions.NoSync.ToString().ToLowerInvariant()},AcceptPartial:{writeOptions.AcceptPartial.ToString().ToLowerInvariant()}}})", + ex.HttpResponseMessage!); + } + + throw; + } } /// diff --git a/README.md b/README.md index 6918bf5e..8eff95a1 100644 --- a/README.md +++ b/README.md @@ -137,10 +137,9 @@ catch (InfluxDBApiException e) See [Partial writes](https://docs.influxdata.com/influxdb3/core/write-data/http-api/v3-write-lp/#partial-writes) for more. -#### Use V2 API Compatibility Mode +#### Compatibility with InfluxDB Clustered and InfluxDB Cloud Dedicated/Serverless -By default, writes use `/api/v3/write_lp`. -For InfluxDB Clustered/v2-compatible backends, set `UseV2Api=true` to route writes to `/api/v2/write`. +Writes use the V2 API endpoint by default, so no additional configuration is required for these products. `UseV2Api` can be configured in three ways: @@ -154,6 +153,11 @@ For InfluxDB Clustered/v2-compatible backends, set `UseV2Api=true` to route writ 2. Connection string `writeAcceptPartial` 3. Environment variable `INFLUX_WRITE_ACCEPT_PARTIAL` +`NoSync` requires the V3 API endpoint, which is available with InfluxDB 3 Core/Enterprise. +`AcceptPartial` applies only when writes are sent to the V3 API endpoint and is ignored when using the V2 API endpoint. +To use `NoSync`, set `UseV2Api=false` (or equivalent via connection string/env var). +Note: when writes use the V2 API endpoint, `NoSync=true` returns a validation error. + ### Query To query your data, you can use code like this: From 15330adc9fe4de1459503ad0c4b1e0e6d5bd4d4b Mon Sep 17 00:00:00 2001 From: Ales Pour Date: Fri, 15 May 2026 16:00:27 +0200 Subject: [PATCH 07/22] test: fix rebase error --- Client.Test.Integration/WriteTest.cs | 1 - 1 file changed, 1 deletion(-) diff --git a/Client.Test.Integration/WriteTest.cs b/Client.Test.Integration/WriteTest.cs index 1a28c449..ace8a240 100644 --- a/Client.Test.Integration/WriteTest.cs +++ b/Client.Test.Integration/WriteTest.cs @@ -27,7 +27,6 @@ public async Task WriteWithError() { if (ex is InfluxDBApiException iaex) { - var iaex = (InfluxDBApiException)ex; Assert.Multiple((Action)(() => { Assert.That(iaex.Message, From 686e4e05f6db898836533ea2cbaf69f9da254c9f Mon Sep 17 00:00:00 2001 From: Ales Pour Date: Fri, 15 May 2026 16:05:39 +0200 Subject: [PATCH 08/22] chore: add missing dependency --- Client/Client.csproj | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/Client/Client.csproj b/Client/Client.csproj index b8f552e4..913e034d 100644 --- a/Client/Client.csproj +++ b/Client/Client.csproj @@ -44,6 +44,10 @@ + + + + <_Parameter1>InfluxDB3.Client.Test, PublicKey=0024000004800000940000000602000000240000525341310004000001000100054b3efef02968d05c3dd8481e23fb40ade1fae377f18cf5fa48c673694140f7c00dc0b38d43be297256824dc8489c5224647e77f861ef600514607159b151cf71b094a0ef5736c420cbaa14100acc3b3694e3815597a5e89cf8090ed22bfdad2d5eec49250d88da1345d670b5e131ed9611eed141e04c31d79f166db39cb4a5 From 5134c01b95f6400a750da82be954581207ae6d73 Mon Sep 17 00:00:00 2001 From: Ales Pour Date: Fri, 15 May 2026 16:09:13 +0200 Subject: [PATCH 09/22] chore: add missing dependency --- Client/Client.csproj | 3 --- 1 file changed, 3 deletions(-) diff --git a/Client/Client.csproj b/Client/Client.csproj index 913e034d..b03a6b38 100644 --- a/Client/Client.csproj +++ b/Client/Client.csproj @@ -42,9 +42,6 @@ - - - From 48ac3412598825cc0936730330e90d78ed6cc0c7 Mon Sep 17 00:00:00 2001 From: Ales Pour Date: Fri, 15 May 2026 16:47:04 +0200 Subject: [PATCH 10/22] fix: error parsing details and docs --- Client.Test/Internal/RestClientTest.cs | 58 +++++++++++++++++++++++++- Client/Internal/RestClient.cs | 31 ++++++++++++-- README.md | 5 ++- 3 files changed, 89 insertions(+), 5 deletions(-) diff --git a/Client.Test/Internal/RestClientTest.cs b/Client.Test/Internal/RestClientTest.cs index 18303449..6d352fcc 100644 --- a/Client.Test/Internal/RestClientTest.cs +++ b/Client.Test/Internal/RestClientTest.cs @@ -238,7 +238,8 @@ public void ErrorJsonBodyV3WithDataObject() { Assert.That(ae, Is.Not.Null); Assert.That(ae.HttpResponseMessage, Is.Not.Null); - Assert.That(ae.Message, Is.EqualTo("parsing failed")); + Assert.That(ae.Message, Is.EqualTo( + "parsing failed: invalid field value in line protocol for field 'value' on line 0")); }); } @@ -271,6 +272,30 @@ public void ErrorJsonBodyV3WithoutData() }); } + [Test] + public void ErrorJsonBodyV3WithDataErrorMessageCombinesMessages() + { + CreateAndConfigureRestClient(new ClientConfig + { + Host = MockServerUrl, + }); + + MockServer + .Given(Request.Create().WithPath("/api").UsingPost()) + .RespondWith(Response.Create() + .WithHeader("Content-Type", "application/json") + .WithBody("{\"error\":\"parsing failed\",\"data\":{\"error_message\":\"invalid field value\"}}") + .WithStatusCode(400)); + + var ae = Assert.ThrowsAsync(async () => + { + await _client.Request("api", HttpMethod.Post); + }); + + Assert.That(ae, Is.Not.Null); + Assert.That(ae!.Message, Is.EqualTo("parsing failed: invalid field value")); + } + [Test] public void ErrorJsonBodyV3WithDataArray() { @@ -389,6 +414,37 @@ public void ErrorJsonBodyV3PartialWriteWithDataObjectErrorMessageOnly() }); } + [Test] + public void ErrorJsonBodyV3PartialWriteWithLineNumberWithoutOriginalLine() + { + CreateAndConfigureRestClient(new ClientConfig + { + Host = MockServerUrl, + }); + + MockServer + .Given(Request.Create().WithPath("/api").UsingPost()) + .RespondWith(Response.Create() + .WithHeader("Content-Type", "application/json") + .WithBody("{\"error\":\"partial write of line protocol occurred\",\"data\":{\"error_message\":\"invalid field value\",\"line_number\":2}}") + .WithStatusCode(400)); + + var ae = Assert.ThrowsAsync(async () => + { + await _client.Request("api", HttpMethod.Post); + }); + + Assert.Multiple(() => + { + Assert.That(ae, Is.Not.Null); + Assert.That(ae.LineErrors, Has.Count.EqualTo(1)); + Assert.That(ae.LineErrors[0].LineNumber, Is.EqualTo(2)); + Assert.That(ae.LineErrors[0].ErrorMessage, Is.EqualTo("invalid field value")); + Assert.That(ae.LineErrors[0].OriginalLine, Is.Null); + Assert.That(ae.Message, Is.EqualTo("partial write of line protocol occurred:\n\tline 2: invalid field value")); + }); + } + [Test] public void ErrorReason() { diff --git a/Client/Internal/RestClient.cs b/Client/Internal/RestClient.cs index b14dae19..f32193d2 100644 --- a/Client/Internal/RestClient.cs +++ b/Client/Internal/RestClient.cs @@ -142,7 +142,10 @@ private static ParsedErrorMessage ParseErrorMessage(string body, string? content var data = GetProperty(root, "data"); var legacyDataMessage = GetLegacyDataErrorMessage(data); - var message = cloudMessage ?? topLevelError ?? legacyDataMessage; + var message = cloudMessage ?? + CombineMessages(topLevelError, legacyDataMessage) ?? + topLevelError ?? + legacyDataMessage; var partial = ParsePartialWrite(topLevelError, data); if (partial is not null) { @@ -217,9 +220,16 @@ private static string BuildPartialWriteMessage( message.Append(':'); hasDetails = true; } - if (detail.LineNumber != 0 && !string.IsNullOrEmpty(detail.OriginalLine)) + if (detail.LineNumber != 0) { - message.Append($"\n\tline {detail.LineNumber}: {detail.ErrorMessage} ({detail.OriginalLine})"); + if (!string.IsNullOrEmpty(detail.OriginalLine)) + { + message.Append($"\n\tline {detail.LineNumber}: {detail.ErrorMessage} ({detail.OriginalLine})"); + } + else + { + message.Append($"\n\tline {detail.LineNumber}: {detail.ErrorMessage}"); + } } else if (!string.IsNullOrEmpty(detail.ErrorMessage)) { @@ -240,6 +250,21 @@ private static bool IsPartialWriteError(string topLevelError) topLevelError.IndexOf("parsing failed for write_lp endpoint", StringComparison.OrdinalIgnoreCase) >= 0; } + private static string? CombineMessages(string? topLevelError, string? dataMessage) + { + if (string.IsNullOrEmpty(topLevelError) || string.IsNullOrEmpty(dataMessage)) + { + return null; + } + + if (string.Equals(topLevelError, dataMessage, StringComparison.Ordinal)) + { + return topLevelError; + } + + return $"{topLevelError}: {dataMessage}"; + } + private static bool TryParseTypedLineErrors( JsonElement data, out List lineErrors) diff --git a/README.md b/README.md index 8eff95a1..1ce3de7a 100644 --- a/README.md +++ b/README.md @@ -114,7 +114,10 @@ using var client = new InfluxDBClient(new ClientConfig Host = host, Token = token, Database = database, - WriteOptions = new WriteOptions() + WriteOptions = new WriteOptions + { + UseV2Api = false + } }); try From 466620ae391c757ab92500b0c3e0367ae344f9d8 Mon Sep 17 00:00:00 2001 From: Ales Pour Date: Fri, 15 May 2026 16:50:13 +0200 Subject: [PATCH 11/22] test: add API compatibility e2e tests --- Client.Test.Integration/QueryWriteTest.cs | 42 +++++++++++++++++++++- Client.Test.Integration/WriteTest.cs | 44 +++++++++++++++++++++++ 2 files changed, 85 insertions(+), 1 deletion(-) diff --git a/Client.Test.Integration/QueryWriteTest.cs b/Client.Test.Integration/QueryWriteTest.cs index dca72dfb..1a7457cb 100644 --- a/Client.Test.Integration/QueryWriteTest.cs +++ b/Client.Test.Integration/QueryWriteTest.cs @@ -53,6 +53,46 @@ public async Task QueryWrite() Assert.That(points.First().GetTag("type"), Is.EqualTo("used")); } + [TestCase(true, TestName = "QueryWriteWithUseV2Api_True")] + [TestCase(false, TestName = "QueryWriteWithUseV2Api_False")] + public async Task QueryWriteWithUseV2Api(bool useV2Api) + { + using var client = new InfluxDBClient(new ClientConfig + { + Host = Host, + Token = Token, + Database = Database, + WriteOptions = new WriteOptions + { + UseV2Api = useV2Api + } + }); + + const string measurement = "integration_test_write_api"; + var testId = DateTimeOffset.UtcNow.ToUnixTimeMilliseconds(); + await client.WriteRecordAsync($"{measurement},type=used value=321.0,testId={testId}"); + + var sql = $"SELECT value FROM {measurement} where \"testId\" = {testId}"; + List results = new(); + for (var i = 0; i < 50; i++) + { + results = await client.Query(sql).ToListAsync(); + if (results.Count > 0) + { + break; + } + + await Task.Delay(100); + } + + Assert.Multiple(() => + { + Assert.That(results, Has.Count.EqualTo(1), $"No rows returned for UseV2Api={useV2Api}"); + Assert.That(results[0], Has.Length.EqualTo(1)); + Assert.That(results[0][0], Is.EqualTo(321.0)); + }); + } + [Test] public void QueryNotAuthorized() { @@ -328,4 +368,4 @@ private static void TestQueryPoints(InfluxDBClient client, TimeSpan? timeout = n })); Assert.That(ex.StatusCode, Is.EqualTo(StatusCode.DeadlineExceeded)); } -} \ No newline at end of file +} diff --git a/Client.Test.Integration/WriteTest.cs b/Client.Test.Integration/WriteTest.cs index ace8a240..baf26b92 100644 --- a/Client.Test.Integration/WriteTest.cs +++ b/Client.Test.Integration/WriteTest.cs @@ -3,6 +3,7 @@ using System.Threading.Tasks; using InfluxDB3.Client.Config; using NUnit.Framework; +using WriteOptions = InfluxDB3.Client.Config.WriteOptions; namespace InfluxDB3.Client.Test.Integration; @@ -44,4 +45,47 @@ public async Task WriteWithError() } } } + + [TestCase(false, true, TestName = "WritePartialBatch_WithV3Api_ReturnsStructuredPartialWriteError")] + [TestCase(true, false, TestName = "WritePartialBatch_WithV2Api_ReturnsGenericApiError")] + public async Task WritePartialBatchBehaviorByWriteApi(bool useV2Api, bool expectStructuredPartialError) + { + using var client = new InfluxDBClient(new ClientConfig + { + Host = Host, + Token = Token, + Database = Database, + WriteOptions = new WriteOptions + { + UseV2Api = useV2Api, + AcceptPartial = true + } + }); + + var testId = DateTimeOffset.UtcNow.ToUnixTimeMilliseconds(); + var validLine = $"vehicle,id=vwbus vel=1.0,testId={testId}"; + var invalidLine = $"vehicle,id=vwbus vel=,testId={testId}"; + + var ae = Assert.ThrowsAsync(async () => + { + await client.WriteRecordsAsync(new[] { validLine, invalidLine }); + }); + + Assert.That(ae, Is.Not.Null); + Assert.That(ae!.StatusCode, Is.EqualTo(HttpStatusCode.BadRequest)); + + if (expectStructuredPartialError) + { + Assert.That(ae, Is.InstanceOf()); + var pwe = (InfluxDBPartialWriteException)ae!; + Assert.That(pwe.LineErrors, Is.Not.Empty); + Assert.That(ae.Message, + Does.Contain("partial write of line protocol occurred") + .Or.Contain("parsing failed for write_lp endpoint")); + } + else + { + Assert.That(ae, Is.Not.InstanceOf()); + } + } } From 7384d69b31f2ba51640499a3f4d97f7bfd324b6b Mon Sep 17 00:00:00 2001 From: Ales Pour Date: Fri, 15 May 2026 16:53:36 +0200 Subject: [PATCH 12/22] test: fix compilication errors --- Client.Test.Integration/QueryWriteTest.cs | 4 ++-- Client.Test.Integration/WriteTest.cs | 6 +++--- 2 files changed, 5 insertions(+), 5 deletions(-) diff --git a/Client.Test.Integration/QueryWriteTest.cs b/Client.Test.Integration/QueryWriteTest.cs index 1a7457cb..925e9f20 100644 --- a/Client.Test.Integration/QueryWriteTest.cs +++ b/Client.Test.Integration/QueryWriteTest.cs @@ -85,12 +85,12 @@ public async Task QueryWriteWithUseV2Api(bool useV2Api) await Task.Delay(100); } - Assert.Multiple(() => + Assert.Multiple((Action)(() => { Assert.That(results, Has.Count.EqualTo(1), $"No rows returned for UseV2Api={useV2Api}"); Assert.That(results[0], Has.Length.EqualTo(1)); Assert.That(results[0][0], Is.EqualTo(321.0)); - }); + })); } [Test] diff --git a/Client.Test.Integration/WriteTest.cs b/Client.Test.Integration/WriteTest.cs index baf26b92..1a36d0f1 100644 --- a/Client.Test.Integration/WriteTest.cs +++ b/Client.Test.Integration/WriteTest.cs @@ -48,7 +48,7 @@ public async Task WriteWithError() [TestCase(false, true, TestName = "WritePartialBatch_WithV3Api_ReturnsStructuredPartialWriteError")] [TestCase(true, false, TestName = "WritePartialBatch_WithV2Api_ReturnsGenericApiError")] - public async Task WritePartialBatchBehaviorByWriteApi(bool useV2Api, bool expectStructuredPartialError) + public void WritePartialBatchBehaviorByWriteApi(bool useV2Api, bool expectStructuredPartialError) { using var client = new InfluxDBClient(new ClientConfig { @@ -66,10 +66,10 @@ public async Task WritePartialBatchBehaviorByWriteApi(bool useV2Api, bool expect var validLine = $"vehicle,id=vwbus vel=1.0,testId={testId}"; var invalidLine = $"vehicle,id=vwbus vel=,testId={testId}"; - var ae = Assert.ThrowsAsync(async () => + var ae = Assert.ThrowsAsync((Func)(async () => { await client.WriteRecordsAsync(new[] { validLine, invalidLine }); - }); + })); Assert.That(ae, Is.Not.Null); Assert.That(ae!.StatusCode, Is.EqualTo(HttpStatusCode.BadRequest)); From 56b365aabadfcf5682f104a416e39e7bd0fdd8b3 Mon Sep 17 00:00:00 2001 From: Ales Pour Date: Fri, 15 May 2026 16:58:10 +0200 Subject: [PATCH 13/22] test: fix errors --- Client.Test.Integration/WriteTest.cs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/Client.Test.Integration/WriteTest.cs b/Client.Test.Integration/WriteTest.cs index 1a36d0f1..9239a0c7 100644 --- a/Client.Test.Integration/WriteTest.cs +++ b/Client.Test.Integration/WriteTest.cs @@ -66,7 +66,7 @@ public void WritePartialBatchBehaviorByWriteApi(bool useV2Api, bool expectStruct var validLine = $"vehicle,id=vwbus vel=1.0,testId={testId}"; var invalidLine = $"vehicle,id=vwbus vel=,testId={testId}"; - var ae = Assert.ThrowsAsync((Func)(async () => + var ae = Assert.CatchAsync((Func)(async () => { await client.WriteRecordsAsync(new[] { validLine, invalidLine }); })); From de73e08c4c581872c8da65b6565e4018200d3084 Mon Sep 17 00:00:00 2001 From: Ales Pour Date: Fri, 15 May 2026 17:19:23 +0200 Subject: [PATCH 14/22] test: add more tests --- Client.Test.Integration/WriteTest.cs | 10 ++++++---- 1 file changed, 6 insertions(+), 4 deletions(-) diff --git a/Client.Test.Integration/WriteTest.cs b/Client.Test.Integration/WriteTest.cs index 9239a0c7..f1daa66d 100644 --- a/Client.Test.Integration/WriteTest.cs +++ b/Client.Test.Integration/WriteTest.cs @@ -46,9 +46,11 @@ public async Task WriteWithError() } } - [TestCase(false, true, TestName = "WritePartialBatch_WithV3Api_ReturnsStructuredPartialWriteError")] - [TestCase(true, false, TestName = "WritePartialBatch_WithV2Api_ReturnsGenericApiError")] - public void WritePartialBatchBehaviorByWriteApi(bool useV2Api, bool expectStructuredPartialError) + [TestCase(false, true, true, TestName = "WritePartialBatch_WithV3Api_ReturnsStructuredPartialWriteError")] + [TestCase(true, false, true, TestName = "WritePartialBatch_WithV2Api_ReturnsGenericApiError")] + [TestCase(false, true, false, TestName = "WritePartialBatch_WithV3Api_AcceptPartialFalse_ReturnsStructuredPartialWriteError")] + [TestCase(true, false, false, TestName = "WritePartialBatch_WithV2Api_AcceptPartialFalse_ReturnsGenericApiError")] + public void WritePartialBatchBehaviorByWriteApi(bool useV2Api, bool expectStructuredPartialError, bool acceptPartial) { using var client = new InfluxDBClient(new ClientConfig { @@ -58,7 +60,7 @@ public void WritePartialBatchBehaviorByWriteApi(bool useV2Api, bool expectStruct WriteOptions = new WriteOptions { UseV2Api = useV2Api, - AcceptPartial = true + AcceptPartial = acceptPartial } }); From 46a46d91e9759e5702b69d9ec8e43dde2bc42964 Mon Sep 17 00:00:00 2001 From: Ales Pour Date: Fri, 15 May 2026 17:27:18 +0200 Subject: [PATCH 15/22] fix: error parsing --- Client.Test/Internal/RestClientTest.cs | 27 ++++++++++++++++++++++++- Client/Config/ClientConfig.cs | 10 --------- Client/InfluxDBPartialWriteException.cs | 4 ++-- Client/Internal/RestClient.cs | 21 ++++++++++--------- 4 files changed, 40 insertions(+), 22 deletions(-) diff --git a/Client.Test/Internal/RestClientTest.cs b/Client.Test/Internal/RestClientTest.cs index 6d352fcc..dea8ae68 100644 --- a/Client.Test/Internal/RestClientTest.cs +++ b/Client.Test/Internal/RestClientTest.cs @@ -296,6 +296,31 @@ public void ErrorJsonBodyV3WithDataErrorMessageCombinesMessages() Assert.That(ae!.Message, Is.EqualTo("parsing failed: invalid field value")); } + [Test] + public void ErrorJsonBodyWithNonObjectRootFallsBackToHeaders() + { + CreateAndConfigureRestClient(new ClientConfig + { + Host = MockServerUrl, + }); + + MockServer + .Given(Request.Create().WithPath("/api").UsingPost()) + .RespondWith(Response.Create() + .WithHeader("Content-Type", "application/json") + .WithHeader("X-Influx-Error", "fallback header message") + .WithBody("[1,2,3]") + .WithStatusCode(400)); + + var ae = Assert.ThrowsAsync(async () => + { + await _client.Request("api", HttpMethod.Post); + }); + + Assert.That(ae, Is.Not.Null); + Assert.That(ae!.Message, Is.EqualTo("fallback header message")); + } + [Test] public void ErrorJsonBodyV3WithDataArray() { @@ -407,7 +432,7 @@ public void ErrorJsonBodyV3PartialWriteWithDataObjectErrorMessageOnly() { Assert.That(ae, Is.Not.Null); Assert.That(ae.LineErrors, Has.Count.EqualTo(1)); - Assert.That(ae.LineErrors[0].LineNumber, Is.EqualTo(0)); + Assert.That(ae.LineErrors[0].LineNumber, Is.Null); Assert.That(ae.LineErrors[0].ErrorMessage, Is.EqualTo("invalid field value")); Assert.That(ae.LineErrors[0].OriginalLine, Is.Null); Assert.That(ae.Message, Is.EqualTo("partial write of line protocol occurred:\n\tinvalid field value")); diff --git a/Client/Config/ClientConfig.cs b/Client/Config/ClientConfig.cs index ebd0ccbd..79ab8366 100644 --- a/Client/Config/ClientConfig.cs +++ b/Client/Config/ClientConfig.cs @@ -249,16 +249,6 @@ internal bool WriteNoSync get => (WriteOptions ?? WriteOptions.DefaultOptions).NoSync; } - internal bool WriteAcceptPartial - { - get => (WriteOptions ?? WriteOptions.DefaultOptions).AcceptPartial; - } - - internal bool WriteUseV2Api - { - get => (WriteOptions ?? WriteOptions.DefaultOptions).UseV2Api; - } - private void ParsePrecision(string? precision) { if (precision != null) diff --git a/Client/InfluxDBPartialWriteException.cs b/Client/InfluxDBPartialWriteException.cs index 0b1d4c0f..e4028e29 100644 --- a/Client/InfluxDBPartialWriteException.cs +++ b/Client/InfluxDBPartialWriteException.cs @@ -10,14 +10,14 @@ public class InfluxDBPartialWriteException : InfluxDBApiException { public sealed class LineError { - public LineError(int lineNumber, string errorMessage, string? originalLine) + public LineError(int? lineNumber, string errorMessage, string? originalLine) { LineNumber = lineNumber; ErrorMessage = errorMessage; OriginalLine = originalLine; } - public int LineNumber { get; } + public int? LineNumber { get; } public string ErrorMessage { get; } diff --git a/Client/Internal/RestClient.cs b/Client/Internal/RestClient.cs index f32193d2..de369b2d 100644 --- a/Client/Internal/RestClient.cs +++ b/Client/Internal/RestClient.cs @@ -136,6 +136,10 @@ private static ParsedErrorMessage ParseErrorMessage(string body, string? content { using var document = JsonDocument.Parse(body); var root = document.RootElement; + if (root.ValueKind != JsonValueKind.Object) + { + return ParsedErrorMessage.Empty; + } var cloudMessage = GetStringProperty(root, "message"); var topLevelError = GetStringProperty(root, "error"); @@ -220,25 +224,22 @@ private static string BuildPartialWriteMessage( message.Append(':'); hasDetails = true; } - if (detail.LineNumber != 0) + if (detail.LineNumber.HasValue) { + var lineNumber = detail.LineNumber.Value; if (!string.IsNullOrEmpty(detail.OriginalLine)) { - message.Append($"\n\tline {detail.LineNumber}: {detail.ErrorMessage} ({detail.OriginalLine})"); + message.Append($"\n\tline {lineNumber}: {detail.ErrorMessage} ({detail.OriginalLine})"); } else { - message.Append($"\n\tline {detail.LineNumber}: {detail.ErrorMessage}"); + message.Append($"\n\tline {lineNumber}: {detail.ErrorMessage}"); } } else if (!string.IsNullOrEmpty(detail.ErrorMessage)) { message.Append($"\n\t{detail.ErrorMessage}"); } - else - { - message.Append($"\n\tline {detail.LineNumber}: {detail.ErrorMessage}"); - } } return message.ToString(); @@ -305,13 +306,15 @@ private static bool TryParseTypedLineError(JsonElement item, out InfluxDBPartial return false; } - var number = 0; + int? number = null; if (lineNumber is not null) { - if (lineNumber.Value.ValueKind != JsonValueKind.Number || !lineNumber.Value.TryGetInt32(out number)) + if (lineNumber.Value.ValueKind != JsonValueKind.Number || !lineNumber.Value.TryGetInt32(out var parsedNumber)) { return false; } + + number = parsedNumber; } string? original = null; From b27fa0cdd0934f42dbb409a947b046a18f2c884c Mon Sep 17 00:00:00 2001 From: Ales Pour Date: Fri, 15 May 2026 18:00:07 +0200 Subject: [PATCH 16/22] fix: error message formatting --- Client.Test/InfluxDBClientWriteTest.cs | 6 +++--- Client/Config/WriteOptions.cs | 2 +- Client/InfluxDBClient.cs | 4 ++-- 3 files changed, 6 insertions(+), 6 deletions(-) diff --git a/Client.Test/InfluxDBClientWriteTest.cs b/Client.Test/InfluxDBClientWriteTest.cs index b7640b18..7b1e77d6 100644 --- a/Client.Test/InfluxDBClientWriteTest.cs +++ b/Client.Test/InfluxDBClientWriteTest.cs @@ -523,7 +523,7 @@ public void WriteNoSyncTrueWithUseV2ApiValidationError() Assert.Multiple(() => { Assert.That(ae, Is.Not.Null); - Assert.That(ae.Message, Is.EqualTo("invalid write options: NoSync requires UseV2Api=false")); + Assert.That(ae.Message, Is.EqualTo("Invalid write options: NoSync requires UseV2Api=false")); }); } @@ -570,7 +570,7 @@ public async Task WriteDefaultV2ToV3OnlyBackendReturnsGuidance() var ae = Assert.ThrowsAsync(async () => { await _client.WriteRecordAsync("mem,tag=a field=1"); }); Assert.That(ae, Is.Not.Null); Assert.That(ae!.Message, Is.EqualTo( - "server doesn't support the V2 API endpoint (/api/v2/write) " + + "Server doesn't support the V2 API endpoint (/api/v2/write) " + "(set UseV2Api=false; write options: {UseV2Api:true,NoSync:false,AcceptPartial:true})")); } @@ -591,7 +591,7 @@ public async Task WriteV3ToV2OnlyBackendReturnsGuidance() var ae = Assert.ThrowsAsync(async () => { await _client.WriteRecordAsync("mem,tag=a field=1"); }); Assert.That(ae, Is.Not.Null); Assert.That(ae!.Message, Is.EqualTo( - "server doesn't support the V3 API endpoint (/api/v3/write_lp) " + + "Server doesn't support the V3 API endpoint (/api/v3/write_lp) " + "(set UseV2Api=true; write options: {UseV2Api:false,NoSync:false,AcceptPartial:true})")); } diff --git a/Client/Config/WriteOptions.cs b/Client/Config/WriteOptions.cs index e0c69463..756ae5b4 100644 --- a/Client/Config/WriteOptions.cs +++ b/Client/Config/WriteOptions.cs @@ -113,7 +113,7 @@ internal void Validate() { if (UseV2Api && NoSync) { - throw new InvalidOperationException("invalid write options: NoSync requires UseV2Api=false"); + throw new InvalidOperationException("Invalid write options: NoSync requires UseV2Api=false"); } } diff --git a/Client/InfluxDBClient.cs b/Client/InfluxDBClient.cs index c6b25815..555ef16d 100644 --- a/Client/InfluxDBClient.cs +++ b/Client/InfluxDBClient.cs @@ -835,7 +835,7 @@ await _restClient if (writeOptions.UseV2Api && path == "api/v2/write") { throw new InfluxDBApiException( - $"server doesn't support the V2 API endpoint (/api/v2/write) " + + $"Server doesn't support the V2 API endpoint (/api/v2/write) " + $"(set UseV2Api=false; write options: {{UseV2Api:true,NoSync:{writeOptions.NoSync.ToString().ToLowerInvariant()},AcceptPartial:{writeOptions.AcceptPartial.ToString().ToLowerInvariant()}}})", ex.HttpResponseMessage!); } @@ -843,7 +843,7 @@ await _restClient if (!writeOptions.UseV2Api && path == "api/v3/write_lp") { throw new InfluxDBApiException( - $"server doesn't support the V3 API endpoint (/api/v3/write_lp) " + + $"Server doesn't support the V3 API endpoint (/api/v3/write_lp) " + $"(set UseV2Api=true; write options: {{UseV2Api:false,NoSync:{writeOptions.NoSync.ToString().ToLowerInvariant()},AcceptPartial:{writeOptions.AcceptPartial.ToString().ToLowerInvariant()}}})", ex.HttpResponseMessage!); } From afc3cc10291152a1daad74aa1cc6bc49ff842ed6 Mon Sep 17 00:00:00 2001 From: Ales Pour Date: Fri, 15 May 2026 18:45:04 +0200 Subject: [PATCH 17/22] fix: sync input for partial write examples and tests with existing implementations --- Client.Test.Integration/WriteTest.cs | 5 ++--- Client.Test/Internal/RestClientTest.cs | 14 +++++++------- README.md | 4 ++++ 3 files changed, 13 insertions(+), 10 deletions(-) diff --git a/Client.Test.Integration/WriteTest.cs b/Client.Test.Integration/WriteTest.cs index f1daa66d..077f6642 100644 --- a/Client.Test.Integration/WriteTest.cs +++ b/Client.Test.Integration/WriteTest.cs @@ -64,9 +64,8 @@ public void WritePartialBatchBehaviorByWriteApi(bool useV2Api, bool expectStruct } }); - var testId = DateTimeOffset.UtcNow.ToUnixTimeMilliseconds(); - var validLine = $"vehicle,id=vwbus vel=1.0,testId={testId}"; - var invalidLine = $"vehicle,id=vwbus vel=,testId={testId}"; + const string validLine = "home,room=Sunroom temp=96 1735545600"; + const string invalidLine = "home,room=Sunroom temp=\"hi\" 1735549200"; var ae = Assert.CatchAsync((Func)(async () => { diff --git a/Client.Test/Internal/RestClientTest.cs b/Client.Test/Internal/RestClientTest.cs index dea8ae68..13ed160b 100644 --- a/Client.Test/Internal/RestClientTest.cs +++ b/Client.Test/Internal/RestClientTest.cs @@ -334,7 +334,7 @@ public void ErrorJsonBodyV3WithDataArray() .RespondWith(Response.Create() .WithHeader("Content-Type", "application/json") .WithHeader("X-Influx-Error", "not used") - .WithBody("{\"error\":\"partial write of line protocol occurred\",\"data\":[{\"error_message\":\"invalid column type for column 'v', expected iox::column_type::field::integer, got iox::column_type::field::float\",\"line_number\":2,\"original_line\":\"testa6a3ad v=1 17702\"}]}") + .WithBody("{\"error\":\"partial write of line protocol occurred\",\"data\":[{\"error_message\":\"invalid column type for column 'temp', expected iox::column_type::field::float, got iox::column_type::field::string\",\"line_number\":2,\"original_line\":\"home,room=Sunroom temp=hi 1735549200\"}]}") .WithStatusCode(400)); var ae = Assert.ThrowsAsync(async () => @@ -347,10 +347,10 @@ public void ErrorJsonBodyV3WithDataArray() Assert.That(ae, Is.Not.Null); Assert.That(ae.LineErrors, Has.Count.EqualTo(1)); Assert.That(ae.LineErrors[0].LineNumber, Is.EqualTo(2)); - Assert.That(ae.LineErrors[0].ErrorMessage, Is.EqualTo("invalid column type for column 'v', expected iox::column_type::field::integer, got iox::column_type::field::float")); - Assert.That(ae.LineErrors[0].OriginalLine, Is.EqualTo("testa6a3ad v=1 17702")); + Assert.That(ae.LineErrors[0].ErrorMessage, Is.EqualTo("invalid column type for column 'temp', expected iox::column_type::field::float, got iox::column_type::field::string")); + Assert.That(ae.LineErrors[0].OriginalLine, Is.EqualTo("home,room=Sunroom temp=hi 1735549200")); Assert.That(ae.HttpResponseMessage, Is.Not.Null); - Assert.That(ae.Message, Is.EqualTo("partial write of line protocol occurred:\n\tline 2: invalid column type for column 'v', expected iox::column_type::field::integer, got iox::column_type::field::float (testa6a3ad v=1 17702)")); + Assert.That(ae.Message, Is.EqualTo("partial write of line protocol occurred:\n\tline 2: invalid column type for column 'temp', expected iox::column_type::field::float, got iox::column_type::field::string (home,room=Sunroom temp=hi 1735549200)")); }); } @@ -389,7 +389,7 @@ public void ErrorJsonBodyV3ParsingFailedWriteLpWithDataObject() .Given(Request.Create().WithPath("/api").UsingPost()) .RespondWith(Response.Create() .WithHeader("Content-Type", "application/json") - .WithBody("{\"error\":\"parsing failed for write_lp endpoint\",\"data\":{\"error_message\":\"invalid field value\",\"line_number\":2,\"original_line\":\"m,t=a f=bad\"}}") + .WithBody("{\"error\":\"parsing failed for write_lp endpoint\",\"data\":{\"error_message\":\"invalid field value\",\"line_number\":2,\"original_line\":\"home,room=Sunroom temp=hi 1735549200\"}}") .WithStatusCode(400)); var ae = Assert.ThrowsAsync(async () => @@ -403,8 +403,8 @@ public void ErrorJsonBodyV3ParsingFailedWriteLpWithDataObject() Assert.That(ae.LineErrors, Has.Count.EqualTo(1)); Assert.That(ae.LineErrors[0].LineNumber, Is.EqualTo(2)); Assert.That(ae.LineErrors[0].ErrorMessage, Is.EqualTo("invalid field value")); - Assert.That(ae.LineErrors[0].OriginalLine, Is.EqualTo("m,t=a f=bad")); - Assert.That(ae.Message, Is.EqualTo("parsing failed for write_lp endpoint:\n\tline 2: invalid field value (m,t=a f=bad)")); + Assert.That(ae.LineErrors[0].OriginalLine, Is.EqualTo("home,room=Sunroom temp=hi 1735549200")); + Assert.That(ae.Message, Is.EqualTo("parsing failed for write_lp endpoint:\n\tline 2: invalid field value (home,room=Sunroom temp=hi 1735549200)")); }); } diff --git a/README.md b/README.md index 1ce3de7a..08af4ba8 100644 --- a/README.md +++ b/README.md @@ -120,6 +120,10 @@ using var client = new InfluxDBClient(new ClientConfig } }); +var lp = + "home,room=Sunroom temp=96 1735545600\n" + + "home,room=Sunroom temp=\"hi\" 1735549200"; + try { await client.WriteRecordAsync(lp); From 39a9c7029d47058a4e535c30f54dc7262b558ae5 Mon Sep 17 00:00:00 2001 From: Ales Pour Date: Fri, 15 May 2026 18:46:41 +0200 Subject: [PATCH 18/22] fix: property initializer with default value --- Client/Config/WriteOptions.cs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/Client/Config/WriteOptions.cs b/Client/Config/WriteOptions.cs index 756ae5b4..81ab386c 100644 --- a/Client/Config/WriteOptions.cs +++ b/Client/Config/WriteOptions.cs @@ -76,7 +76,7 @@ public class WriteOptions : ICloneable /// /// The threshold in bytes for gzipping the body. /// - public int GzipThreshold { get; set; } + public int GzipThreshold { get; set; } = 1000; /// /// Instructs the server whether to wait with the response until WAL persistence completes. From 54db8c950d85900fdf81370ab53a1594dcdd24df Mon Sep 17 00:00:00 2001 From: alespour <42931850+alespour@users.noreply.github.com> Date: Fri, 15 May 2026 19:06:11 +0200 Subject: [PATCH 19/22] docs: fix CHANGELOG entry Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com> --- CHANGELOG.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 3fc8c7d6..c01209d5 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,7 +2,7 @@ ### Features -1. [#258](https://github.com/InfluxCommunity/influxdb3-csharp/pull/258): Add partial writes support and default writes to the V2 API endpoint. +1. [#269](https://github.com/InfluxCommunity/influxdb3-csharp/pull/269): Add partial writes support and default writes to the V2 API endpoint. - `NoSync` requires `UseV2Api=false` and the V3 API endpoint. - `AcceptPartial` applies only when writes are sent to the V3 API endpoint and is ignored when using the V2 API endpoint. From 302dbd42221218adc4b905ca8b5daeb61016b6bc Mon Sep 17 00:00:00 2001 From: Ales Pour Date: Tue, 19 May 2026 11:20:32 +0200 Subject: [PATCH 20/22] fix: aligh quoting across all clients --- Client.Test/Internal/RestClientTest.cs | 2 +- Client/Internal/RestClient.cs | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/Client.Test/Internal/RestClientTest.cs b/Client.Test/Internal/RestClientTest.cs index 13ed160b..ecfdb289 100644 --- a/Client.Test/Internal/RestClientTest.cs +++ b/Client.Test/Internal/RestClientTest.cs @@ -374,7 +374,7 @@ public void ErrorJsonBodyV3WithDataArrayUntypedFallback() await _client.Request("api", HttpMethod.Post); }); - Assert.That(ae.Message, Is.EqualTo("partial write of line protocol occurred:\n\tbad line\n\ttrue\n\t3")); + Assert.That(ae.Message, Is.EqualTo("partial write of line protocol occurred:\n\t\"bad line\"\n\ttrue\n\t3")); } [Test] diff --git a/Client/Internal/RestClient.cs b/Client/Internal/RestClient.cs index de369b2d..3ed6217c 100644 --- a/Client/Internal/RestClient.cs +++ b/Client/Internal/RestClient.cs @@ -358,7 +358,7 @@ private static bool TryParseTypedLineError(JsonElement item, out InfluxDBPartial private static string ToDetailString(JsonElement element) { - return element.ValueKind == JsonValueKind.String ? (element.GetString() ?? "") : element.GetRawText(); + return element.GetRawText(); } private sealed class ParsedErrorMessage From 3fef6f79444e0861b636a235909ff9886a676aa4 Mon Sep 17 00:00:00 2001 From: Ales Pour Date: Tue, 19 May 2026 12:20:41 +0200 Subject: [PATCH 21/22] docs: align CHANGELOG entry across clients --- CHANGELOG.md | 1 + 1 file changed, 1 insertion(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index c01209d5..07fd69d6 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,6 +5,7 @@ 1. [#269](https://github.com/InfluxCommunity/influxdb3-csharp/pull/269): Add partial writes support and default writes to the V2 API endpoint. - `NoSync` requires `UseV2Api=false` and the V3 API endpoint. - `AcceptPartial` applies only when writes are sent to the V3 API endpoint and is ignored when using the V2 API endpoint. + See [Partial writes](https://docs.influxdata.com/influxdb3/core/write-data/http-api/v3-write-lp/#partial-writes) for more. ## 1.8.0 [2026-04-23] From e950f1ed4ce157ca42a5cd14507484e2e3bae14e Mon Sep 17 00:00:00 2001 From: Ales Pour Date: Tue, 19 May 2026 12:41:32 +0200 Subject: [PATCH 22/22] docs: fix letter case to be aligned with used terminology --- README.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/README.md b/README.md index 08af4ba8..dcd3a980 100644 --- a/README.md +++ b/README.md @@ -105,7 +105,7 @@ await client.WriteRecordAsync(record: record); #### Partial Writes -Use `WriteOptions.AcceptPartial` to control whether a v3 write can partially succeed when some lines fail. +Use `WriteOptions.AcceptPartial` to control whether a V3 write can partially succeed when some lines fail. Default is `true` (server default). When `false`, the full batch is rejected. ```csharp