This is an automated email from the ASF dual-hosted git repository.

exceptionfactory pushed a commit to branch main
in repository https://gitbox.apache.org/repos/asf/nifi.git


The following commit(s) were added to refs/heads/main by this push:
     new eee7824b3e3 NIFI-16537 Fixed BitbucketFlowRegistryClient failing to 
commit large flow to Bitbucket Cloud (#11876)
eee7824b3e3 is described below

commit eee7824b3e3b81b9db6cd6c0e3480356c46727f5
Author: Pierre Villard <[email protected]>
AuthorDate: Mon Nov 2 22:25:34 2026 +0100

    NIFI-16537 Fixed BitbucketFlowRegistryClient failing to commit large flow 
to Bitbucket Cloud (#11876)
    
    Signed-off-by: David Handermann <[email protected]>
---
 .../bitbucket/BitbucketRepositoryClient.java       | 41 ++++++++++++----
 .../bitbucket/BitbucketRepositoryClientTest.java   | 55 ++++++++++++++++++----
 2 files changed, 77 insertions(+), 19 deletions(-)

diff --git 
a/nifi-extension-bundles/nifi-atlassian-bundle/nifi-atlassian-extensions/src/main/java/org/apache/nifi/atlassian/bitbucket/BitbucketRepositoryClient.java
 
b/nifi-extension-bundles/nifi-atlassian-bundle/nifi-atlassian-extensions/src/main/java/org/apache/nifi/atlassian/bitbucket/BitbucketRepositoryClient.java
index f5e2a9b0308..23f071b3ebf 100644
--- 
a/nifi-extension-bundles/nifi-atlassian-bundle/nifi-atlassian-extensions/src/main/java/org/apache/nifi/atlassian/bitbucket/BitbucketRepositoryClient.java
+++ 
b/nifi-extension-bundles/nifi-atlassian-bundle/nifi-atlassian-extensions/src/main/java/org/apache/nifi/atlassian/bitbucket/BitbucketRepositoryClient.java
@@ -26,6 +26,7 @@ import org.apache.nifi.registry.flow.FlowRegistryException;
 import org.apache.nifi.registry.flow.git.client.GitCommit;
 import org.apache.nifi.registry.flow.git.client.GitCreateContentRequest;
 import org.apache.nifi.registry.flow.git.client.GitRepositoryClient;
+import org.apache.nifi.stream.io.LimitingInputStream;
 import org.apache.nifi.web.client.api.HttpResponseEntity;
 import org.apache.nifi.web.client.api.HttpUriBuilder;
 import org.apache.nifi.web.client.api.StandardHttpContentType;
@@ -103,6 +104,8 @@ public class BitbucketRepositoryClient implements 
GitRepositoryClient {
     private static final String ENTRY_FILE_DATA_CENTER = "FILE";
     private static final String ENTRY_FILE_CLOUD = "commit_file";
     private static final int MAX_PUSH_ATTEMPTS = 3;
+    private static final int MAX_ERROR_RESPONSE_LENGTH = 512;
+    private static final String UNKNOWN_CONTENT_TYPE = "unknown";
 
     private final ObjectMapper objectMapper = JsonMapper.builder().build();
 
@@ -433,7 +436,7 @@ public class BitbucketRepositoryClient implements 
GitRepositoryClient {
             final String branchHead = getBranchHeadCloud(branch);
 
             final StandardMultipartFormDataStreamBuilder multipartBuilder = 
new StandardMultipartFormDataStreamBuilder();
-            multipartBuilder.addPart(resolvedPath, 
StandardHttpContentType.APPLICATION_JSON, 
request.getContent().getBytes(StandardCharsets.UTF_8));
+            multipartBuilder.addPart(resolvedPath, getFileName(resolvedPath), 
StandardHttpContentType.APPLICATION_JSON, 
request.getContent().getBytes(StandardCharsets.UTF_8));
             multipartBuilder.addPart(FIELD_MESSAGE, 
StandardHttpContentType.TEXT_PLAIN, 
request.getMessage().getBytes(StandardCharsets.UTF_8));
             multipartBuilder.addPart(FIELD_BRANCH, 
StandardHttpContentType.TEXT_PLAIN, branch.getBytes(StandardCharsets.UTF_8));
             multipartBuilder.addPart(FIELD_PARENTS, 
StandardHttpContentType.TEXT_PLAIN, 
branchHead.getBytes(StandardCharsets.UTF_8));
@@ -445,10 +448,11 @@ public class BitbucketRepositoryClient implements 
GitRepositoryClient {
                 multipartBuilder.addPart(FIELD_AUTHOR, 
StandardHttpContentType.TEXT_PLAIN, 
authorValue.getBytes(StandardCharsets.UTF_8));
             }
 
+            final byte[] requestBody = toByteArray(multipartBuilder.build());
             final HttpResponseEntity response = 
this.webClient.getWebClientService()
                     .post()
                     .uri(uri)
-                    .body(multipartBuilder.build(), OptionalLong.empty())
+                    .body(new ByteArrayInputStream(requestBody), 
OptionalLong.of(requestBody.length))
                     .header(AUTHORIZATION_HEADER, 
authToken.getAuthzHeaderValue())
                     .header(CONTENT_TYPE_HEADER, 
multipartBuilder.getHttpContentType().getContentType())
                     .retrieve();
@@ -639,10 +643,11 @@ public class BitbucketRepositoryClient implements 
GitRepositoryClient {
         final URI uri = 
getRepositoryUriBuilder().addPathSegment("src").build();
         final String errorMessage = "Error while deleting content for 
repository [%s] on branch %s at path %s"
                 .formatted(repoName, branch, resolvedPath);
+        final byte[] requestBody = toByteArray(multipartBuilder.build());
         try (final HttpResponseEntity response = 
this.webClient.getWebClientService()
                 .post()
                 .uri(uri)
-                .body(multipartBuilder.build(), OptionalLong.empty())
+                .body(new ByteArrayInputStream(requestBody), 
OptionalLong.of(requestBody.length))
                 .header(AUTHORIZATION_HEADER, authToken.getAuthzHeaderValue())
                 .header(CONTENT_TYPE_HEADER, 
multipartBuilder.getHttpContentType().getContentType())
                 .retrieve()) {
@@ -1066,15 +1071,26 @@ public class BitbucketRepositoryClient implements 
GitRepositoryClient {
         }
     }
 
-    private String getErrorMessage(HttpResponseEntity response) throws 
FlowRegistryException {
+    private String getErrorMessage(final HttpResponseEntity response) throws 
FlowRegistryException {
+        final byte[] responseBody;
+        try (InputStream body = response.body()) {
+            responseBody = body == null ? new byte[0] : new 
LimitingInputStream(body, MAX_ERROR_RESPONSE_LENGTH).readAllBytes();
+        } catch (final IOException e) {
+            throw new FlowRegistryException("Failed to read Bitbucket API 
response with HTTP status %d".formatted(response.statusCode()), e);
+        }
+
+        if (responseBody.length == 0) {
+            return "Empty response from Bitbucket API [%s] with HTTP status 
%d".formatted(response.uri(), response.statusCode());
+        }
+
         final JsonNode jsonResponse;
         try {
-            jsonResponse = this.objectMapper.readTree(response.body());
-        } catch (IOException e) {
-            throw new FlowRegistryException("Could not parse response from 
Bitbucket API", e);
+            jsonResponse = this.objectMapper.readTree(responseBody);
+        } catch (final IOException e) {
+            return getNonJsonErrorMessage(response, responseBody);
         }
-        if (jsonResponse == null) {
-            return "Unknown error";
+        if (jsonResponse == null || jsonResponse.isMissingNode()) {
+            return getNonJsonErrorMessage(response, responseBody);
         }
 
         final JsonNode errorNode = jsonResponse.get(FIELD_ERROR_MESSAGE);
@@ -1098,6 +1114,13 @@ public class BitbucketRepositoryClient implements 
GitRepositoryClient {
         return jsonResponse.toString();
     }
 
+    private String getNonJsonErrorMessage(final HttpResponseEntity response, 
final byte[] responseBody) {
+        final String contentType = 
response.headers().getFirstHeader(CONTENT_TYPE_HEADER).orElse(UNKNOWN_CONTENT_TYPE);
+        final String body = new String(responseBody, 
StandardCharsets.UTF_8).strip().replaceAll("\\s+", " ");
+        return "Non-JSON response from Bitbucket API [%s] with HTTP status %d, 
Content-Type [%s], body [%s]"
+                .formatted(response.uri(), response.statusCode(), contentType, 
body);
+    }
+
     private JsonNode parseResponseBody(final HttpResponseEntity response, 
final URI uri) throws FlowRegistryException {
         try {
             return objectMapper.readTree(response.body());
diff --git 
a/nifi-extension-bundles/nifi-atlassian-bundle/nifi-atlassian-extensions/src/test/java/org/apache/nifi/atlassian/bitbucket/BitbucketRepositoryClientTest.java
 
b/nifi-extension-bundles/nifi-atlassian-bundle/nifi-atlassian-extensions/src/test/java/org/apache/nifi/atlassian/bitbucket/BitbucketRepositoryClientTest.java
index 8129d237ca1..d6d748b5854 100644
--- 
a/nifi-extension-bundles/nifi-atlassian-bundle/nifi-atlassian-extensions/src/test/java/org/apache/nifi/atlassian/bitbucket/BitbucketRepositoryClientTest.java
+++ 
b/nifi-extension-bundles/nifi-atlassian-bundle/nifi-atlassian-extensions/src/test/java/org/apache/nifi/atlassian/bitbucket/BitbucketRepositoryClientTest.java
@@ -20,6 +20,7 @@ package org.apache.nifi.atlassian.bitbucket;
 import org.apache.nifi.logging.ComponentLog;
 import org.apache.nifi.registry.flow.FlowRegistryException;
 import org.apache.nifi.registry.flow.git.client.GitCreateContentRequest;
+import org.apache.nifi.web.client.api.HttpEntityHeaders;
 import org.apache.nifi.web.client.api.HttpRequestBodySpec;
 import org.apache.nifi.web.client.api.HttpRequestHeadersSpec;
 import org.apache.nifi.web.client.api.HttpRequestUriSpec;
@@ -30,6 +31,10 @@ import 
org.apache.nifi.web.client.provider.api.WebClientServiceProvider;
 import org.junit.jupiter.api.BeforeEach;
 import org.junit.jupiter.api.Test;
 import org.junit.jupiter.api.extension.ExtendWith;
+import org.junit.jupiter.params.ParameterizedTest;
+import org.junit.jupiter.params.provider.Arguments;
+import org.junit.jupiter.params.provider.MethodSource;
+import org.mockito.ArgumentCaptor;
 import org.mockito.Mock;
 import org.mockito.junit.jupiter.MockitoExtension;
 import org.mockito.stubbing.OngoingStubbing;
@@ -43,14 +48,17 @@ import java.nio.charset.StandardCharsets;
 import java.util.Optional;
 import java.util.OptionalLong;
 import java.util.Set;
+import java.util.stream.Stream;
 
 import static org.junit.jupiter.api.Assertions.assertEquals;
+import static org.junit.jupiter.api.Assertions.assertFalse;
 import static org.junit.jupiter.api.Assertions.assertThrows;
 import static org.junit.jupiter.api.Assertions.assertTrue;
 import static org.mockito.ArgumentMatchers.any;
 import static org.mockito.ArgumentMatchers.anyString;
 import static org.mockito.Mockito.lenient;
 import static org.mockito.Mockito.mock;
+import static org.mockito.Mockito.verify;
 import static org.mockito.Mockito.when;
 
 @ExtendWith(MockitoExtension.class)
@@ -78,6 +86,8 @@ class BitbucketRepositoryClientTest {
     private HttpRequestBodySpec getBodySpec;
     private HttpRequestBodySpec postAfterHeaders;
 
+    private HttpRequestBodySpec postBodySpec;
+
     @BeforeEach
     void setUp() {
         
lenient().when(webClientProvider.getWebClientService()).thenReturn(webClientService);
@@ -124,7 +134,7 @@ class BitbucketRepositoryClientTest {
 
     private void stubPostChain(final HttpResponseEntity... responses) {
         final HttpRequestUriSpec postSpec = mock(HttpRequestUriSpec.class);
-        final HttpRequestBodySpec postBodySpec = 
mock(HttpRequestBodySpec.class);
+        postBodySpec = mock(HttpRequestBodySpec.class);
         final HttpRequestHeadersSpec afterBody = 
mock(HttpRequestHeadersSpec.class);
         postAfterHeaders = mock(HttpRequestBodySpec.class);
         lenient().when(webClientService.post()).thenReturn(postSpec);
@@ -147,7 +157,14 @@ class BitbucketRepositoryClientTest {
     }
 
     private HttpResponseEntity mockResponse(final int statusCode, final String 
body) {
+        return mockResponse(statusCode, body, "application/json");
+    }
+
+    private HttpResponseEntity mockResponse(final int statusCode, final String 
body, final String contentType) {
         final HttpResponseEntity response = mock(HttpResponseEntity.class);
+        final HttpEntityHeaders headers = mock(HttpEntityHeaders.class);
+        
lenient().when(headers.getFirstHeader("Content-Type")).thenReturn(Optional.of(contentType));
+        lenient().when(response.headers()).thenReturn(headers);
         lenient().when(response.statusCode()).thenReturn(statusCode);
         lenient().when(response.body()).thenReturn(new 
ByteArrayInputStream(body.getBytes(StandardCharsets.UTF_8)));
         return response;
@@ -173,10 +190,6 @@ class BitbucketRepositoryClientTest {
         return mockResponse(HttpURLConnection.HTTP_CONFLICT, 
"{\"error\":{\"message\":\"conflict\"}}");
     }
 
-    private HttpResponseEntity serverErrorResponse() {
-        return mockResponse(HttpURLConnection.HTTP_INTERNAL_ERROR, 
"{\"error\":{\"message\":\"internal error\"}}");
-    }
-
     private HttpResponseEntity createdResponse() {
         return mockResponse(HttpURLConnection.HTTP_CREATED, "{}");
     }
@@ -197,7 +210,7 @@ class BitbucketRepositoryClientTest {
     }
 
     @Test
-    void testCreateContentCloudSuccess() throws FlowRegistryException {
+    void testCreateContentCloudSuccess() throws FlowRegistryException, 
IOException {
         stubGetChain(
                 branchListResponse(),
                 branchHeadResponse(BRANCH_HEAD_SHA),
@@ -209,6 +222,14 @@ class BitbucketRepositoryClientTest {
         final BitbucketRepositoryClient client = buildCloudClient();
         final String commitSha = 
client.createContent(createRequest("existing-sha", null));
         assertEquals(RESULT_COMMIT_SHA, commitSha);
+
+        final ArgumentCaptor<InputStream> requestBody = 
ArgumentCaptor.forClass(InputStream.class);
+        final ArgumentCaptor<OptionalLong> contentLength = 
ArgumentCaptor.forClass(OptionalLong.class);
+        verify(postBodySpec).body(requestBody.capture(), 
contentLength.capture());
+        assertTrue(contentLength.getValue().isPresent());
+        assertTrue(contentLength.getValue().getAsLong() > CONTENT.length());
+        final String multipartBody = new 
String(requestBody.getValue().readAllBytes(), StandardCharsets.UTF_8);
+        assertTrue(multipartBody.contains("name=\"%s\"; 
filename=\"test.json\"".formatted(FILE_PATH)));
     }
 
     @Test
@@ -257,17 +278,31 @@ class BitbucketRepositoryClientTest {
         assertTrue(exception.getMessage().contains("Push failed after 3 
attempts"));
     }
 
-    @Test
-    void testCreateContentCloudNon409ErrorNotRetried() throws 
FlowRegistryException {
+    @ParameterizedTest
+    @MethodSource("nonJsonErrorResponses")
+    void testCreateContentCloudNon409ErrorNotRetried(final String 
responseBody, final String expectedMessage, final String unexpectedMessage)
+            throws FlowRegistryException {
         stubGetChain(
                 branchListResponse(),
                 branchHeadResponse(BRANCH_HEAD_SHA)
         );
-        stubPostChain(serverErrorResponse());
+        stubPostChain(mockResponse(HttpURLConnection.HTTP_BAD_REQUEST, 
responseBody, "text/html"));
 
         final BitbucketRepositoryClient client = buildCloudClient();
-        assertThrows(FlowRegistryException.class,
+        final FlowRegistryException exception = 
assertThrows(FlowRegistryException.class,
                 () -> client.createContent(createRequest("existing-sha", 
null)));
+        assertTrue(exception.getMessage().contains("HTTP status 400"));
+        assertTrue(exception.getMessage().contains(expectedMessage));
+        assertFalse(exception.getMessage().contains(unexpectedMessage));
+    }
+
+    private static Stream<Arguments> nonJsonErrorResponses() {
+        final String truncatedBody = "<html>" + "a".repeat(506);
+        return Stream.of(
+                Arguments.of("\n<html><body>Bad Request (400)</body></html>", 
"Content-Type [text/html], body [<html><body>Bad Request (400)</body></html>]", 
"Empty response"),
+                Arguments.of("", "Empty response from Bitbucket API", 
"Non-JSON response"),
+                Arguments.of(truncatedBody + "TRUNCATED", "body 
[%s]".formatted(truncatedBody), "TRUNCATED")
+        );
     }
 
     @Test

Reply via email to