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

dominikriemer pushed a commit to branch dev
in repository https://gitbox.apache.org/repos/asf/streampipes.git


The following commit(s) were added to refs/heads/dev by this push:
     new 6b0c0bdfe5 #4683 fix(msteams): migrate sink to Power Automate workflow 
format (#4684)
6b0c0bdfe5 is described below

commit 6b0c0bdfe5db494a47f2c360a35442c98da22931
Author: Stefan Obermeier <[email protected]>
AuthorDate: Thu Jul 16 21:38:29 2026 +0200

    #4683 fix(msteams): migrate sink to Power Automate workflow format (#4684)
    
    Co-authored-by: obermeier <[email protected]>
    Co-authored-by: Dominik Riemer <[email protected]>
---
 .../notifications/jvm/msteams/MSTeamsSink.java     | 518 +++++++++++----------
 .../documentation.md                               |  21 +-
 .../notifications/jvm/msteams/TestMSTeamsSink.java | 230 +++++----
 3 files changed, 428 insertions(+), 341 deletions(-)

diff --git 
a/streampipes-extensions/streampipes-sinks-notifications-jvm/src/main/java/org/apache/streampipes/sinks/notifications/jvm/msteams/MSTeamsSink.java
 
b/streampipes-extensions/streampipes-sinks-notifications-jvm/src/main/java/org/apache/streampipes/sinks/notifications/jvm/msteams/MSTeamsSink.java
index e62cf725e8..86e0469da6 100644
--- 
a/streampipes-extensions/streampipes-sinks-notifications-jvm/src/main/java/org/apache/streampipes/sinks/notifications/jvm/msteams/MSTeamsSink.java
+++ 
b/streampipes-extensions/streampipes-sinks-notifications-jvm/src/main/java/org/apache/streampipes/sinks/notifications/jvm/msteams/MSTeamsSink.java
@@ -40,285 +40,309 @@ import 
org.apache.streampipes.wrapper.standalone.StreamPipesNotificationSink;
 import com.fasterxml.jackson.core.JsonProcessingException;
 import com.fasterxml.jackson.databind.DeserializationFeature;
 import com.fasterxml.jackson.databind.ObjectMapper;
-import org.apache.http.Header;
 import org.apache.http.HttpHost;
+import org.apache.http.client.config.RequestConfig;
 import org.apache.http.client.methods.CloseableHttpResponse;
 import org.apache.http.client.methods.HttpPost;
 import org.apache.http.entity.ContentType;
 import org.apache.http.entity.StringEntity;
 import org.apache.http.impl.client.CloseableHttpClient;
 import org.apache.http.impl.client.HttpClientBuilder;
-import org.apache.http.impl.client.HttpClients;
+import org.slf4j.Logger;
+import org.slf4j.LoggerFactory;
 
 import java.io.IOException;
-import java.net.MalformedURLException;
-import java.net.URL;
-import java.time.Duration;
+import java.net.URI;
+import java.net.URISyntaxException;
 import java.util.Map;
 
 public class MSTeamsSink extends StreamPipesNotificationSink {
 
-  public static final String ID = 
"org.apache.streampipes.sinks.notifications.jvm.msteams";
-
-  private static final String KEY_MESSAGE_ADVANCED = "messageAdvanced";
-  private static final String KEY_MESSAGE_ADVANCED_CONTENT = 
"messageContentAdvanced";
-  private static final String KEY_MESSAGE_SIMPLE = "messageSimple";
-  private static final String KEY_MESSAGE_SIMPLE_CONTENT = 
"messageContentSimple";
-  private static final String KEY_MESSAGE_TYPE_ALTERNATIVES = "messageType";
-  private static final String KEY_WEBHOOK_URL = "webhookUrl";
-  public static final String KEY_PROXY_ALTERNATIVES = "proxy";
-  public static final String KEY_PROXY_DISABLED = "proxyDisabled";
-  public static final String KEY_PROXY_ENABLED = "proxyEnabled";
-  public static final String KEY_PROXY_GROUP = "proxyConfigurationGroup";
-  public static final String KEY_PROXY_URL = "proxyUrl";
-  protected static final String SIMPLE_MESSAGE_TEMPLATE = "{\"text\": \"%s\"}";
-  private static final int MAX_RETRIES = 5;
-  private static final int HTTP_TOO_MANY_REQUESTS = 429;
-  private static final Duration BASE_BACKOFF = Duration.ofSeconds(1);
-
-  private String messageContent;
-  private boolean isSimpleMessageMode;
-  private String webhookUrl;
-  private ObjectMapper objectMapper;
-  private CloseableHttpClient httpClient;
-
-  public MSTeamsSink() {
-    super();
-    this.objectMapper = JacksonSerializer.getObjectMapper(Map.of(
-      DeserializationFeature.FAIL_ON_UNKNOWN_PROPERTIES, true
-    ));
-  }
-
-  @Override
-  public IDataSinkConfiguration declareConfig() {
-    var builder = declareModelWithoutSilentPeriod();
-    addSilentPeriodParameter(builder);
-
-    return DataSinkConfiguration.create(
-        MSTeamsSink::new,
-        builder.build()
-    );
-  }
-
-  @Override
-  public void onPipelineStarted(
-      IDataSinkParameters parameters,
-      EventSinkRuntimeContext runtimeContext
-  ) {
-    super.onPipelineStarted(parameters, runtimeContext);
-
-    this.objectMapper = JacksonSerializer.getObjectMapper(Map.of(
-      DeserializationFeature.FAIL_ON_UNKNOWN_PROPERTIES, true
-    ));
-
-    var extractor = parameters.extractor();
-    webhookUrl = extractor.secretValue(KEY_WEBHOOK_URL);
-
-    validateWebhookUrl(webhookUrl);
-
-    var selectedAlternative = 
extractor.selectedAlternativeInternalId(KEY_MESSAGE_TYPE_ALTERNATIVES);
-    if (selectedAlternative.equals(KEY_MESSAGE_ADVANCED)) {
-      isSimpleMessageMode = false;
-      messageContent = 
extractor.singleValueParameter(KEY_MESSAGE_ADVANCED_CONTENT, String.class);
-    } else {
-      isSimpleMessageMode = true;
-      messageContent = 
extractor.singleValueParameter(KEY_MESSAGE_SIMPLE_CONTENT, String.class);
+    public static final String ID = 
"org.apache.streampipes.sinks.notifications.jvm.msteams";
+
+    private static final String KEY_MESSAGE_ADVANCED = "messageAdvanced";
+    private static final String KEY_MESSAGE_ADVANCED_CONTENT = 
"messageContentAdvanced";
+    private static final String KEY_MESSAGE_SIMPLE = "messageSimple";
+    private static final String KEY_MESSAGE_SIMPLE_CONTENT = 
"messageContentSimple";
+    private static final String KEY_MESSAGE_TYPE_ALTERNATIVES = "messageType";
+    private static final String KEY_WEBHOOK_URL = "webhookUrl";
+    public static final String KEY_PROXY_ALTERNATIVES = "proxy";
+    public static final String KEY_PROXY_DISABLED = "proxyDisabled";
+    public static final String KEY_PROXY_ENABLED = "proxyEnabled";
+    public static final String KEY_PROXY_GROUP = "proxyConfigurationGroup";
+    public static final String KEY_PROXY_URL = "proxyUrl";
+
+    private static final int MAX_ATTEMPTS = 3;
+    private static final long RETRY_DELAY_MS = 2000;
+    private static final int CONNECT_TIMEOUT_MS = 5_000;
+    private static final int SOCKET_TIMEOUT_MS = 10_000;
+    private static final int CONNECTION_REQUEST_TIMEOUT_MS = 5_000;
+
+    private static final Logger LOG = 
LoggerFactory.getLogger(MSTeamsSink.class);
+
+    private String messageContent;
+    private boolean isSimpleMessageMode;
+    private URI webhookUrl;
+    private ObjectMapper objectMapper;
+    private CloseableHttpClient httpClient;
+
+    public MSTeamsSink() {
+        super();
+        this.objectMapper = JacksonSerializer
+                
.getObjectMapper(Map.of(DeserializationFeature.FAIL_ON_UNKNOWN_PROPERTIES, 
true));
     }
 
-    var selectedProxyAlternative = 
extractor.selectedAlternativeInternalId(KEY_PROXY_ALTERNATIVES);
-    if (selectedProxyAlternative.equals(KEY_PROXY_DISABLED)) {
-      this.httpClient = HttpClients.createDefault();
-    } else {
-      var proxyUrl = extractor.singleValueParameter(KEY_PROXY_URL, 
String.class);
-      this.httpClient = HttpClientBuilder
-          .create()
-          .setProxy(HttpHost.create(proxyUrl))
-          .build();
+    @Override
+    public IDataSinkConfiguration declareConfig() {
+        var builder = declareModelWithoutSilentPeriod();
+        return DataSinkConfiguration.create(MSTeamsSink::new, builder.build());
     }
-  }
 
-  @Override
-  public void onNotificationEvent(Event event) {
+    @Override
+    public void onPipelineStarted(IDataSinkParameters parameters, 
EventSinkRuntimeContext runtimeContext) {
+        super.onPipelineStarted(parameters, runtimeContext);
 
-    // This sink allows to use placeholders for event properties when defining 
the message content in the UI
-    // Therefore, we need to replace these placeholders based on the actual 
event before actually sending the message
-    var processedMessageContent = 
PlaceholderExtractor.replacePlaceholders(event, messageContent);
+        this.objectMapper = JacksonSerializer
+                
.getObjectMapper(Map.of(DeserializationFeature.FAIL_ON_UNKNOWN_PROPERTIES, 
true));
 
-    String teamsMessageContent;
-    if (isSimpleMessageMode) {
-      teamsMessageContent = 
createMessageFromSimpleContent(processedMessageContent);
-    } else {
-      teamsMessageContent = 
createMessageFromAdvancedContent(processedMessageContent);
-    }
-    sendPayloadToWebhook(httpClient, teamsMessageContent, webhookUrl);
-  }
-
-  @Override
-  public DataSinkBuilder declareModelWithoutSilentPeriod() {
-    return DataSinkBuilder
-        .create(ID, 1)
-        .withLocales(Locales.EN)
-        .withAssets(ExtensionAssetType.DOCUMENTATION, ExtensionAssetType.ICON)
-        .category(DataSinkType.NOTIFICATION)
-        .requiredStream(
-            StreamRequirementsBuilder
-                .create()
-                .requiredProperty(EpRequirements.anyProperty())
-                .build()
-        )
-        .requiredSecret(Labels.withId(KEY_WEBHOOK_URL))
-        .requiredAlternatives(
-            Labels.withId(KEY_PROXY_ALTERNATIVES),
-            Alternatives.from(Labels.withId(KEY_PROXY_DISABLED)),
-            Alternatives.from(Labels.withId(KEY_PROXY_ENABLED),
-                StaticProperties.group(Labels.withId(KEY_PROXY_GROUP),
-                    
StaticProperties.stringFreeTextProperty(Labels.withId(KEY_PROXY_URL))
-                )
-            ))
-        .requiredAlternatives(
-            Labels.withId(KEY_MESSAGE_TYPE_ALTERNATIVES),
-            Alternatives.from(
-                Labels.withId(KEY_MESSAGE_SIMPLE),
-                StaticProperties.stringFreeTextProperty(
-                    Labels.withId(KEY_MESSAGE_SIMPLE_CONTENT),
-                    true,
-                    true
-                ),
-                true
-            ),
-            Alternatives.from(
-                Labels.withId(KEY_MESSAGE_ADVANCED),
-                StaticProperties.stringFreeTextProperty(
-                    Labels.withId(KEY_MESSAGE_ADVANCED_CONTENT),
-                    true,
-                    true
-                )
-            )
-        );
-  }
-
-  @Override
-  public void onPipelineStopped() {
-    try {
-        this.httpClient.close();
-    } catch (IOException e) {
-        throw new SpRuntimeException("Error when closing MSTeams client: 
%s".formatted(e.getMessage()));
-    }
-   }
-
-  /**
-   * Creates a JSON string intended for the MS Teams Webhook URL based on the 
provided plain message content.
-   * <p>
-   * This method utilizes a basic approach for constructing messages to be 
sent to MS Teams.
-   * If you intend to provide text in the form of Adaptive Cards, consider 
using
-   * {@link #createMessageFromAdvancedContent(String)} for a more advanced and 
interactive message format.
-   * </p>
-   *
-   * @param messageContent The plain message content to be included in the 
Teams message.
-   * @return A JSON string formatted using a predefined template with the 
provided message content.
-   */
-  protected String createMessageFromSimpleContent(String messageContent) {
-    return SIMPLE_MESSAGE_TEMPLATE.formatted(messageContent);
-  }
-
-  /**
-   * Creates a message for MS Teams from a JSON string, specifically designed 
for use with Adaptive Cards.
-   * <p>
-   * This method takes a JSON string as input, which is expected to represent 
the content of the message.
-   * The content is directly forwarded to MS Teams, allowing for the 
utilization of Adaptive Cards.
-   * Adaptive Cards provide a flexible and interactive way to present content 
in Microsoft Teams.
-   * Learn more about Adaptive Cards: <a 
href="https://learn.microsoft.com/en-us/adaptive-cards/";>here</a>
-   * </p>
-   *
-   * @param messageContent The JSON string representing the content of the 
message.
-   * @return The original JSON string, unchanged.
-   * @throws SpRuntimeException If the provided message is not a valid JSON 
string.
-   */
-  protected String createMessageFromAdvancedContent(String messageContent) {
-    try {
-      objectMapper.readValue(messageContent, Object.class);
-    } catch (JsonProcessingException e) {
-      throw new SpRuntimeException(
-          "Advanced message content provided is not a valid JSON string: 
%s".formatted(messageContent),
-          e
-      );
-    }
-    return messageContent;
-  }
-
-  /**
-   * Sends a payload to a webhook using the provided HTTP client, payload, and 
webhook URL.
-   *
-   * @param mockedClient The HTTP client used to send the payload.
-   * @param payload    The payload to be sent to the webhook.
-   * @param webhookUrl The URL of the webhook to which the payload will be 
sent.
-   * @throws SpRuntimeException If an I/O error occurs while sending the 
payload to the webhook or
-   *                            the payload sent is not accepted by the API.
-   */
-  protected void sendPayloadToWebhook(CloseableHttpClient mockedClient, String 
payload, String webhookUrl) {
-
-    for (int attempt = 1; ; attempt++) {
-      HttpPost request = new HttpPost(webhookUrl);
-      request.setEntity(new StringEntity(payload, 
ContentType.APPLICATION_JSON));
-
-      if (Thread.currentThread().isInterrupted()) {
-        throw new SpRuntimeException("Interrupted while sending MS Teams 
webhook");
-      }
-
-      try (CloseableHttpResponse response = mockedClient.execute(request)) {
-        int status = response.getStatusLine().getStatusCode();
-        if (status >= 200 && status < 300) {
-          return;
+        var extractor = parameters.extractor();
+        webhookUrl = 
validateWebhookUrl(extractor.secretValue(KEY_WEBHOOK_URL));
+
+        var selectedAlternative = 
extractor.selectedAlternativeInternalId(KEY_MESSAGE_TYPE_ALTERNATIVES);
+        if (selectedAlternative.equals(KEY_MESSAGE_ADVANCED)) {
+            isSimpleMessageMode = false;
+            messageContent = 
extractor.singleValueParameter(KEY_MESSAGE_ADVANCED_CONTENT, String.class);
+        } else {
+            isSimpleMessageMode = true;
+            messageContent = 
extractor.singleValueParameter(KEY_MESSAGE_SIMPLE_CONTENT, String.class);
         }
 
-        if (status != HTTP_TOO_MANY_REQUESTS && (status < 500 || status >= 
600)) {
-          throw new SpRuntimeException("MS Teams webhook rejected request 
(status=%d)".formatted(status));
+        var selectedProxyAlternative = 
extractor.selectedAlternativeInternalId(KEY_PROXY_ALTERNATIVES);
+        var requestConfig = 
RequestConfig.custom().setConnectTimeout(CONNECT_TIMEOUT_MS)
+                
.setSocketTimeout(SOCKET_TIMEOUT_MS).setConnectionRequestTimeout(CONNECTION_REQUEST_TIMEOUT_MS).build();
+
+        if (selectedProxyAlternative.equals(KEY_PROXY_DISABLED)) {
+            this.httpClient = 
HttpClientBuilder.create().setDefaultRequestConfig(requestConfig).build();
+            LOG.info("MS Teams sink initialized (no proxy), webhook host={}", 
webhookUrl.getHost());
+        } else {
+            var proxyUrl = extractor.singleValueParameter(KEY_PROXY_URL, 
String.class);
+            this.httpClient = 
HttpClientBuilder.create().setDefaultRequestConfig(requestConfig)
+                    .setProxy(HttpHost.create(proxyUrl)).build();
+            LOG.info("MS Teams sink initialized via proxy {}", proxyUrl);
         }
+    }
+
+    @Override
+    public void onNotificationEvent(Event event) {
 
-        if (attempt > MAX_RETRIES) {
-          throw new SpRuntimeException("MS Teams webhook failed after %d 
attempts (status=%d)"
-            .formatted(attempt - 1, status));
+        // This sink allows to use placeholders for event properties when 
defining the
+        // message content in the UI
+        // Therefore, we need to replace these placeholders based on the 
actual event
+        // before actually sending the message
+        var processedMessageContent = 
PlaceholderExtractor.replacePlaceholders(event, messageContent);
+
+        String teamsMessageContent;
+        if (isSimpleMessageMode) {
+            teamsMessageContent = 
createMessageFromSimpleContent(processedMessageContent);
+        } else {
+            teamsMessageContent = 
createMessageFromAdvancedContent(processedMessageContent);
         }
+        sendPayloadToWebhook(httpClient, teamsMessageContent, webhookUrl);
+    }
 
-        long backoffMs = BASE_BACKOFF.toMillis() << Math.min(attempt, 6);
+    @Override
+    public DataSinkBuilder declareModelWithoutSilentPeriod() {
+        return DataSinkBuilder.create(ID, 1).withLocales(Locales.EN)
+                .withAssets(ExtensionAssetType.DOCUMENTATION, 
ExtensionAssetType.ICON)
+                .category(DataSinkType.NOTIFICATION)
+                .requiredStream(
+                        
StreamRequirementsBuilder.create().requiredProperty(EpRequirements.anyProperty()).build())
+                .requiredSecret(Labels.withId(KEY_WEBHOOK_URL))
+                .requiredAlternatives(Labels.withId(KEY_PROXY_ALTERNATIVES),
+                        Alternatives.from(Labels.withId(KEY_PROXY_DISABLED)),
+                        Alternatives.from(Labels.withId(KEY_PROXY_ENABLED),
+                                
StaticProperties.group(Labels.withId(KEY_PROXY_GROUP),
+                                        
StaticProperties.stringFreeTextProperty(Labels.withId(KEY_PROXY_URL)))))
+                
.requiredAlternatives(Labels.withId(KEY_MESSAGE_TYPE_ALTERNATIVES),
+                        Alternatives.from(Labels.withId(KEY_MESSAGE_SIMPLE),
+                                
StaticProperties.stringFreeTextProperty(Labels.withId(KEY_MESSAGE_SIMPLE_CONTENT),
 true,
+                                        true),
+                                true),
+                        Alternatives.from(Labels.withId(KEY_MESSAGE_ADVANCED), 
StaticProperties
+                                
.stringFreeTextProperty(Labels.withId(KEY_MESSAGE_ADVANCED_CONTENT), true, 
true)));
+    }
 
-        Header retryAfter = response.getFirstHeader("Retry-After");
-        if (retryAfter != null) {
-        try {
-            backoffMs = Long.parseLong(retryAfter.getValue()) * 1000;
-          } catch (NumberFormatException ignored) {}
+    @Override
+    public void onPipelineStopped() {
+        if (httpClient != null) {
+            try {
+                httpClient.close();
+                LOG.info("MS Teams sink stopped, HTTP client closed");
+            } catch (IOException e) {
+                LOG.warn("Error closing MS Teams HTTP client: {}", 
e.getMessage());
+            }
         }
+    }
+
+    protected String createMessageFromSimpleContent(String messageContent) {
+        var card = objectMapper.createObjectNode();
+        card.put("$schema", 
"http://adaptivecards.io/schemas/adaptive-card.json";);
+        card.put("type", "AdaptiveCard");
+        card.put("version", "1.4");
 
-        Thread.sleep(backoffMs);
-      } catch (IOException | InterruptedException e) {
-        if (attempt > MAX_RETRIES) {
-          throw new SpRuntimeException("I/O error sending MS Teams webhook", 
e);
+        var textBlock = objectMapper.createObjectNode();
+        textBlock.put("type", "TextBlock");
+        textBlock.put("text", messageContent);
+        textBlock.put("wrap", true);
+        card.putArray("body").add(textBlock);
+
+        var attachment = objectMapper.createObjectNode();
+        attachment.put("contentType", 
"application/vnd.microsoft.card.adaptive");
+        attachment.set("content", card);
+
+        var message = objectMapper.createObjectNode();
+        message.put("type", "message");
+        message.putArray("attachments").add(attachment);
+
+        try {
+            return objectMapper.writeValueAsString(message);
+        } catch (JsonProcessingException e) {
+            throw new SpRuntimeException("Could not serialize MS Teams message 
content", e);
         }
+    }
 
+    /**
+     * Creates a message for MS Teams from a JSON string, specifically 
designed for
+     * use with Adaptive Cards.
+     * <p>
+     * This method takes a JSON string as input, which is expected to 
represent the
+     * content of the message. The content is directly forwarded to MS Teams,
+     * allowing for the utilization of Adaptive Cards. Adaptive Cards provide a
+     * flexible and interactive way to present content in Microsoft Teams. 
Learn
+     * more about Adaptive Cards:
+     * <a href="https://learn.microsoft.com/en-us/adaptive-cards/";>here</a>
+     * </p>
+     *
+     * @param messageContent The JSON string representing the content of the
+     *                       message.
+     * @return The original JSON string, unchanged.
+     * @throws SpRuntimeException If the provided message is not a valid JSON
+     *                            string.
+     */
+    protected String createMessageFromAdvancedContent(String messageContent) {
         try {
-          Thread.sleep(BASE_BACKOFF.toMillis() << Math.min(attempt, 6));
-        } catch (InterruptedException ie) {
-          Thread.currentThread().interrupt();
-          throw new SpRuntimeException("Interrupted while retrying MS Teams 
webhook", ie);
+            objectMapper.readValue(messageContent, Object.class);
+        } catch (JsonProcessingException e) {
+            throw new SpRuntimeException(
+                    "Advanced message content provided is not a valid JSON 
string: %s".formatted(messageContent), e);
         }
-      }
+        return messageContent;
     }
-  }
-
-  /**
-   * Validates a webhook URL to ensure it is not null, not empty, and has a 
valid URL format.
-   *
-   * @param webhookUrl The webhook URL to be validated.
-   * @throws SpRuntimeException If the webhook URL is null or empty, or if it 
is not a valid URL.
-   */
-  protected void validateWebhookUrl(String webhookUrl) {
-    if (webhookUrl == null || webhookUrl.isEmpty()) {
-      throw new SpRuntimeException("Given webhook URL is empty");
+
+    /**
+     * Sends a payload to the configured MS Teams webhook, retrying transient
+     * failures with a fixed delay.
+     * <p>
+     * A request is retried up to {@value #MAX_ATTEMPTS} times (waiting
+     * {@value #RETRY_DELAY_MS} ms between attempts) when the call fails with 
an I/O
+     * error or the webhook responds with HTTP 429 or a 5xx status. A 4xx 
status
+     * other than 429 is treated as permanent (e.g. an invalid webhook, revoked
+     * token, or malformed card) and fails immediately without retrying.
+     * </p>
+     * <p>
+     * If the calling thread is interrupted (for example because the pipeline 
is
+     * being stopped), the method restores the interrupt flag and returns 
quietly
+     * without throwing, since an aborted send during shutdown is not a 
failure.
+     * </p>
+     *
+     * @param client  The HTTP client used to send the payload.
+     * @param payload The payload to be sent to the webhook.
+     * @param url     The URL of the webhook to which the payload will be sent.
+     * @throws SpRuntimeException If the webhook rejects the message with a
+     *                            permanent (non-429 4xx) status, or if all 
retry
+     *                            attempts are exhausted without success.
+     */
+    void sendPayloadToWebhook(CloseableHttpClient client, String payload, URI 
url) {
+        var post = new HttpPost(url);
+        post.setEntity(new StringEntity(payload, 
ContentType.APPLICATION_JSON));
+
+        SpRuntimeException last = null;
+
+        for (int attempt = 1; attempt <= MAX_ATTEMPTS; attempt++) {
+            LOG.debug("Sending notification to MS Teams (attempt {}/{})", 
attempt, MAX_ATTEMPTS);
+
+            try (CloseableHttpResponse response = client.execute(post)) {
+                int status = response.getStatusLine().getStatusCode();
+
+                if (status >= 200 && status < 300) {
+                    if (attempt > 1) {
+                        LOG.info("MS Teams notification succeeded on attempt 
{}/{}", attempt, MAX_ATTEMPTS);
+                    } else {
+                        LOG.debug("MS Teams notification sent (HTTP {})", 
status);
+                    }
+                    return;
+                }
+
+                if (status >= 400 && status < 500 && status != 429) {
+                    // client error: retrying won't help (bad webhook, revoked 
token, malformed
+                    // card)
+                    LOG.error("MS Teams rejected the message with HTTP {} - 
not retrying", status);
+                    throw new SpRuntimeException("Teams rejected the message: 
HTTP " + status);
+                }
+
+                // 5xx or 429 -> transient, worth retrying
+                LOG.warn("MS Teams returned HTTP {} (attempt {}/{})", status, 
attempt, MAX_ATTEMPTS);
+                last = new SpRuntimeException("Teams returned HTTP " + status);
+
+            } catch (IOException e) {
+                if (Thread.currentThread().isInterrupted()) {
+                    LOG.debug("MS Teams request aborted because the pipeline 
is stopping - ignoring");
+                    return; // clean shutdown, not a failure
+                }
+                LOG.warn("MS Teams request failed (attempt {}/{}): {}", 
attempt, MAX_ATTEMPTS, e.getMessage());
+                last = new SpRuntimeException("Sending notification to MS 
Teams failed.", e);
+            }
+
+            if (attempt < MAX_ATTEMPTS) {
+                LOG.info("Retrying MS Teams notification in {} ms", 
RETRY_DELAY_MS);
+                try {
+                    Thread.sleep(RETRY_DELAY_MS);
+                } catch (InterruptedException e) {
+                    Thread.currentThread().interrupt();
+                    LOG.debug("Interrupted while waiting to retry - pipeline 
stopping, giving up");
+                    return;
+                }
+            }
+        }
+
+        LOG.error("Giving up on MS Teams notification after {} attempts", 
MAX_ATTEMPTS);
+        // 'last' is always assigned before reaching this point, but guard 
defensively.
+        throw last != null ? last : new SpRuntimeException("Sending 
notification to MS Teams failed.");
     }
-    try {
-      new URL(webhookUrl);
-    } catch (MalformedURLException e) {
-      throw new SpRuntimeException("The given webhook is not a valid URL: 
%s".formatted(webhookUrl));
+
+    /**
+     * Validates a webhook URL to ensure it is not null, not empty, and has a 
valid
+     * URL format.
+     *
+     * @param webhookUrl The webhook URL to be validated.
+     * @throws SpRuntimeException If the webhook URL is null or empty, or if 
it is
+     *                            not a valid URL.
+     */
+    protected URI validateWebhookUrl(String webhookUrl) {
+        if (webhookUrl == null || webhookUrl.isEmpty()) {
+            throw new SpRuntimeException("Given webhook URL is empty");
+        }
+        try {
+            URI uri = new URI(webhookUrl);
+            if (uri.getScheme() == null || uri.getHost() == null) {
+                throw new SpRuntimeException("The given webhook URL is not 
absolute or has no host");
+            }
+            if (!"http".equalsIgnoreCase(uri.getScheme()) && 
!"https".equalsIgnoreCase(uri.getScheme())) {
+                throw new SpRuntimeException("The given webhook URL must use 
http or https");
+            }
+            return uri;
+        } catch (URISyntaxException e) {
+            throw new SpRuntimeException("The given webhook URL is not valid", 
e);
+        }
     }
-  }
-}
+}
\ No newline at end of file
diff --git 
a/streampipes-extensions/streampipes-sinks-notifications-jvm/src/main/resources/org.apache.streampipes.sinks.notifications.jvm.msteams/documentation.md
 
b/streampipes-extensions/streampipes-sinks-notifications-jvm/src/main/resources/org.apache.streampipes.sinks.notifications.jvm.msteams/documentation.md
index 3c6d41d4f9..f055757a66 100644
--- 
a/streampipes-extensions/streampipes-sinks-notifications-jvm/src/main/resources/org.apache.streampipes.sinks.notifications.jvm.msteams/documentation.md
+++ 
b/streampipes-extensions/streampipes-sinks-notifications-jvm/src/main/resources/org.apache.streampipes.sinks.notifications.jvm.msteams/documentation.md
@@ -43,28 +43,21 @@ with any type of incoming event, making it a versatile 
choice for various use ca
 
 #### Webhook URL
 
-To configure the MS Teams Sink, you need to provide the Webhook URL that 
enables the sink to send messages to a specific
-MS Teams channel. If you don't have a Webhook URL, you can learn how to create
-one 
[here](https://learn.microsoft.com/en-us/microsoftteams/platform/webhooks-and-connectors/how-to/add-incoming-webhook?tabs=dotnet#create-incoming-webhooks-1).
+To configure the MS Teams Sink, you need to provide the Webhook URL that 
enables the sink to send
+messages to a specific MS Teams channel. The webhook must be created as a 
Power Automate workflow
+using the *"Post to a channel when a webhook request is received"* workflow 
template.
 
 #### Message Content Options
 
 You can choose between two message content formats:
 
-- **Simple Message Content:** Supports plain text and basic markdown 
formatting.
-- **Advanced Message Content:** Expects JSON input directly forwarded to Teams 
without modification. This format is
-  highly customizable and can be used for Adaptive Cards.
+- **Simple Message Content:** Send plain text. The text is automatically 
wrapped in an Adaptive Card
+  before being sent to Teams.
+- **Advanced Message Content:** Expects JSON input directly forwarded to Teams 
without modification.
+  This format is highly customizable and can be used for Adaptive Cards.
 
 Choose the format that best suits your messaging needs.
 
-### Silent Period
-
-The *Silent Period* is the duration, expressed in minutes, during which 
notifications are temporarily disabled after one
-has been sent. This feature is implemented to prevent overwhelming the target 
with frequent notifications, avoiding
-potential spam behavior.
-
----
-
 ## Usage
 
 #### Simple Message Format
diff --git 
a/streampipes-extensions/streampipes-sinks-notifications-jvm/src/test/java/org/apache/streampipes/sinks/notifications/jvm/msteams/TestMSTeamsSink.java
 
b/streampipes-extensions/streampipes-sinks-notifications-jvm/src/test/java/org/apache/streampipes/sinks/notifications/jvm/msteams/TestMSTeamsSink.java
index 8c5617f3fa..55dd51aca6 100644
--- 
a/streampipes-extensions/streampipes-sinks-notifications-jvm/src/test/java/org/apache/streampipes/sinks/notifications/jvm/msteams/TestMSTeamsSink.java
+++ 
b/streampipes-extensions/streampipes-sinks-notifications-jvm/src/test/java/org/apache/streampipes/sinks/notifications/jvm/msteams/TestMSTeamsSink.java
@@ -32,7 +32,10 @@ import org.junit.jupiter.api.Test;
 import org.mockito.ArgumentCaptor;
 
 import java.io.IOException;
+import java.net.URI;
+import java.net.URISyntaxException;
 
+import static org.junit.jupiter.api.Assertions.assertDoesNotThrow;
 import static org.junit.jupiter.api.Assertions.assertEquals;
 import static org.junit.jupiter.api.Assertions.assertThrows;
 import static org.mockito.ArgumentMatchers.any;
@@ -43,84 +46,151 @@ import static org.mockito.Mockito.when;
 
 public class TestMSTeamsSink {
 
-  @Test
-  public void createMessageFromSimpleContent() {
-    var messageContent = "This is test";
-    var sink = new MSTeamsSink();
+    @Test
+    public void createMessageFromSimpleContent() {
+        var sink = new MSTeamsSink();
+
+        var expectedTeamsMessage = """
+                {
+                  "type" : "message",
+                  "attachments" : [ {
+                    "contentType" : "application/vnd.microsoft.card.adaptive",
+                    "content" : {
+                      "$schema" : 
"http://adaptivecards.io/schemas/adaptive-card.json";,
+                      "type" : "AdaptiveCard",
+                      "version" : "1.4",
+                      "body" : [ {
+                        "type" : "TextBlock",
+                        "text" : "This is test",
+                        "wrap" : true
+                      } ]
+                    }
+                  } ]
+                }""";
+
+        var createdTeamsMessage = sink.createMessageFromSimpleContent("This is 
test");
+
+        assertEquals(
+                expectedTeamsMessage.replace("\r\n", "\n"),
+                createdTeamsMessage.replace("\r\n", "\n")
+            );
+        }
+
+    @Test
+    public void createMessageFromAdvancedContent() {
+        var messageContent = "{\"text\": \"Hi this is a message from Apache 
StreamPipes\"}";
+
+        var sink = new MSTeamsSink();
+        assertEquals(messageContent, 
sink.createMessageFromAdvancedContent(messageContent));
+    }
+
+    @Test
+    public void createMessageFromAdvancedContentCheckException() {
+        var messageContent = "invalid-complex-input";
+
+        var sink = new MSTeamsSink();
+
+        assertThrows(SpRuntimeException.class, () -> 
sink.createMessageFromAdvancedContent(messageContent));
+    }
+
+    @Test
+    public void sendPayloadToWebhook() throws IOException, URISyntaxException {
+
+        var mockedClient = mock(CloseableHttpClient.class);
+        var mockedResponse = mock(CloseableHttpResponse.class);
+        var mockedStatusLine = mock(StatusLine.class);
+        var argumentCaptor = ArgumentCaptor.forClass(HttpPost.class);
+
+        when(mockedStatusLine.getStatusCode()).thenReturn(HttpStatus.SC_OK);
+        when(mockedResponse.getStatusLine()).thenReturn(mockedStatusLine);
+        when(mockedClient.execute(any())).thenReturn(mockedResponse);
+
+        var payload = "This is a test";
+        var webhook = "https://webhook.com";;
+        var sink = new MSTeamsSink();
+
+        sink.sendPayloadToWebhook(mockedClient, payload, new URI(webhook));
+        verify(mockedClient, times(1)).execute(argumentCaptor.capture());
+
+        var capturedPost = argumentCaptor.getValue();
+
+        Assertions.assertNotNull(capturedPost);
+        assertEquals(webhook, capturedPost.getURI().toString());
+        assertEquals(ContentType.APPLICATION_JSON.toString(), 
capturedPost.getEntity().getContentType().getValue());
+        assertEquals(payload, EntityUtils.toString(capturedPost.getEntity()));
+    }
+
+    @Test
+    public void sendPayloadToWebhookBadResponse() throws IOException {
+        CloseableHttpClient mockedClient = mock(CloseableHttpClient.class);
+        var mockedResponse = mock(CloseableHttpResponse.class);
+        var mockedStatusLine = mock(StatusLine.class);
+
+        
when(mockedStatusLine.getStatusCode()).thenReturn(HttpStatus.SC_BAD_REQUEST);
+        when(mockedResponse.getStatusLine()).thenReturn(mockedStatusLine);
+        when(mockedClient.execute(any())).thenReturn(mockedResponse);
+
+        var sink = new MSTeamsSink();
+        var payload = "<a>invalid</a>";
+        var url = "https://webhook.com";;
+
+        assertThrows(SpRuntimeException.class, () -> 
sink.sendPayloadToWebhook(mockedClient, payload, new URI(url)));
+
+        // A 4xx (other than 429) is permanent: it must fail on the first 
attempt, no
+        // retries.
+        verify(mockedClient, times(1)).execute(any());
+    }
+
+    @Test
+    public void sendPayloadToWebhookRetriesOnServerErrorThenGivesUp() throws 
IOException {
+        var mockedClient = mock(CloseableHttpClient.class);
+        var mockedResponse = mock(CloseableHttpResponse.class);
+        var mockedStatusLine = mock(StatusLine.class);
+
+        // Every attempt returns a 500 -> transient, should be retried up to
+        // MAX_ATTEMPTS.
+        
when(mockedStatusLine.getStatusCode()).thenReturn(HttpStatus.SC_INTERNAL_SERVER_ERROR);
+        when(mockedResponse.getStatusLine()).thenReturn(mockedStatusLine);
+        when(mockedClient.execute(any())).thenReturn(mockedResponse);
+
+        var sink = new MSTeamsSink();
+
+        assertThrows(SpRuntimeException.class,
+                () -> sink.sendPayloadToWebhook(mockedClient, "payload", new 
URI("https://webhook.com";)));
+
+        // After exhausting all attempts the call must have been made 
MAX_ATTEMPTS
+        // times.
+        verify(mockedClient, times(3)).execute(any());
+    }
+
+    @Test
+    public void sendPayloadToWebhookRecoversAfterTransientFailure() throws 
IOException {
+        var mockedClient = mock(CloseableHttpClient.class);
+        var failResponse = mock(CloseableHttpResponse.class);
+        var failStatusLine = mock(StatusLine.class);
+        var okResponse = mock(CloseableHttpResponse.class);
+        var okStatusLine = mock(StatusLine.class);
+
+        
when(failStatusLine.getStatusCode()).thenReturn(HttpStatus.SC_SERVICE_UNAVAILABLE);
+        when(failResponse.getStatusLine()).thenReturn(failStatusLine);
+        when(okStatusLine.getStatusCode()).thenReturn(HttpStatus.SC_OK);
+        when(okResponse.getStatusLine()).thenReturn(okStatusLine);
 
-    assertEquals(MSTeamsSink.SIMPLE_MESSAGE_TEMPLATE.formatted(messageContent),
-                            
sink.createMessageFromSimpleContent(messageContent));
-  }
-
-  @Test
-  public void createMessageFromAdvancedContent() {
-    var messageContent = "{\"text\": \"Hi this is a message from Apache 
StreamPipes\"}";
-
-    var sink = new MSTeamsSink();
-    assertEquals(messageContent, 
sink.createMessageFromAdvancedContent(messageContent));
-  }
-
-  @Test
-  public void createMessageFromAdvancedContentCheckException() {
-    var messageContent = "invalid-complex-input";
-
-    var sink = new MSTeamsSink();
-
-    assertThrows(SpRuntimeException.class, () -> 
sink.createMessageFromAdvancedContent(messageContent));
-  }
-
-  @Test
-  public void sendPayloadToWebhook() throws IOException {
-
-    var mockedClient = mock(CloseableHttpClient.class);
-    var mockedResponse = mock(CloseableHttpResponse.class);
-    var mockedStatusLine = mock(StatusLine.class);
-    var argumentCaptor = ArgumentCaptor.forClass(HttpPost.class);
-
-    when(mockedStatusLine.getStatusCode()).thenReturn(HttpStatus.SC_OK);
-    when(mockedResponse.getStatusLine()).thenReturn(mockedStatusLine);
-    when(mockedClient.execute(any())).thenReturn(mockedResponse);
-
-    var payload = "This is a test";
-    var webhook = "https://webhook.com";;
-    var sink = new MSTeamsSink();
-
-    sink.sendPayloadToWebhook(mockedClient, payload, webhook);
-    verify(mockedClient, times(1)).execute(argumentCaptor.capture());
-
-
-    var capturedPost = argumentCaptor.getValue();
-
-    Assertions.assertNotNull(capturedPost);
-    assertEquals(webhook,
-                            capturedPost.getURI().toString()
-    );
-    assertEquals(ContentType.APPLICATION_JSON.toString(),
-                            
capturedPost.getEntity().getContentType().getValue());
-    assertEquals(payload, EntityUtils.toString(capturedPost.getEntity()));
-  }
-
-  @Test
-  public void sendPayloadToWebhookBadResponse() throws  IOException {
-    CloseableHttpClient mockedClient = mock(CloseableHttpClient.class);
-    var mockedResponse = mock(CloseableHttpResponse.class);
-    var mockedStatusLine = mock(StatusLine.class);
-
-    
when(mockedStatusLine.getStatusCode()).thenReturn(HttpStatus.SC_BAD_REQUEST);
-    when(mockedResponse.getStatusLine()).thenReturn(mockedStatusLine);
-    when(mockedClient.execute(any())).thenReturn(mockedResponse);
-
-    var sink = new MSTeamsSink();
-    var payload = "<a>invalid</a>";
-    var url = "https://webhook.com";;
-
-    assertThrows(SpRuntimeException.class, () -> 
sink.sendPayloadToWebhook(mockedClient, payload, url));
-  }
-
-  @Test
-  public void validateWebhookUrl() {
-    var sink = new MSTeamsSink();
-    assertThrows(SpRuntimeException.class, () -> sink.validateWebhookUrl(""));
-    assertThrows(SpRuntimeException.class, () -> 
sink.validateWebhookUrl("some-string"));
-  }
-}
+        // First call fails with 503, second call succeeds.
+        
when(mockedClient.execute(any())).thenReturn(failResponse).thenReturn(okResponse);
+
+        var sink = new MSTeamsSink();
+
+        assertDoesNotThrow(() -> sink.sendPayloadToWebhook(mockedClient, 
"payload", new URI("https://webhook.com";)));
+
+        // One failed attempt + one successful retry = two executions, no 
third.
+        verify(mockedClient, times(2)).execute(any());
+    }
+
+    @Test
+    public void validateWebhookUrl() {
+        var sink = new MSTeamsSink();
+        assertThrows(SpRuntimeException.class, () -> 
sink.validateWebhookUrl(""));
+        assertThrows(SpRuntimeException.class, () -> 
sink.validateWebhookUrl("some-string"));
+    }
+}
\ No newline at end of file


Reply via email to