From 291c0555d0abbb80d863a7b81e6c0b4f1fd5db07 Mon Sep 17 00:00:00 2001 From: Ashutosh-jarvis Date: Wed, 28 Oct 2026 06:23:18 +0000 Subject: [PATCH] Fix IndexOutOfBoundsException in SalesforceWideRecordReader when bulk query returns empty results Surface HTTP status and readable error message for non-JSON Salesforce OAuth responses --- .../authenticator/Authenticator.java | 77 ++++++++++++++++++- .../batch/SalesforceWideRecordReader.java | 8 +- .../batch/SalesforceWideRecordReaderTest.java | 47 +++++++++++ 3 files changed, 128 insertions(+), 4 deletions(-) diff --git a/src/main/java/io/cdap/plugin/salesforce/authenticator/Authenticator.java b/src/main/java/io/cdap/plugin/salesforce/authenticator/Authenticator.java index 8bbf280c..91b52a9b 100644 --- a/src/main/java/io/cdap/plugin/salesforce/authenticator/Authenticator.java +++ b/src/main/java/io/cdap/plugin/salesforce/authenticator/Authenticator.java @@ -18,6 +18,7 @@ import com.google.common.base.Strings; import com.google.gson.Gson; +import com.google.gson.JsonSyntaxException; import com.sforce.ws.ConnectorConfig; import io.cdap.plugin.salesforce.SalesforceConnectionUtil; import io.cdap.plugin.salesforce.SalesforceConstants; @@ -25,17 +26,28 @@ import org.eclipse.jetty.client.HttpClient; import org.eclipse.jetty.client.HttpProxy; import org.eclipse.jetty.client.ProxyConfiguration; +import org.eclipse.jetty.client.api.ContentResponse; import org.eclipse.jetty.client.api.Request; import org.eclipse.jetty.util.ssl.SslContextFactory; import java.net.URI; import java.net.URISyntaxException; +import java.util.regex.Matcher; +import java.util.regex.Pattern; /** * Authentication to Salesforce via oauth2 */ public class Authenticator { private static final Gson GSON = new Gson(); + /** + * Matches an HTML {@code
...
} element returned + * by Salesforce HTML login/error pages instead of JSON, capturing its inner + * content across multiple lines (DOTALL) regardless of tag case. + */ + private static final Pattern HTML_ERROR_PATTERN = + Pattern.compile("]*\\bid=['\"]?error['\"]?[^>]*>(.*?)", + Pattern.DOTALL | Pattern.CASE_INSENSITIVE); /** * Authenticates via oauth2 to salesforce and returns a connectorConfig @@ -114,8 +126,30 @@ public static OAuthInfo getOAuthInfo(AuthenticatorCredentials credentials) throw break; } - String response = authRequest.send().getContentAsString(); - AuthResponse authResponse = GSON.fromJson(response, AuthResponse.class); + ContentResponse httpResponse = authRequest.send(); + int statusCode = httpResponse.getStatus(); + String reason = httpResponse.getReason(); + String response = httpResponse.getContentAsString(); + AuthResponse authResponse; + + try { + authResponse = GSON.fromJson(response, AuthResponse.class); + } catch (JsonSyntaxException e) { + throw new IllegalStateException( + String.format( + "Cannot authenticate to Salesforce (HTTP %d %s). %s", + statusCode, + Strings.nullToEmpty(reason), + extractErrorFromResponse(response)), + e); + } + + if (authResponse == null) { + throw new IllegalStateException( + String.format( + "Empty OAuth response from Salesforce (HTTP %d %s).", + statusCode, Strings.nullToEmpty(reason))); + } if (!Strings.isNullOrEmpty(authResponse.getError())) { throw new IllegalArgumentException( @@ -150,4 +184,43 @@ public static void setProxy(AuthenticatorCredentials credentials, HttpClient htt "given proxy server details. Error : %s", e.getMessage()), e); } } + + /** + * Extracts a concise, human-readable error message from a non-JSON Salesforce + * OAuth response instead of embedding the entire raw HTML page in the + * exception message. + * + *

It searches the response for an HTML error container matching + * {@link #HTML_ERROR_PATTERN}, strips any nested HTML tags from its inner + * content, and trims surrounding whitespace. If no non-empty error container + * is found, it returns a generic authentication error message. + * + *

Example: + *

{@code
+   * // 1. Input HTML containing an id="error" div:
+   * "
Error: Enter username.
" + * // Returns: "Error: Enter username." + * + * // 2. Input HTML without an id="error" div (e.g., 503 maintenance page): + * "

503 Service Unavailable

" + * // Returns: "Error while authenticating to Salesforce with given credentials." + * }
+ * + * @param response raw HTTP response body returned by Salesforce OAuth + * @return extracted plain-text error message, or a default error message + */ + private static String extractErrorFromResponse(String response) { + if (!Strings.isNullOrEmpty(response)) { + Matcher matcher = HTML_ERROR_PATTERN.matcher(response); + if (matcher.find()) { + // Strip any nested HTML tags inside the
...
block (e.g., + // , ). + String error = matcher.group(1).replaceAll("<[^>]+>", "").trim(); + if (!error.isEmpty()) { + return error; + } + } + } + return "Error while authenticating to Salesforce with given credentials."; + } } diff --git a/src/main/java/io/cdap/plugin/salesforce/plugin/source/batch/SalesforceWideRecordReader.java b/src/main/java/io/cdap/plugin/salesforce/plugin/source/batch/SalesforceWideRecordReader.java index 2299fa6d..2d69c3eb 100644 --- a/src/main/java/io/cdap/plugin/salesforce/plugin/source/batch/SalesforceWideRecordReader.java +++ b/src/main/java/io/cdap/plugin/salesforce/plugin/source/batch/SalesforceWideRecordReader.java @@ -34,6 +34,7 @@ import java.util.ArrayList; import java.util.Arrays; import java.util.Collection; +import java.util.Collections; import java.util.Iterator; import java.util.List; import java.util.Map; @@ -106,8 +107,11 @@ public SalesforceWideRecordReader initialize( batchIterator = partitions.iterator(); - // initialize the 1st batch - results = fetchBatchRecords(); + if (batchIterator.hasNext()) { + results = fetchBatchRecords(); + } else { + results = Collections.emptyList(); + } return this; } diff --git a/src/test/java/io/cdap/plugin/salesforce/plugin/source/batch/SalesforceWideRecordReaderTest.java b/src/test/java/io/cdap/plugin/salesforce/plugin/source/batch/SalesforceWideRecordReaderTest.java index 35a24c38..a3021783 100644 --- a/src/test/java/io/cdap/plugin/salesforce/plugin/source/batch/SalesforceWideRecordReaderTest.java +++ b/src/test/java/io/cdap/plugin/salesforce/plugin/source/batch/SalesforceWideRecordReaderTest.java @@ -16,11 +16,17 @@ package io.cdap.plugin.salesforce.plugin.source.batch; +import com.sun.net.httpserver.HttpServer; import io.cdap.cdap.api.data.schema.Schema; +import io.cdap.plugin.salesforce.authenticator.AuthenticatorCredentials; +import io.cdap.plugin.salesforce.plugin.OAuthInfo; import org.apache.hadoop.mapreduce.InputSplit; import org.apache.hadoop.mapreduce.TaskAttemptContext; import org.junit.Test; import org.mockito.internal.util.reflection.FieldSetter; + +import java.net.InetSocketAddress; +import java.nio.charset.StandardCharsets; import java.util.ArrayList; import java.util.Arrays; import java.util.Collections; @@ -31,6 +37,8 @@ import java.util.concurrent.atomic.AtomicInteger; import static org.junit.Assert.assertEquals; +import static org.junit.Assert.assertFalse; +import static org.junit.Assert.assertNull; import static org.mockito.ArgumentMatchers.any; import static org.mockito.Mockito.doAnswer; import static org.mockito.Mockito.doNothing; @@ -106,4 +114,43 @@ public void testWideRecordReader_fetchBatchRecordsCalledExpectedNumberOfTimes() verify(reader, times(4)).fetchBatchRecords(); assertEquals(3, readRecords.size()); } + + @Test + public void nextKeyValue_withEmptyBatch_returnsFalse() throws Exception { + HttpServer server = HttpServer.create(new InetSocketAddress(0), 0); + server.createContext("/services/async/", exchange -> { + String response = exchange.getRequestURI().getPath().endsWith("/result") + ? "res1" + : "" + + "batch1job1Completed"; + byte[] bytes = response.getBytes(StandardCharsets.UTF_8); + exchange.getResponseHeaders().add("Content-Type", "application/xml"); + exchange.sendResponseHeaders(200, bytes.length); + exchange.getResponseBody().write(bytes); + exchange.close(); + }); + server.start(); + + try { + Schema schema = Schema.recordOf("test", Schema.Field.of("Id", Schema.of(Schema.Type.STRING))); + SoapRecordToMapTransformer transformer = mock(SoapRecordToMapTransformer.class); + SalesforceWideRecordReader reader = spy(new SalesforceWideRecordReader( + schema, "SELECT Id FROM Account", transformer)); + doNothing().when(reader).setupParser(); + SalesforceSplit split = new SalesforceSplit("job1", "batch1", "SELECT Id FROM Account"); + OAuthInfo oAuthInfo = new OAuthInfo("token", "http://localhost:" + server.getAddress().getPort()); + AuthenticatorCredentials credentials = AuthenticatorCredentials.fromParameters( + oAuthInfo, 5000, 5000, null, 1L, 1L, 1, false); + + reader.initialize(split, credentials); + boolean hasNext = reader.nextKeyValue(); + + assertFalse(hasNext); + assertEquals(0.0f, reader.getProgress(), 0.0001); + assertNull(reader.getCurrentValue()); + verify(reader, times(0)).fetchBatchRecords(); + } finally { + server.stop(0); + } + } }