From 3b9b672623c5f3c3c77ce607c9b08a5a08ec6848 Mon Sep 17 00:00:00 2001 From: "claude[bot]" <41898282+claude[bot]@users.noreply.github.com> Date: Fri, 20 Feb 2026 13:37:36 +0000 Subject: [PATCH 1/2] feat: add failOnError option to abort pipeline on notification failures Add a `failOnError` configuration option (default: false) that causes the pipeline to fail when Slack notifications cannot be delivered. When `failOnError = true`, any notification failure (sending messages, uploading files, adding reactions, or progress updates) will throw a RuntimeException, aborting the pipeline. When false (default), errors are logged as warnings and the pipeline continues. Senders (WebhookSlackSender, BotSlackSender) now throw exceptions on failure instead of silently swallowing errors. SlackObserver wraps all notification calls and delegates error handling to the new `handleNotificationError` helper based on the failOnError config. Usage: ```nextflow slack { failOnError = true // default: false } ``` Closes #45 Co-authored-by: Adam Talbot --- .../nextflow/slack/BotSlackSender.groovy | 214 ++++++++---------- .../groovy/nextflow/slack/SlackConfig.groovy | 8 + .../nextflow/slack/SlackExtension.groovy | 107 +++++---- .../nextflow/slack/SlackObserver.groovy | 71 ++++-- .../nextflow/slack/WebhookSlackSender.groovy | 24 +- .../nextflow/slack/BotSlackSenderTest.groovy | 55 +++-- .../nextflow/slack/SlackConfigTest.groovy | 59 +++++ .../nextflow/slack/SlackObserverTest.groovy | 137 +++++++++++ 8 files changed, 441 insertions(+), 234 deletions(-) diff --git a/src/main/groovy/nextflow/slack/BotSlackSender.groovy b/src/main/groovy/nextflow/slack/BotSlackSender.groovy index 60135a0..003e112 100644 --- a/src/main/groovy/nextflow/slack/BotSlackSender.groovy +++ b/src/main/groovy/nextflow/slack/BotSlackSender.groovy @@ -52,7 +52,6 @@ class BotSlackSender implements SlackSender { private final String botToken private final String channelId - private final Set loggedErrors = Collections.synchronizedSet(new HashSet()) private String threadTs // Store the thread timestamp for threaded conversations private String resolvedChannelId // Channel ID resolved from Slack API response @@ -68,22 +67,15 @@ class BotSlackSender implements SlackSender { } /** - * Send a message to Slack via Web API + * Send a message to Slack via Web API. + * Throws a RuntimeException if the message cannot be delivered. * * @param message JSON message payload (must be compatible with chat.postMessage) + * @throws RuntimeException if the API call fails */ @Override void sendMessage(String message) { - try { - // Message is already formatted by SlackMessageBuilder with channel ID - postToSlack(message) - - } catch (Exception e) { - def errorMsg = "Slack plugin: Error sending bot message: ${e.message}".toString() - if (loggedErrors.add(errorMsg)) { - log.error errorMsg - } - } + postToSlack(message) } /** @@ -92,76 +84,69 @@ class BotSlackSender implements SlackSender { * 2. Upload the file content to that URL * 3. Call files.completeUploadExternal to finalize and share * + * Throws a RuntimeException if the upload fails at any step. + * * @param filePath Path to the file to upload * @param options Map with optional keys: title, comment, filename, threadTs + * @throws IllegalArgumentException if the file is invalid + * @throws RuntimeException if the upload fails */ @Override void uploadFile(Path filePath, Map options) { - try { - if (filePath == null) { - log.error "Slack plugin: File path is required for file upload" - return - } - - if (!Files.exists(filePath)) { - log.error "Slack plugin: File not found: ${filePath}" - return - } + if (filePath == null) { + throw new IllegalArgumentException("Slack plugin: File path is required for file upload") + } - if (!Files.isReadable(filePath)) { - log.error "Slack plugin: File is not readable: ${filePath}" - return - } + if (!Files.exists(filePath)) { + throw new IllegalArgumentException("Slack plugin: File not found: ${filePath}") + } - def fileSize = Files.size(filePath) - if (fileSize == 0) { - log.error "Slack plugin: Cannot upload empty file: ${filePath}" - return - } + if (!Files.isReadable(filePath)) { + throw new IllegalArgumentException("Slack plugin: File is not readable: ${filePath}") + } - if (fileSize > MAX_FILE_SIZE) { - log.error "Slack plugin: File exceeds maximum size of ${MAX_FILE_SIZE / (1024 * 1024)}MB: ${filePath}" - return - } + def fileSize = Files.size(filePath) + if (fileSize == 0) { + throw new IllegalArgumentException("Slack plugin: Cannot upload empty file: ${filePath}") + } - def filename = (options?.filename as String) ?: filePath.getFileName().toString() - def title = (options?.title as String) ?: filename - def comment = options?.comment as String - def threadTs = options?.threadTs as String + if (fileSize > MAX_FILE_SIZE) { + throw new IllegalArgumentException("Slack plugin: File exceeds maximum size of ${MAX_FILE_SIZE / (1024 * 1024)}MB: ${filePath}") + } - // Step 1: Get upload URL - def uploadInfo = getUploadUrl(filename, fileSize) - if (!uploadInfo) { - return - } + def filename = (options?.filename as String) ?: filePath.getFileName().toString() + def title = (options?.title as String) ?: filename + def comment = options?.comment as String + def threadTs = options?.threadTs as String - def uploadUrl = uploadInfo.upload_url as String - def fileId = uploadInfo.file_id as String + // Step 1: Get upload URL (throws on failure) + def uploadInfo = getUploadUrl(filename, fileSize) + if (!uploadInfo) { + throw new RuntimeException("Slack plugin: Failed to get upload URL for file: ${filename}") + } - // Step 2: Upload file content - if (!uploadFileContent(uploadUrl, filePath)) { - return - } + def uploadUrl = uploadInfo.upload_url as String + def fileId = uploadInfo.file_id as String - // Step 3: Complete the upload - completeUpload(fileId, title, channelId, comment, threadTs) + // Step 2: Upload file content (throws on failure) + if (!uploadFileContent(uploadUrl, filePath)) { + throw new RuntimeException("Slack plugin: Failed to upload file content for: ${filename}") + } - log.debug "Slack plugin: Successfully uploaded file: ${filename}" + // Step 3: Complete the upload (throws on failure) + completeUpload(fileId, title, channelId, comment, threadTs) - } catch (Exception e) { - def errorMsg = "Slack plugin: Error uploading file: ${e.message}".toString() - if (loggedErrors.add(errorMsg)) { - log.error errorMsg - } - } + log.debug "Slack plugin: Successfully uploaded file: ${filename}" } /** - * Step 1: Get an external upload URL from Slack + * Step 1: Get an external upload URL from Slack. + * Throws a RuntimeException on failure. * * @param filename The filename to upload * @param length The file size in bytes - * @return Map with upload_url and file_id, or null on failure + * @return Map with upload_url and file_id + * @throws RuntimeException if the API call fails */ protected Map getUploadUrl(String filename, long length) { HttpURLConnection connection = null @@ -175,35 +160,35 @@ class BotSlackSender implements SlackSender { def responseCode = connection.responseCode if (responseCode != 200) { def errorBody = connection.errorStream?.text ?: "" - log.error "Slack plugin: Failed to get upload URL - HTTP ${responseCode}: ${errorBody}" - return null + throw new RuntimeException("Slack plugin: Failed to get upload URL - HTTP ${responseCode}: ${errorBody}") } def responseText = connection.inputStream.text def response = new JsonSlurper().parseText(responseText) as Map if (!response.ok) { - def error = response.error - log.error "Slack plugin: Failed to get upload URL - API error: ${error}" - return null + throw new RuntimeException("Slack plugin: Failed to get upload URL - API error: ${response.error}") } return response + } catch (RuntimeException e) { + throw e } catch (Exception e) { - log.error "Slack plugin: Error getting upload URL: ${e.message}" - return null + throw new RuntimeException("Slack plugin: Error getting upload URL: ${e.message}", e) } finally { connection?.disconnect() } } /** - * Step 2: Upload file content to the external URL + * Step 2: Upload file content to the external URL. + * Throws a RuntimeException on failure. * * @param uploadUrl The URL to upload to (from Step 1) * @param filePath The file to upload * @return true if successful + * @throws RuntimeException if the upload fails */ protected boolean uploadFileContent(String uploadUrl, Path filePath) { HttpURLConnection connection = null @@ -222,28 +207,30 @@ class BotSlackSender implements SlackSender { def responseCode = connection.responseCode if (responseCode != 200) { def errorBody = connection.errorStream?.text ?: "" - log.error "Slack plugin: Failed to upload file content - HTTP ${responseCode}: ${errorBody}" - return false + throw new RuntimeException("Slack plugin: Failed to upload file content - HTTP ${responseCode}: ${errorBody}") } return true + } catch (RuntimeException e) { + throw e } catch (Exception e) { - log.error "Slack plugin: Error uploading file content: ${e.message}" - return false + throw new RuntimeException("Slack plugin: Error uploading file content: ${e.message}", e) } finally { connection?.disconnect() } } /** - * Step 3: Complete the file upload and share to channel + * Step 3: Complete the file upload and share to channel. + * Throws a RuntimeException on failure. * * @param fileId The file ID from Step 1 * @param title The title to display for the file * @param channelId The channel to share the file in * @param comment Optional initial comment * @param threadTs Optional thread timestamp for threading + * @throws RuntimeException if the API call fails */ protected void completeUpload(String fileId, String title, String channelId, String comment, String threadTs) { HttpURLConnection connection = null @@ -277,20 +264,20 @@ class BotSlackSender implements SlackSender { def responseCode = connection.responseCode if (responseCode != 200) { def errorBody = connection.errorStream?.text ?: "" - log.error "Slack plugin: Failed to complete file upload - HTTP ${responseCode}: ${errorBody}" - return + throw new RuntimeException("Slack plugin: Failed to complete file upload - HTTP ${responseCode}: ${errorBody}") } def responseText = connection.inputStream.text def response = new JsonSlurper().parseText(responseText) as Map if (!response.ok) { - def error = response.error - log.error "Slack plugin: Failed to complete file upload - API error: ${error}" + throw new RuntimeException("Slack plugin: Failed to complete file upload - API error: ${response.error}") } + } catch (RuntimeException e) { + throw e } catch (Exception e) { - log.error "Slack plugin: Error completing file upload: ${e.message}" + throw new RuntimeException("Slack plugin: Error completing file upload: ${e.message}", e) } finally { connection?.disconnect() } @@ -370,8 +357,7 @@ class BotSlackSender implements SlackSender { def responseCode = connection.responseCode if (responseCode != 200) { def errorBody = connection.errorStream?.text ?: "" - log.error "Slack plugin: HTTP ${responseCode}: ${errorBody}" - return + throw new RuntimeException("Slack plugin: HTTP ${responseCode}: ${errorBody}") } // Check Slack API 'ok' status @@ -379,29 +365,24 @@ class BotSlackSender implements SlackSender { def response = new JsonSlurper().parseText(responseText) as Map if (!response.ok) { - def error = response.error - def errorMsg = "Slack plugin: API error: ${error}".toString() - if (loggedErrors.add(errorMsg)) { - log.error errorMsg - } - } else { - // Capture the thread timestamp from the response for future threaded replies - def ts = response.ts as String - if (ts && !threadTs) { - threadTs = ts - log.debug "Slack plugin: Captured thread timestamp: ${threadTs}" - } - def channel = response.channel as String - if (channel && !resolvedChannelId) { - resolvedChannelId = channel - } + throw new RuntimeException("Slack plugin: API error: ${response.error}") } - } catch (Exception e) { - def errorMsg = "Slack plugin: Error sending bot message: ${e.message}".toString() - if (loggedErrors.add(errorMsg)) { - log.error errorMsg + // Capture the thread timestamp from the response for future threaded replies + def ts = response.ts as String + if (ts && !threadTs) { + threadTs = ts + log.debug "Slack plugin: Captured thread timestamp: ${threadTs}" } + def channel = response.channel as String + if (channel && !resolvedChannelId) { + resolvedChannelId = channel + } + + } catch (RuntimeException e) { + throw e + } catch (Exception e) { + throw new RuntimeException("Slack plugin: Error sending bot message: ${e.message}", e) } finally { connection?.disconnect() } @@ -418,14 +399,7 @@ class BotSlackSender implements SlackSender { @Override void updateMessage(String message, String messageTs) { - try { - postUpdate(message, messageTs) - } catch (Exception e) { - def errorMsg = "Slack plugin: Error updating message: ${e.message}".toString() - if (loggedErrors.add(errorMsg)) { - log.error errorMsg - } - } + postUpdate(message, messageTs) } protected void postUpdate(String jsonPayload, String messageTs) { @@ -453,18 +427,18 @@ class BotSlackSender implements SlackSender { def responseCode = connection.responseCode if (responseCode != 200) { def errorBody = connection.errorStream?.text ?: "" - log.error "Slack plugin: Failed to update message - HTTP ${responseCode}: ${errorBody}" - return + throw new RuntimeException("Slack plugin: Failed to update message - HTTP ${responseCode}: ${errorBody}") } def responseText = connection.inputStream.text def response = new JsonSlurper().parseText(responseText) as Map if (!response.ok) { - def error = response.error - log.error "Slack plugin: Failed to update message - API error: ${error}" + throw new RuntimeException("Slack plugin: Failed to update message - API error: ${response.error}") } + } catch (RuntimeException e) { + throw e } catch (Exception e) { - log.error "Slack plugin: Error updating message: ${e.message}" + throw new RuntimeException("Slack plugin: Error updating message: ${e.message}", e) } finally { connection?.disconnect() } @@ -472,11 +446,7 @@ class BotSlackSender implements SlackSender { @Override void addReaction(String emoji, String messageTs) { - try { - postReaction(emoji, messageTs) - } catch (Exception e) { - log.debug "Slack plugin: Failed to add reaction '${emoji}': ${e.message}" - } + postReaction(emoji, messageTs) } protected void postReaction(String emoji, String messageTs) { @@ -485,11 +455,7 @@ class BotSlackSender implements SlackSender { @Override void removeReaction(String emoji, String messageTs) { - try { - deleteReaction(emoji, messageTs) - } catch (Exception e) { - log.debug "Slack plugin: Failed to remove reaction '${emoji}': ${e.message}" - } + deleteReaction(emoji, messageTs) } protected void deleteReaction(String emoji, String messageTs) { diff --git a/src/main/groovy/nextflow/slack/SlackConfig.groovy b/src/main/groovy/nextflow/slack/SlackConfig.groovy index 065bbb7..88aa85c 100644 --- a/src/main/groovy/nextflow/slack/SlackConfig.groovy +++ b/src/main/groovy/nextflow/slack/SlackConfig.groovy @@ -89,6 +89,12 @@ class SlackConfig { */ final boolean validateOnStartup + /** + * If true, throw an exception (aborting the pipeline) when a Slack notification fails. + * Default: false (log warning and continue) + */ + final boolean failOnError + /** * Configuration for workflow start notifications */ @@ -130,6 +136,7 @@ class SlackConfig { this.botChannel = botConfig?.channel as String this.useThreads = botConfig?.useThreads != null ? botConfig.useThreads as boolean : true this.validateOnStartup = config.validateOnStartup != null ? config.validateOnStartup as boolean : true + this.failOnError = config.failOnError != null ? config.failOnError as boolean : false this.onStart = new OnStartConfig(config.onStart as Map) this.onComplete = new OnCompleteConfig(config.onComplete as Map) this.onError = new OnErrorConfig(config.onError as Map) @@ -234,6 +241,7 @@ class SlackConfig { return "SlackConfig[enabled=${enabled}, " + "webhook=${webhook ? '***configured***' : 'null'}, " + "botToken=${botToken ? '***configured***' : 'null'}, " + + "failOnError=${failOnError}, " + "onStart=${onStart}, onComplete=${onComplete}, onError=${onError}]" } } diff --git a/src/main/groovy/nextflow/slack/SlackExtension.groovy b/src/main/groovy/nextflow/slack/SlackExtension.groovy index 95e2e99..208273b 100644 --- a/src/main/groovy/nextflow/slack/SlackExtension.groovy +++ b/src/main/groovy/nextflow/slack/SlackExtension.groovy @@ -56,20 +56,20 @@ class SlackExtension extends PluginExtensionPoint { */ @Function void slackMessage(String text) { - try { - // Get the observer instance from factory - def observer = SlackFactory.observerInstance + // Get the observer instance from factory + def observer = SlackFactory.observerInstance - if (!observer) { - log.debug "Slack plugin: Observer not initialized, skipping message" - return - } + if (!observer) { + log.debug "Slack plugin: Observer not initialized, skipping message" + return + } - if (!observer.sender || !observer.messageBuilder) { - log.debug "Slack plugin: Not configured, skipping message" - return - } + if (!observer.sender || !observer.messageBuilder) { + log.debug "Slack plugin: Not configured, skipping message" + return + } + try { // Get thread timestamp if threading is enabled def threadTs = null if (observer.config?.useThreads && observer.sender instanceof BotSlackSender) { @@ -83,8 +83,11 @@ class SlackExtension extends PluginExtensionPoint { log.debug "Slack plugin: Sent custom text message" } catch (Exception e) { - log.error "Slack plugin: Error sending message: ${e.message}", e - // Don't propagate exception - never fail the workflow + def msg = "Slack plugin: Error sending message: ${e.message}" + log.error msg, e + if (observer.config?.failOnError) { + throw new RuntimeException(msg, e) + } } } @@ -106,26 +109,26 @@ class SlackExtension extends PluginExtensionPoint { */ @Function void slackMessage(Map options) { - try { - // Validate required parameters - if (!options.message) { - log.error "Slack plugin: 'message' parameter is required for rich messages" - return - } + // Validate required parameters + if (!options.message) { + log.error "Slack plugin: 'message' parameter is required for rich messages" + return + } - // Get the observer instance from factory - def observer = SlackFactory.observerInstance + // Get the observer instance from factory + def observer = SlackFactory.observerInstance - if (!observer) { - log.debug "Slack plugin: Observer not initialized, skipping message" - return - } + if (!observer) { + log.debug "Slack plugin: Observer not initialized, skipping message" + return + } - if (!observer.sender || !observer.messageBuilder) { - log.debug "Slack plugin: Not configured, skipping message" - return - } + if (!observer.sender || !observer.messageBuilder) { + log.debug "Slack plugin: Not configured, skipping message" + return + } + try { // Get thread timestamp if threading is enabled def threadTs = null if (observer.config?.useThreads && observer.sender instanceof BotSlackSender) { @@ -139,8 +142,11 @@ class SlackExtension extends PluginExtensionPoint { log.debug "Slack plugin: Sent custom rich message" } catch (Exception e) { - log.error "Slack plugin: Error sending rich message: ${e.message}", e - // Don't propagate exception - never fail the workflow + def msg = "Slack plugin: Error sending rich message: ${e.message}" + log.error msg, e + if (observer.config?.failOnError) { + throw new RuntimeException(msg, e) + } } } @@ -176,26 +182,26 @@ class SlackExtension extends PluginExtensionPoint { */ @Function void slackFileUpload(Map options) { - try { - // Validate required parameters - if (!options.file) { - log.error "Slack plugin: 'file' parameter is required for file upload" - return - } + // Validate required parameters + if (!options.file) { + log.error "Slack plugin: 'file' parameter is required for file upload" + return + } - // Get the observer instance from factory - def observer = SlackFactory.observerInstance + // Get the observer instance from factory + def observer = SlackFactory.observerInstance - if (!observer) { - log.debug "Slack plugin: Observer not initialized, skipping file upload" - return - } + if (!observer) { + log.debug "Slack plugin: Observer not initialized, skipping file upload" + return + } - if (!observer.sender) { - log.debug "Slack plugin: Not configured, skipping file upload" - return - } + if (!observer.sender) { + log.debug "Slack plugin: Not configured, skipping file upload" + return + } + try { // Resolve file path def file = options.file Path path @@ -223,8 +229,11 @@ class SlackExtension extends PluginExtensionPoint { log.debug "Slack plugin: Uploaded file ${path.fileName}" } catch (Exception e) { - log.error "Slack plugin: Error uploading file: ${e.message}", e - // Don't propagate exception - never fail the workflow + def msg = "Slack plugin: Error uploading file: ${e.message}" + log.error msg, e + if (observer.config?.failOnError) { + throw new RuntimeException(msg, e) + } } } } diff --git a/src/main/groovy/nextflow/slack/SlackObserver.groovy b/src/main/groovy/nextflow/slack/SlackObserver.groovy index 2a82ace..b133485 100644 --- a/src/main/groovy/nextflow/slack/SlackObserver.groovy +++ b/src/main/groovy/nextflow/slack/SlackObserver.groovy @@ -98,9 +98,13 @@ class SlackObserver implements TraceObserver { // Send workflow started notification if enabled if (config.onStart.enabled) { - def message = messageBuilder.buildWorkflowStartMessage() - sender.sendMessage(message) - log.debug "Slack plugin: Sent workflow start notification" + try { + def message = messageBuilder.buildWorkflowStartMessage() + sender.sendMessage(message) + log.debug "Slack plugin: Sent workflow start notification" + } catch (Exception e) { + handleNotificationError("send workflow start notification", e) + } } // Set up progress updates if enabled and using bot sender @@ -193,13 +197,17 @@ class SlackObserver implements TraceObserver { if (isSuccess) { // Send completion message if enabled if (config.onComplete.enabled) { - def threadTs = getThreadTsIfEnabled() - def message = messageBuilder.buildWorkflowCompleteMessage(threadTs) - sender.sendMessage(message) - log.debug "Slack plugin: Sent workflow complete notification" + try { + def threadTs = getThreadTsIfEnabled() + def message = messageBuilder.buildWorkflowCompleteMessage(threadTs) + sender.sendMessage(message) + log.debug "Slack plugin: Sent workflow complete notification" + } catch (Exception e) { + handleNotificationError("send workflow complete notification", e) + } // Upload configured files - uploadConfiguredFiles(config.onComplete.files, threadTs) + uploadConfiguredFiles(config.onComplete.files, getThreadTsIfEnabled()) } // Handle reactions independently of notification @@ -229,14 +237,17 @@ class SlackObserver implements TraceObserver { if (!isConfigured()) return if (config.onError.enabled) { - // Get thread timestamp if threading is enabled and we're using bot sender - def threadTs = getThreadTsIfEnabled() - def message = messageBuilder.buildWorkflowErrorMessage(trace, threadTs) - sender.sendMessage(message) - log.debug "Slack plugin: Sent workflow error notification" + try { + def threadTs = getThreadTsIfEnabled() + def message = messageBuilder.buildWorkflowErrorMessage(trace, threadTs) + sender.sendMessage(message) + log.debug "Slack plugin: Sent workflow error notification" + } catch (Exception e) { + handleNotificationError("send workflow error notification", e) + } // Upload configured files - uploadConfiguredFiles(config.onError.files, threadTs) + uploadConfiguredFiles(config.onError.files, getThreadTsIfEnabled()) } if (startReactionAdded) { @@ -246,7 +257,8 @@ class SlackObserver implements TraceObserver { } /** - * Upload files configured in the notification config + * Upload files configured in the notification config. + * Respects failOnError: throws on failure when enabled, logs and continues otherwise. */ private void uploadConfiguredFiles(List files, String threadTs) { if (!files) return @@ -262,13 +274,14 @@ class SlackObserver implements TraceObserver { log.debug "Slack plugin: Uploaded file ${filePath}" } catch (Exception e) { - log.warn "Slack plugin: Failed to upload file ${filePath}: ${e.message}" + handleNotificationError("upload file ${filePath}", e) } } } /** - * Add an emoji reaction to the start message if reactions are enabled + * Add an emoji reaction to the start message if reactions are enabled. + * Respects failOnError: throws on failure when enabled, logs and continues otherwise. */ private void addReactionIfEnabled(String emoji) { if (!emoji) return @@ -282,12 +295,13 @@ class SlackObserver implements TraceObserver { } } catch (Exception e) { - log.debug "Slack plugin: Failed to add reaction: ${e.message}" + handleNotificationError("add reaction '${emoji}'", e) } } /** - * Remove an emoji reaction from the start message if reactions are enabled + * Remove an emoji reaction from the start message if reactions are enabled. + * Respects failOnError: throws on failure when enabled, logs and continues otherwise. */ private void removeReactionIfEnabled(String emoji) { if (!emoji) return @@ -301,7 +315,7 @@ class SlackObserver implements TraceObserver { } } catch (Exception e) { - log.debug "Slack plugin: Failed to remove reaction: ${e.message}" + handleNotificationError("remove reaction '${emoji}'", e) } } @@ -315,6 +329,23 @@ class SlackObserver implements TraceObserver { return null } + /** + * Handle a notification error based on failOnError configuration. + * When failOnError is false (default): logs a warning and continues. + * When failOnError is true: logs a warning and throws to abort the pipeline. + * + * @param description Human-readable description of the failed operation + * @param e The exception that caused the failure + * @throws RuntimeException if failOnError is true + */ + private void handleNotificationError(String description, Exception e) { + def msg = "Slack plugin: Failed to ${description}: ${e.message}" + log.warn msg + if (config?.failOnError) { + throw new RuntimeException(msg, e) + } + } + /** * Check if the observer is properly configured */ diff --git a/src/main/groovy/nextflow/slack/WebhookSlackSender.groovy b/src/main/groovy/nextflow/slack/WebhookSlackSender.groovy index 19c920e..b7b91d9 100644 --- a/src/main/groovy/nextflow/slack/WebhookSlackSender.groovy +++ b/src/main/groovy/nextflow/slack/WebhookSlackSender.groovy @@ -34,7 +34,6 @@ import groovy.util.logging.Slf4j class WebhookSlackSender implements SlackSender { private final String webhookUrl - private final Set loggedErrors = Collections.synchronizedSet(new HashSet()) /** * Create a new WebhookSlackSender with the given webhook URL @@ -44,15 +43,18 @@ class WebhookSlackSender implements SlackSender { } /** - * Send a message to Slack webhook + * Send a message to Slack webhook. + * Throws a RuntimeException if the message cannot be delivered. * * @param message JSON message payload + * @throws RuntimeException if the webhook call fails */ @Override void sendMessage(String message) { + HttpURLConnection connection = null try { def url = new URL(webhookUrl) - def connection = url.openConnection() as HttpURLConnection + connection = url.openConnection() as HttpURLConnection connection.requestMethod = 'POST' connection.doOutput = true connection.setRequestProperty('Content-type', 'application/json') @@ -65,18 +67,14 @@ class WebhookSlackSender implements SlackSender { def responseCode = connection.responseCode if (responseCode != 200) { def errorBody = connection.errorStream?.text ?: "" - def errorMsg = "Slack webhook HTTP ${responseCode}: ${errorBody}".toString() - if (loggedErrors.add(errorMsg)) { - log.error errorMsg - } + throw new RuntimeException("Slack webhook HTTP ${responseCode}: ${errorBody}") } - - connection.disconnect() + } catch (RuntimeException e) { + throw e } catch (Exception e) { - def errorMsg = "Slack plugin: Error sending message: ${e.message}".toString() - if (loggedErrors.add(errorMsg)) { - log.error errorMsg - } + throw new RuntimeException("Slack plugin: Error sending webhook message: ${e.message}", e) + } finally { + connection?.disconnect() } } diff --git a/src/test/groovy/nextflow/slack/BotSlackSenderTest.groovy b/src/test/groovy/nextflow/slack/BotSlackSenderTest.groovy index ed526b9..2f1eae3 100644 --- a/src/test/groovy/nextflow/slack/BotSlackSenderTest.groovy +++ b/src/test/groovy/nextflow/slack/BotSlackSenderTest.groovy @@ -17,6 +17,7 @@ package nextflow.slack import spock.lang.Specification +import java.net.ConnectException import java.nio.file.Path /** @@ -32,22 +33,22 @@ class BotSlackSenderTest extends Specification { sender != null } - def 'should handle message sending gracefully'() { + def 'should throw exception when sending message fails'() { when: def sender = new BotSlackSender('xoxb-token', 'C123456') sender.sendMessage('{"text":"test"}') then: - noExceptionThrown() + thrown(RuntimeException) } - def 'should handle invalid JSON gracefully'() { + def 'should throw exception for invalid JSON payload'() { when: def sender = new BotSlackSender('xoxb-token', 'C123456') sender.sendMessage('not valid json') then: - noExceptionThrown() + thrown(RuntimeException) } def 'should return null for threadTs initially'() { @@ -69,7 +70,7 @@ class BotSlackSenderTest extends Specification { threadTs == null || threadTs instanceof String } - def 'should handle file upload gracefully when API is unreachable'() { + def 'should throw exception when file upload API is unreachable'() { given: def sender = new BotSlackSender('xoxb-token', 'C123456') def tempFile = File.createTempFile('test', '.txt') @@ -79,13 +80,13 @@ class BotSlackSenderTest extends Specification { sender.uploadFile(tempFile.toPath(), [:]) then: - noExceptionThrown() + thrown(RuntimeException) cleanup: tempFile.delete() } - def 'should handle file upload for non-existent file gracefully'() { + def 'should throw exception for non-existent file'() { given: def sender = new BotSlackSender('xoxb-token', 'C123456') def nonExistentPath = java.nio.file.Paths.get('/tmp/non-existent-file-' + System.nanoTime() + '.txt') @@ -94,10 +95,10 @@ class BotSlackSenderTest extends Specification { sender.uploadFile(nonExistentPath, [:]) then: - noExceptionThrown() + thrown(IllegalArgumentException) } - def 'should handle file upload for empty file gracefully'() { + def 'should throw exception for empty file'() { given: def sender = new BotSlackSender('xoxb-token', 'C123456') def emptyFile = File.createTempFile('test-empty', '.txt') @@ -107,13 +108,13 @@ class BotSlackSenderTest extends Specification { sender.uploadFile(emptyFile.toPath(), [:]) then: - noExceptionThrown() + thrown(IllegalArgumentException) cleanup: emptyFile.delete() } - def 'should handle file upload for unreadable file gracefully'() { + def 'should throw exception for unreadable file'() { given: def sender = new BotSlackSender('xoxb-token', 'C123456') def unreadableFile = File.createTempFile('test-unreadable', '.txt') @@ -124,14 +125,14 @@ class BotSlackSenderTest extends Specification { sender.uploadFile(unreadableFile.toPath(), [:]) then: - noExceptionThrown() + thrown(IllegalArgumentException) cleanup: unreadableFile.setReadable(true) unreadableFile.delete() } - def 'should accept file upload with custom options'() { + def 'should throw exception when upload API unreachable even with custom options'() { given: def sender = new BotSlackSender('xoxb-token', 'C123456') def tempFile = File.createTempFile('test-options', '.png') @@ -146,14 +147,13 @@ class BotSlackSenderTest extends Specification { ]) then: - // API call will fail but should not throw (graceful handling) - noExceptionThrown() + thrown(RuntimeException) cleanup: tempFile.delete() } - def 'should use filename from path when not specified in options'() { + def 'should throw exception when upload API unreachable using default filename'() { given: def sender = new BotSlackSender('xoxb-token', 'C123456') def tempFile = File.createTempFile('test-default-name', '.txt') @@ -163,8 +163,7 @@ class BotSlackSenderTest extends Specification { sender.uploadFile(tempFile.toPath(), [:]) then: - // Graceful handling - API unreachable but no exception - noExceptionThrown() + thrown(RuntimeException) cleanup: tempFile.delete() @@ -244,7 +243,7 @@ class BotSlackSenderTest extends Specification { tempFile?.delete() } - def 'should stop upload flow when getUploadUrl fails'() { + def 'should throw exception and stop upload flow when getUploadUrl fails'() { given: def uploadContentCalled = false def completeUploadCalled = false @@ -270,7 +269,7 @@ class BotSlackSenderTest extends Specification { sender.uploadFile(tempFile.toPath(), [:]) then: - noExceptionThrown() + thrown(RuntimeException) !uploadContentCalled !completeUploadCalled @@ -278,7 +277,7 @@ class BotSlackSenderTest extends Specification { tempFile?.delete() } - def 'should call updateMessage gracefully when API is unreachable'() { + def 'should throw exception when updateMessage API is unreachable'() { given: def sender = new BotSlackSender('xoxb-test-token', 'C123456') @@ -286,10 +285,10 @@ class BotSlackSenderTest extends Specification { sender.updateMessage('{"text":"progress update"}', '1234567890.123456') then: - noExceptionThrown() + thrown(RuntimeException) } - def 'should add reaction gracefully when API unreachable' () { + def 'should propagate exception from addReaction when API unreachable' () { given: def sender = new BotSlackSender('xoxb-test-token', 'C123456') { @Override @@ -302,7 +301,7 @@ class BotSlackSenderTest extends Specification { sender.addReaction('white_check_mark', '1234567890.123456') then: - noExceptionThrown() + thrown(ConnectException) } def 'should call postReaction with correct parameters' () { @@ -325,7 +324,7 @@ class BotSlackSenderTest extends Specification { capturedTs == '1234567890.123456' } - def 'should remove reaction gracefully when API unreachable' () { + def 'should propagate exception from removeReaction when API unreachable' () { given: def sender = new BotSlackSender('xoxb-test-token', 'C123456') { @Override @@ -338,7 +337,7 @@ class BotSlackSenderTest extends Specification { sender.removeReaction('rocket', '1234567890.123456') then: - noExceptionThrown() + thrown(ConnectException) } def 'should call deleteReaction with correct parameters' () { @@ -361,7 +360,7 @@ class BotSlackSenderTest extends Specification { capturedTs == '1234567890.123456' } - def 'should handle non-200 HTTP response from postReaction without throwing' () { + def 'should throw exception on non-200 HTTP response from postReaction' () { given: def sender = new BotSlackSender('xoxb-test-token', 'C123456') @@ -369,7 +368,7 @@ class BotSlackSenderTest extends Specification { sender.addReaction('rocket', '1234567890.123456') then: - noExceptionThrown() + thrown(Exception) } def 'should return false when validate hits unreachable endpoint'() { diff --git a/src/test/groovy/nextflow/slack/SlackConfigTest.groovy b/src/test/groovy/nextflow/slack/SlackConfigTest.groovy index 24959d5..263e7a7 100644 --- a/src/test/groovy/nextflow/slack/SlackConfigTest.groovy +++ b/src/test/groovy/nextflow/slack/SlackConfigTest.groovy @@ -497,4 +497,63 @@ class SlackConfigTest extends Specification { config.seqeraPlatform != null config.seqeraPlatform.enabled == false } + + def 'should default failOnError to false'() { + given: + def session = Mock(Session) + session.config >> [ + slack: [ + webhook: [ + url: 'https://hooks.slack.com/services/TEST/TEST/TEST' + ] + ] + ] + + when: + def config = SlackConfig.from(session) + + then: + config != null + config.failOnError == false + } + + def 'should parse failOnError when set to true'() { + given: + def session = Mock(Session) + session.config >> [ + slack: [ + webhook: [ + url: 'https://hooks.slack.com/services/TEST/TEST/TEST' + ], + failOnError: true + ] + ] + + when: + def config = SlackConfig.from(session) + + then: + config != null + config.failOnError == true + } + + def 'should parse failOnError when set to false explicitly'() { + given: + def session = Mock(Session) + session.config >> [ + slack: [ + webhook: [ + url: 'https://hooks.slack.com/services/TEST/TEST/TEST' + ], + failOnError: false + ] + ] + + when: + def config = SlackConfig.from(session) + + then: + config != null + config.failOnError == false + } } diff --git a/src/test/groovy/nextflow/slack/SlackObserverTest.groovy b/src/test/groovy/nextflow/slack/SlackObserverTest.groovy index b71b5de..b7db7d8 100644 --- a/src/test/groovy/nextflow/slack/SlackObserverTest.groovy +++ b/src/test/groovy/nextflow/slack/SlackObserverTest.groovy @@ -912,4 +912,141 @@ class SlackObserverTest extends Specification { 1 * mockSender.removeReaction('rocket', '1234567890.123456') noExceptionThrown() } + + def 'should throw exception on notification failure when failOnError is true'() { + given: + def mockSender = Mock(SlackSender) + mockSender.sendMessage(_) >> { throw new RuntimeException("Slack API error") } + + def config = new SlackConfig([ + enabled: true, + bot: [token: 'xoxb-test-token', channel: 'C123456'], + failOnError: true, + validateOnStartup: false, + onError: [enabled: true] + ]) + def observer = new SlackObserver() + observer.setConfig(config) + observer.setSender(mockSender) + observer.setMessageBuilder(Mock(SlackMessageBuilder)) + + when: + observer.onFlowError(null, Mock(TraceRecord)) + + then: + thrown(RuntimeException) + } + + def 'should log and continue on notification failure when failOnError is false'() { + given: + def mockSender = Mock(SlackSender) + mockSender.sendMessage(_) >> { throw new RuntimeException("Slack API error") } + + def config = new SlackConfig([ + enabled: true, + bot: [token: 'xoxb-test-token', channel: 'C123456'], + failOnError: false, + validateOnStartup: false, + onError: [enabled: true] + ]) + def observer = new SlackObserver() + observer.setConfig(config) + observer.setSender(mockSender) + observer.setMessageBuilder(Mock(SlackMessageBuilder)) + + when: + observer.onFlowError(null, Mock(TraceRecord)) + + then: + noExceptionThrown() + } + + def 'should throw exception on complete notification failure when failOnError is true'() { + given: + def mockSender = Mock(SlackSender) + mockSender.sendMessage(_) >> { throw new RuntimeException("Slack API error") } + def mockSession = Mock(Session) + def mockMetadata = Mock(WorkflowMetadata) + mockMetadata.success >> true + mockSession.workflowMetadata >> mockMetadata + + def config = new SlackConfig([ + enabled: true, + bot: [token: 'xoxb-test-token', channel: 'C123456'], + failOnError: true, + validateOnStartup: false, + onComplete: [enabled: true] + ]) + def observer = new SlackObserver() + observer.setConfig(config) + observer.setSender(mockSender) + observer.setSession(mockSession) + observer.setMessageBuilder(Mock(SlackMessageBuilder)) + + when: + observer.onFlowComplete() + + then: + thrown(RuntimeException) + } + + def 'should throw exception on start notification failure when failOnError is true'() { + given: + def mockSender = Mock(SlackSender) + mockSender.validate() >> true + mockSender.sendMessage(_) >> { throw new RuntimeException("Slack API error") } + def mockSession = Mock(Session) + mockSession.config >> [:] + def mockMetadata = Mock(WorkflowMetadata) + mockMetadata.scriptName >> 'test.nf' + mockSession.workflowMetadata >> mockMetadata + mockSession.runName >> 'test-run' + + def config = new SlackConfig([ + enabled: true, + bot: [token: 'xoxb-test-token', channel: 'C123456'], + failOnError: true, + validateOnStartup: false, + onStart: [enabled: true] + ]) + def observer = new SlackObserver() + observer.setConfig(config) + observer.setSender(mockSender) + observer.setMessageBuilder(Mock(SlackMessageBuilder)) + + when: + observer.onFlowCreate(mockSession) + + then: + thrown(RuntimeException) + } + + def 'should throw exception on file upload failure when failOnError is true'() { + given: + def mockSender = Mock(SlackSender) + mockSender.uploadFile(_, _) >> { throw new RuntimeException("Upload failed") } + def mockSession = Mock(Session) + def mockMetadata = Mock(WorkflowMetadata) + mockMetadata.success >> true + mockSession.workflowMetadata >> mockMetadata + + def config = new SlackConfig([ + enabled: true, + bot: [token: 'xoxb-test-token', channel: 'C123456'], + failOnError: true, + validateOnStartup: false, + onComplete: [enabled: true, files: ['results/report.html']] + ]) + def observer = new SlackObserver() + observer.setConfig(config) + observer.setSender(mockSender) + observer.setSession(mockSession) + observer.setMessageBuilder(Mock(SlackMessageBuilder)) + + when: + observer.onFlowComplete() + + then: + thrown(RuntimeException) + } } From ac9a5482de1f9f72259b29e2d35a604e5860f301 Mon Sep 17 00:00:00 2001 From: adamrtalbot <12817534+adamrtalbot@users.noreply.github.com> Date: Tue, 30 Jun 2026 16:34:57 +0100 Subject: [PATCH 2/2] fix: address Claude review on failOnError option Document failOnError, add extension tests, preserve DEBUG logging for reaction failures when disabled, and align sender tests with throw behavior. Generated by Codex Co-authored-by: Cursor --- CHANGELOG.md | 4 + docs/reference/api.md | 1 + docs/usage/guide.md | 16 +++ example/configs/01-minimal.config | 1 + .../nextflow/slack/BotSlackSender.groovy | 11 +- .../nextflow/slack/SlackObserver.groovy | 13 ++- .../nextflow/slack/SlackClientTest.groovy | 8 +- .../nextflow/slack/SlackExtensionTest.groovy | 103 ++++++++++++++++++ 8 files changed, 147 insertions(+), 10 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 69a7a9e..997f67f 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,10 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] +### Added + +- `slack.failOnError` option to abort the pipeline when Slack notification operations fail ([#54](https://github.com/seqeralabs/nf-slack/pull/54)) + ## [0.5.1] - 2026-02-19 ### Fixed diff --git a/docs/reference/api.md b/docs/reference/api.md index 82406d2..674e6ce 100644 --- a/docs/reference/api.md +++ b/docs/reference/api.md @@ -23,6 +23,7 @@ Complete API reference for nf-slack plugin configuration options and functions. | `onComplete` | Closure | See [`slack.onComplete`](#slackoncomplete) | No | Configuration for workflow completion notifications | | `onError` | Closure | See [`slack.onError`](#slackonerror) | No | Configuration for workflow error notifications | | `validateOnStartup` | Boolean | `true` | No | Validate Slack connection credentials on pipeline startup | +| `failOnError` | Boolean | `false` | No | Abort the pipeline when a Slack notification operation fails | \*Either `webhook` or `bot` is required. If neither is configured, the plugin will automatically disable itself. diff --git a/docs/usage/guide.md b/docs/usage/guide.md index cc52ca2..065dedf 100644 --- a/docs/usage/guide.md +++ b/docs/usage/guide.md @@ -351,6 +351,22 @@ slack { This checks the token/webhook and channel access at pipeline start, failing fast if there's a configuration problem. +## Fail on Notification Errors + +By default, Slack notification failures are logged and the pipeline continues. Set `failOnError = true` to abort the pipeline when any notification operation fails (messages, file uploads, reactions, or extension calls): + +```groovy +slack { + bot { + token = System.getenv('SLACK_BOT_TOKEN') + channel = 'general' + } + failOnError = true +} +``` + +Reaction failures continue to log at `DEBUG` when `failOnError` is `false` (the default). + ## What's Next - [Examples](../examples/gallery.md) — Copy-paste configurations for common scenarios diff --git a/example/configs/01-minimal.config b/example/configs/01-minimal.config index 68591e4..50aba67 100644 --- a/example/configs/01-minimal.config +++ b/example/configs/01-minimal.config @@ -4,6 +4,7 @@ plugins { slack { enabled = true + // failOnError = true // Uncomment to abort the pipeline on notification failures bot { token = System.getenv('SLACK_BOT_TOKEN') channel = System.getenv('SLACK_CHANNEL_ID') diff --git a/src/main/groovy/nextflow/slack/BotSlackSender.groovy b/src/main/groovy/nextflow/slack/BotSlackSender.groovy index 003e112..cfabb20 100644 --- a/src/main/groovy/nextflow/slack/BotSlackSender.groovy +++ b/src/main/groovy/nextflow/slack/BotSlackSender.groovy @@ -491,12 +491,19 @@ class BotSlackSender implements SlackSender { log.debug "Slack plugin: Reaction '${emoji}' ${action} skipped: ${response.error}" } else { def hint = response.error == 'missing_scope' ? ' (add reactions:write scope to your Slack app)' : '' - log.warn "Slack plugin: Failed to ${action} reaction '${emoji}': ${response.error}${hint}" + throw new RuntimeException("Slack plugin: Failed to ${action} reaction '${emoji}': ${response.error}${hint}") } } } else { - log.debug "Slack plugin: Failed to ${action} reaction - HTTP ${responseCode}" + def errorBody = connection.errorStream?.text ?: 'No error details' + throw new RuntimeException("Slack plugin: Failed to ${action} reaction - HTTP ${responseCode}: ${errorBody}") } + } + catch (RuntimeException e) { + throw e + } + catch (Exception e) { + throw new RuntimeException("Slack plugin: Error ${action} reaction '${emoji}': ${e.message}", e) } finally { connection?.disconnect() } diff --git a/src/main/groovy/nextflow/slack/SlackObserver.groovy b/src/main/groovy/nextflow/slack/SlackObserver.groovy index b133485..44f689c 100644 --- a/src/main/groovy/nextflow/slack/SlackObserver.groovy +++ b/src/main/groovy/nextflow/slack/SlackObserver.groovy @@ -295,7 +295,7 @@ class SlackObserver implements TraceObserver { } } catch (Exception e) { - handleNotificationError("add reaction '${emoji}'", e) + handleNotificationError("add reaction '${emoji}'", e, true) } } @@ -315,7 +315,7 @@ class SlackObserver implements TraceObserver { } } catch (Exception e) { - handleNotificationError("remove reaction '${emoji}'", e) + handleNotificationError("remove reaction '${emoji}'", e, true) } } @@ -338,12 +338,17 @@ class SlackObserver implements TraceObserver { * @param e The exception that caused the failure * @throws RuntimeException if failOnError is true */ - private void handleNotificationError(String description, Exception e) { + private void handleNotificationError(String description, Exception e, boolean debugWhenNotFatal = false) { def msg = "Slack plugin: Failed to ${description}: ${e.message}" - log.warn msg if (config?.failOnError) { + log.warn msg throw new RuntimeException(msg, e) } + if (debugWhenNotFatal) { + log.debug msg + } else { + log.warn msg + } } /** diff --git a/src/test/groovy/nextflow/slack/SlackClientTest.groovy b/src/test/groovy/nextflow/slack/SlackClientTest.groovy index 2a1a911..5e8ae69 100644 --- a/src/test/groovy/nextflow/slack/SlackClientTest.groovy +++ b/src/test/groovy/nextflow/slack/SlackClientTest.groovy @@ -34,22 +34,22 @@ class SlackClientTest extends Specification { sender != null } - def 'should handle null webhook URL gracefully'() { + def 'should throw when webhook URL is null'() { when: def sender = new WebhookSlackSender(null) sender.sendMessage('{"text":"test"}') then: - noExceptionThrown() + thrown(RuntimeException) } - def 'should handle invalid JSON gracefully'() { + def 'should throw when webhook delivery fails'() { when: def sender = new WebhookSlackSender('https://hooks.slack.com/services/TEST/TEST/TEST') sender.sendMessage('not valid json') then: - noExceptionThrown() + thrown(RuntimeException) } def 'should handle file upload gracefully with warning'() { diff --git a/src/test/groovy/nextflow/slack/SlackExtensionTest.groovy b/src/test/groovy/nextflow/slack/SlackExtensionTest.groovy index 583dc90..62de657 100644 --- a/src/test/groovy/nextflow/slack/SlackExtensionTest.groovy +++ b/src/test/groovy/nextflow/slack/SlackExtensionTest.groovy @@ -281,4 +281,107 @@ class SlackExtensionTest extends Specification { cleanup: tempFile.delete() } + + def 'should throw when slackMessage fails and failOnError is true'() { + given: + def mockBotSender = Mock(BotSlackSender) + def mockObserver = Mock(SlackObserver) + def mockMessageBuilder = Mock(SlackMessageBuilder) + def mockSession = Mock(Session) + mockSession.config >> [slack: [bot: [token: 'xoxb-test', channel: 'C123'], failOnError: true, validateOnStartup: false]] + def config = SlackConfig.from(mockSession) + + mockObserver.sender >> mockBotSender + mockObserver.messageBuilder >> mockMessageBuilder + mockObserver.config >> config + + SlackFactory.observerInstance = mockObserver + def extension = new SlackExtension() + + when: + extension.slackMessage('Test message') + + then: + 1 * mockMessageBuilder.buildSimpleMessage('Test message', _) >> '{"text":"Test message"}' + 1 * mockBotSender.sendMessage(_) >> { throw new IOException('network error') } + thrown(RuntimeException) + } + + def 'should continue when slackMessage fails and failOnError is false'() { + given: + def mockBotSender = Mock(BotSlackSender) + def mockObserver = Mock(SlackObserver) + def mockMessageBuilder = Mock(SlackMessageBuilder) + def mockSession = Mock(Session) + mockSession.config >> [slack: [bot: [token: 'xoxb-test', channel: 'C123'], failOnError: false, validateOnStartup: false]] + def config = SlackConfig.from(mockSession) + + mockObserver.sender >> mockBotSender + mockObserver.messageBuilder >> mockMessageBuilder + mockObserver.config >> config + + SlackFactory.observerInstance = mockObserver + def extension = new SlackExtension() + + when: + extension.slackMessage('Test message') + + then: + 1 * mockMessageBuilder.buildSimpleMessage('Test message', _) >> '{"text":"Test message"}' + 1 * mockBotSender.sendMessage(_) >> { throw new IOException('network error') } + noExceptionThrown() + } + + def 'should throw when rich slackMessage fails and failOnError is true'() { + given: + def mockBotSender = Mock(BotSlackSender) + def mockObserver = Mock(SlackObserver) + def mockMessageBuilder = Mock(SlackMessageBuilder) + def options = [message: 'Rich message'] + def mockSession = Mock(Session) + mockSession.config >> [slack: [bot: [token: 'xoxb-test', channel: 'C123'], failOnError: true, validateOnStartup: false]] + def config = SlackConfig.from(mockSession) + + mockObserver.sender >> mockBotSender + mockObserver.messageBuilder >> mockMessageBuilder + mockObserver.config >> config + + SlackFactory.observerInstance = mockObserver + def extension = new SlackExtension() + + when: + extension.slackMessage(options) + + then: + 1 * mockMessageBuilder.buildRichMessage(options, _) >> '{"blocks":[]}' + 1 * mockBotSender.sendMessage(_) >> { throw new IOException('network error') } + thrown(RuntimeException) + } + + def 'should throw when slackFileUpload fails and failOnError is true'() { + given: + def mockBotSender = Mock(BotSlackSender) + def mockObserver = Mock(SlackObserver) + def mockSession = Mock(Session) + mockSession.config >> [slack: [bot: [token: 'xoxb-test', channel: 'C123'], failOnError: true, validateOnStartup: false]] + def config = SlackConfig.from(mockSession) + + mockObserver.sender >> mockBotSender + mockObserver.config >> config + + SlackFactory.observerInstance = mockObserver + def extension = new SlackExtension() + def tempFile = File.createTempFile('test-fail', '.txt') + tempFile.text = 'test content' + + when: + extension.slackFileUpload(tempFile.absolutePath) + + then: + 1 * mockBotSender.uploadFile(_, _) >> { throw new IOException('upload failed') } + thrown(RuntimeException) + + cleanup: + tempFile.delete() + } }