Repository: benbjohnson/litestream

Issue:
Set X-Tigris-Consistent header to 'true' for Tigris endpoints in S3 replica client
## Summary

When using the S3 replica client with Tigris object storage, the `X-Tigris-Consistent` HTTP header should be set to `"true"` to ensure strong read-after-write consistency.

## Background

Tigris is an S3-compatible object storage service that offers strong consistency when the `X-Tigris-Consistent: true` header is included in requests. Without this header, Tigris may return eventually consistent results, which could cause issues during replication and restore operations.

## Current Behavior

Litestream already detects Tigris endpoints via `IsTigrisEndpoint()` and applies Tigris-specific defaults:
- `SignPayload` is set to `true`
- `RequireContentMD5` is set to `false`

However, the consistency header is not currently set.

## Proposed Change

Add middleware in `s3/replica_client.go` to set the `X-Tigris-Consistent: true` header on all requests when a Tigris endpoint is detected. This could be done in the `middlewareOption()` function alongside the existing User-Agent middleware.

## Implementation Notes

- The `isTigris` boolean is already computed in `NewReplicaClientFromURL()`
- A new field (e.g., `IsTigris bool`) could be added to `ReplicaClient` to track this
- The header should be added in the Build middleware phase, similar to the User-Agent header

## References

- Tigris consistency documentation: https://www.tigrisdata.com/docs/concepts/consistency/
- Existing Tigris handling: `s3/replica_client.go:157-188`

Discussion:

Solution patch:
diff --git a/s3/replica_client.go b/s3/replica_client.go
index 7236865..61b89b2 100644
--- a/s3/replica_client.go
+++ b/s3/replica_client.go
@@ -80,6 +80,7 @@ type ReplicaClient struct {
 	SkipVerify        bool
 	SignPayload       bool
 	RequireContentMD5 bool
+	IsTigris          bool
 
 	// Upload configuration
 	PartSize    int64 // Part size for multipart uploads (default: 5MB)
@@ -181,6 +182,7 @@ func NewReplicaClientFromURL(scheme, host, urlPath string, query url.Values, use
 	// Apply provider-specific defaults for S3-compatible providers.
 	if isTigris {
 		// Tigris: requires signed payloads, no MD5
+		client.IsTigris = true
 		if !signPayloadSet {
 			signPayload, signPayloadSet = true, true
 		}
@@ -561,6 +563,25 @@ func (c *ReplicaClient) middlewareOption() func(*middleware.Stack) error {
 			return err
 		}
 
+		if c.IsTigris {
+			if err := stack.Build.Add(
+				middleware.BuildMiddlewareFunc(
+					"LitestreamTigrisConsistent",
+					func(ctx context.Context, in middleware.BuildInput, next middleware.BuildHandler) (
+						out middleware.BuildOutput, metadata middleware.Metadata, err error,
+					) {
+						if req, ok := in.Request.(*smithyhttp.Request); ok {
+							req.Header.Set("X-Tigris-Consistent", "true")
+						}
+						return next.HandleBuild(ctx, in)
+					},
+				),
+				middleware.After,
+			); err != nil {
+				return err
+			}
+		}
+
 		// Many S3-compatible providers (e.g. Filebase) do not support SigV4
 		// payload hashing. Switching to unsigned payload matches the behavior
 		// of the AWS SDK v1 client used in Litestream v0.3.x and restores



Test patch:
diff --git a/cmd/litestream/main.go b/cmd/litestream/main.go
index 17b8bca..fb1edb9 100644
--- a/cmd/litestream/main.go
+++ b/cmd/litestream/main.go
@@ -1307,6 +1307,7 @@ func NewS3ReplicaClientFromConfig(c *ReplicaConfig, _ *litestream.Replica) (_ *s
 
 	client.SignPayload = signSetting.value
 	client.RequireContentMD5 = requireSetting.value
+	client.IsTigris = isTigris
 
 	// Apply upload configuration if specified.
 	if c.PartSize != nil {
diff --git a/s3/replica_client_test.go b/s3/replica_client_test.go
index 254e13e..baac7af 100644
--- a/s3/replica_client_test.go
+++ b/s3/replica_client_test.go
@@ -1006,3 +1006,68 @@ func TestParseHost(t *testing.T) {
 		})
 	}
 }
+
+func TestReplicaClient_TigrisConsistentHeader(t *testing.T) {
+	data := mustLTX(t)
+
+	tests := []struct {
+		name       string
+		isTigris   bool
+		wantHeader string
+	}{
+		{name: "TigrisEnabled", isTigris: true, wantHeader: "true"},
+		{name: "TigrisDisabled", isTigris: false, wantHeader: ""},
+	}
+
+	for _, tt := range tests {
+		t.Run(tt.name, func(t *testing.T) {
+			headers := make(chan http.Header, 1)
+			server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
+				defer r.Body.Close()
+				_, _ = io.Copy(io.Discard, r.Body)
+
+				if r.Method == http.MethodPut {
+					select {
+					case headers <- r.Header.Clone():
+					default:
+					}
+					w.Header().Set("ETag", `"test-etag"`)
+					w.WriteHeader(http.StatusOK)
+					return
+				}
+
+				w.WriteHeader(http.StatusOK)
+			}))
+			defer server.Close()
+
+			client := NewReplicaClient()
+			client.Bucket = "test-bucket"
+			client.Path = "replica"
+			client.Region = "us-east-1"
+			client.Endpoint = server.URL
+			client.ForcePathStyle = true
+			client.AccessKeyID = "test-access-key"
+			client.SecretAccessKey = "test-secret-key"
+			client.IsTigris = tt.isTigris
+
+			ctx := context.Background()
+			if err := client.Init(ctx); err != nil {
+				t.Fatalf("Init() error: %v", err)
+			}
+
+			if _, err := client.WriteLTXFile(ctx, 0, 2, 2, bytes.NewReader(data)); err != nil {
+				t.Fatalf("WriteLTXFile() error: %v", err)
+			}
+
+			select {
+			case hdr := <-headers:
+				got := hdr.Get("X-Tigris-Consistent")
+				if got != tt.wantHeader {
+					t.Fatalf("X-Tigris-Consistent header = %q, want %q", got, tt.wantHeader)
+				}
+			case <-time.After(time.Second):
+				t.Fatal("timeout waiting for PUT request")
+			}
+		})
+	}
+}