diff --git a/RELEASE-NOTES.md b/RELEASE-NOTES.md index 489af3a6..190fe59e 100644 --- a/RELEASE-NOTES.md +++ b/RELEASE-NOTES.md @@ -9,6 +9,19 @@ The release download file is: OpenAS2Server-4.13.0.zip The zip file contains a PDF document (OpenAS2HowTo.pdf) providing information on installing and using the application. ## NOTE: Testing covers Java 17 to 21. +Version UNRELEASED +=========================== + +This is a minor enhancement and bugfix release. +1. Fix the AS2 sender's "readtimeout" having no effect, and allow the send timeouts to be set per partnership. When + "sockettimeout" replaced "readtimeout" as the setting for how long to wait for a partner's response, the shipped + config.xml and the HowTo kept setting "readtimeout", which was then read and never applied, so sends always timed out + after 60 seconds whatever it said. "readtimeout" is honoured again as the socket timeout when "sockettimeout" is not + set. A partnership can also set its own "connecttimeout" and "sockettimeout" attributes, in milliseconds, so a slow + partner can be given longer without raising the timeout for every partner. Note that a "readtimeout" set in + openas2.properties now takes effect: the sample file sets module.AS2SenderModule.readtimeout=3000000, 50 minutes, so + check the value if your properties file was copied from it. + Version 4.14.0 2026-10-13 =========================== diff --git a/Server/src/main/java/org/openas2/processor/sender/AS2SenderModule.java b/Server/src/main/java/org/openas2/processor/sender/AS2SenderModule.java index 81695db4..f0421ed4 100644 --- a/Server/src/main/java/org/openas2/processor/sender/AS2SenderModule.java +++ b/Server/src/main/java/org/openas2/processor/sender/AS2SenderModule.java @@ -189,7 +189,7 @@ private void sendMessage(String url, Message msg, MimeBodyPart securedData) thro logger.info("Connecting to: " + url + msg.getLogMsgID()); } - Map httpOptions = getHttpOptions(); + Map httpOptions = getHttpOptions(msg.getPartnership()); httpOptions.put(HTTPUtil.PARAM_HTTP_USER, msg.getPartnership().getAttribute(HTTPUtil.PARAM_HTTP_USER)); httpOptions.put(HTTPUtil.PARAM_HTTP_PWD, msg.getPartnership().getAttribute(HTTPUtil.PARAM_HTTP_PWD)); // Mutual TLS client certificate - partnership attributes override global properties diff --git a/Server/src/main/java/org/openas2/processor/sender/HttpSenderModule.java b/Server/src/main/java/org/openas2/processor/sender/HttpSenderModule.java index 000c198d..f737683b 100644 --- a/Server/src/main/java/org/openas2/processor/sender/HttpSenderModule.java +++ b/Server/src/main/java/org/openas2/processor/sender/HttpSenderModule.java @@ -7,22 +7,31 @@ import org.openas2.OpenAS2Exception; import org.openas2.cert.CertificateFactory; import org.openas2.cert.X509CertificateFactory; +import org.openas2.partner.Partnership; import org.openas2.util.HTTPUtil; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; public abstract class HttpSenderModule extends BaseSenderModule { + /* + * The original name for the socket timeout, still used by the shipped config.xml and the HowTo. + * It was mapped to the wrong HttpClient setting and then replaced by "sockettimeout", which left it + * read but never applied, so it is honoured as the socket timeout when that is not set. + */ public static final String PARAM_READ_TIMEOUT = "readtimeout"; public static final String PARAM_CONNECT_TIMEOUT = "connecttimeout"; public static final String PARAM_SOCKET_TIMEOUT = "sockettimeout"; + public static final String DEFAULT_TIMEOUT_MILLIS = "60000"; public static final String PARAM_CUSTOM_SSL_TRUST_STORE = "custom_ssl_trust_store"; - // private Logger logger = LoggerFactory.getLogger(HttpSenderModule.class); + private static final Logger LOG = LoggerFactory.getLogger(HttpSenderModule.class); public Map getHttpOptions() throws OpenAS2Exception { Map options = new HashMap(); - options.put(HTTPUtil.PARAM_READ_TIMEOUT, getParameter(PARAM_READ_TIMEOUT, "60000")); - options.put(HTTPUtil.PARAM_CONNECT_TIMEOUT, getParameter(PARAM_CONNECT_TIMEOUT, "60000")); - options.put(HTTPUtil.PARAM_SOCKET_TIMEOUT, getParameter(PARAM_SOCKET_TIMEOUT, "60000")); + options.put(HTTPUtil.PARAM_READ_TIMEOUT, getParameter(PARAM_READ_TIMEOUT, DEFAULT_TIMEOUT_MILLIS)); + options.put(HTTPUtil.PARAM_CONNECT_TIMEOUT, getParameter(PARAM_CONNECT_TIMEOUT, DEFAULT_TIMEOUT_MILLIS)); + options.put(HTTPUtil.PARAM_SOCKET_TIMEOUT, getParameter(PARAM_SOCKET_TIMEOUT, getParameter(PARAM_READ_TIMEOUT, DEFAULT_TIMEOUT_MILLIS))); try { X509CertificateFactory cf = (X509CertificateFactory) getSession().getCertificateFactory(CertificateFactory.COMPID_SSL_TRUST_CERTIFICATE_FACTORY); if (cf != null) { @@ -32,4 +41,39 @@ public Map getHttpOptions() throws OpenAS2Exception { } return options; } + + /** + * Builds the HTTP options for sending to the partner of a partnership: the module's settings, with + * the connect and socket timeouts overridden by the partnership's own "connecttimeout" and + * "sockettimeout" attributes where it sets them, so one slow partner can be given longer without + * raising the timeout for every partner. + * + * @param partnership - the partnership being sent on + * @return the options to pass to HTTPUtil + * @throws OpenAS2Exception if the module's own options cannot be built + */ + public Map getHttpOptions(Partnership partnership) throws OpenAS2Exception { + Map options = getHttpOptions(); + overrideTimeout(options, partnership, PARAM_CONNECT_TIMEOUT, HTTPUtil.PARAM_CONNECT_TIMEOUT); + overrideTimeout(options, partnership, PARAM_SOCKET_TIMEOUT, HTTPUtil.PARAM_SOCKET_TIMEOUT); + return options; + } + + private void overrideTimeout(Map options, Partnership partnership, String attribute, String option) { + String value = partnership.getAttribute(attribute); + if (value == null || value.trim().isEmpty()) { + return; + } + try { + if (Integer.parseInt(value.trim()) < 0) { + throw new NumberFormatException("negative"); + } + } catch (NumberFormatException e) { + // A bad value would otherwise fail every send to this partner when the request is built + LOG.warn("Ignoring the \"" + attribute + "\" attribute of partnership " + partnership.getName() + ": \"" + value + + "\" is not a number of milliseconds. Using " + options.get(option) + " instead."); + return; + } + options.put(option, value.trim()); + } } diff --git a/Server/src/main/java/org/openas2/processor/sender/MDNSenderModule.java b/Server/src/main/java/org/openas2/processor/sender/MDNSenderModule.java index c2084a5e..891466fd 100644 --- a/Server/src/main/java/org/openas2/processor/sender/MDNSenderModule.java +++ b/Server/src/main/java/org/openas2/processor/sender/MDNSenderModule.java @@ -144,7 +144,7 @@ private boolean sendAsyncMDN(MessageMDN mdn, String url, DispositionType disposi logger.debug("ASYNC MDN attempting connection to: " + url + mdn.getMessage().getLogMsgID()); } long maxSize = msg.getPartnership().getNoChunkedMaxSize(); - Map httpOptions = getHttpOptions(); + Map httpOptions = getHttpOptions(msg.getPartnership()); httpOptions.put(HTTPUtil.PARAM_HTTP_USER, msg.getPartnership().getAttribute(HTTPUtil.PARAM_HTTP_USER)); httpOptions.put(HTTPUtil.PARAM_HTTP_PWD, msg.getPartnership().getAttribute(HTTPUtil.PARAM_HTTP_PWD)); // Mutual TLS client certificate - partnership attributes override global properties diff --git a/Server/src/test/java/org/openas2/processor/sender/InterruptedSendReportStartupTest.java b/Server/src/test/java/org/openas2/processor/sender/InterruptedSendReportStartupTest.java index 28d58a60..851cbfad 100644 --- a/Server/src/test/java/org/openas2/processor/sender/InterruptedSendReportStartupTest.java +++ b/Server/src/test/java/org/openas2/processor/sender/InterruptedSendReportStartupTest.java @@ -13,6 +13,7 @@ import org.slf4j.LoggerFactory; import java.io.File; +import java.io.FileOutputStream; import java.nio.charset.StandardCharsets; import java.nio.file.Files; @@ -29,6 +30,13 @@ public class InterruptedSendReportStartupTest extends BaseServerSetup { @BeforeAll public void setUp() throws Exception { super.createFileSystemResources(this.getClass().getName()); + /* + * A pending folder of this test's own: the test configuration's is shared between test classes, + * and any file another test leaves there changes what the report can conclude about this one. + */ + try (FileOutputStream fos = new FileOutputStream(openAS2PropertiesFile)) { + fos.write(("storageBaseDir=" + new File(configDir, "data").getAbsolutePath().replace('\\', '/') + "\n").getBytes()); + } super.setup(); } diff --git a/Server/src/test/java/org/openas2/processor/sender/SendTimeoutTest.java b/Server/src/test/java/org/openas2/processor/sender/SendTimeoutTest.java new file mode 100644 index 00000000..2af5de1a --- /dev/null +++ b/Server/src/test/java/org/openas2/processor/sender/SendTimeoutTest.java @@ -0,0 +1,209 @@ +package org.openas2.processor.sender; + +import com.sun.net.httpserver.HttpServer; +import org.junit.jupiter.api.AfterAll; +import org.junit.jupiter.api.BeforeAll; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.TestInstance; +import org.junit.jupiter.api.TestInstance.Lifecycle; +import org.mockito.Mockito; +import org.openas2.app.BaseServerSetup; +import org.openas2.message.FileAttribute; +import org.openas2.message.Message; +import org.openas2.partner.Partnership; +import org.openas2.processor.resender.ResenderModule; +import org.openas2.util.HTTPUtil; + +import java.io.File; +import java.io.FileOutputStream; +import java.io.InputStream; +import java.io.OutputStream; +import java.net.InetSocketAddress; +import java.util.HashMap; +import java.util.Map; +import java.util.concurrent.atomic.AtomicInteger; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertTrue; + +/** + * Verifies how the timeouts for sending to a partner are worked out. + *

+ * The sender module's "readtimeout" was replaced by "sockettimeout" when it turned out to be mapped to + * the wrong HttpClient setting, but the shipped config.xml and the HowTo still set "readtimeout", which + * was then read and never applied, so the socket timeout stayed at 60 seconds whatever it said. It is + * honoured again as the socket timeout when "sockettimeout" is not set. A partnership can also set its + * own "connecttimeout" and "sockettimeout", so one slow partner can be given longer without raising the + * timeout for every partner. + */ +@TestInstance(Lifecycle.PER_CLASS) +public class SendTimeoutTest extends BaseServerSetup { + + // Long enough that a socket timeout well below it is clearly what ended the send + private static final int PARTNER_RESPONSE_DELAY_MILLIS = 3000; + + private HttpServer slowPartner; + private String slowPartnerUrl; + private final AtomicInteger requestsReceived = new AtomicInteger(); + + @BeforeAll + public void setUp() throws Exception { + super.createFileSystemResources(this.getClass().getName()); + // Pending and resend files go to a folder of this test's own rather than the one the test + // configuration shares between test classes, so the sends here leave nothing for others to find + try (FileOutputStream fos = new FileOutputStream(openAS2PropertiesFile)) { + fos.write(("storageBaseDir=" + new File(configDir, "data").getAbsolutePath().replace('\\', '/') + "\n").getBytes()); + } + super.setup(); + + slowPartner = HttpServer.create(new InetSocketAddress("127.0.0.1", 0), 0); + slowPartner.createContext("/as2", exchange -> { + try (InputStream in = exchange.getRequestBody()) { + in.readAllBytes(); + } + requestsReceived.incrementAndGet(); + try { + Thread.sleep(PARTNER_RESPONSE_DELAY_MILLIS); + } catch (InterruptedException e) { + Thread.currentThread().interrupt(); + } + exchange.sendResponseHeaders(200, 0); + try (OutputStream os = exchange.getResponseBody()) { + os.flush(); + } + }); + slowPartner.setExecutor(java.util.concurrent.Executors.newCachedThreadPool()); + slowPartner.start(); + slowPartnerUrl = "http://127.0.0.1:" + slowPartner.getAddress().getPort() + "/as2"; + } + + @AfterAll + public void tearDown() throws Exception { + if (slowPartner != null) { + slowPartner.stop(0); + } + super.tearDown(); + } + + /* -------------------------------------------------------------- the module's own settings */ + + @Test + public void withNothingSetBothTimeoutsAreSixtySeconds() throws Exception { + Map options = sender(new HashMap()).getHttpOptions(); + + assertEquals("60000", options.get(HTTPUtil.PARAM_SOCKET_TIMEOUT)); + assertEquals("60000", options.get(HTTPUtil.PARAM_CONNECT_TIMEOUT)); + } + + @Test + public void readTimeoutIsHonouredAsTheSocketTimeout() throws Exception { + // What the shipped config.xml and the HowTo set, which was read and then never applied + Map options = sender(params(HttpSenderModule.PARAM_READ_TIMEOUT, "120000")).getHttpOptions(); + + assertEquals("120000", options.get(HTTPUtil.PARAM_SOCKET_TIMEOUT)); + } + + @Test + public void socketTimeoutWinsOverReadTimeoutWhenBothAreSet() throws Exception { + Map params = params(HttpSenderModule.PARAM_READ_TIMEOUT, "120000"); + params.put(HttpSenderModule.PARAM_SOCKET_TIMEOUT, "300000"); + + assertEquals("300000", sender(params).getHttpOptions().get(HTTPUtil.PARAM_SOCKET_TIMEOUT)); + } + + /* ---------------------------------------------------------------- per partnership overrides */ + + @Test + public void aPartnershipCanSetItsOwnTimeouts() throws Exception { + Partnership partnership = new Partnership(); + partnership.setAttribute(HttpSenderModule.PARAM_SOCKET_TIMEOUT, "900000"); + partnership.setAttribute(HttpSenderModule.PARAM_CONNECT_TIMEOUT, "15000"); + + Map options = sender(params(HttpSenderModule.PARAM_SOCKET_TIMEOUT, "60000")).getHttpOptions(partnership); + + assertEquals("900000", options.get(HTTPUtil.PARAM_SOCKET_TIMEOUT)); + assertEquals("15000", options.get(HTTPUtil.PARAM_CONNECT_TIMEOUT)); + } + + @Test + public void aPartnershipThatSetsNothingUsesTheModuleSettings() throws Exception { + Map options = sender(params(HttpSenderModule.PARAM_SOCKET_TIMEOUT, "200000")).getHttpOptions(new Partnership()); + + assertEquals("200000", options.get(HTTPUtil.PARAM_SOCKET_TIMEOUT)); + assertEquals("60000", options.get(HTTPUtil.PARAM_CONNECT_TIMEOUT)); + } + + @Test + public void anInvalidPartnershipValueIsIgnoredRatherThanFailingEverySend() throws Exception { + Partnership partnership = new Partnership(); + partnership.setName("PartnerA-typo"); + partnership.setAttribute(HttpSenderModule.PARAM_SOCKET_TIMEOUT, "5 minutes"); + partnership.setAttribute(HttpSenderModule.PARAM_CONNECT_TIMEOUT, "-1"); + + Map options = sender(params(HttpSenderModule.PARAM_SOCKET_TIMEOUT, "200000")).getHttpOptions(partnership); + + assertEquals("200000", options.get(HTTPUtil.PARAM_SOCKET_TIMEOUT)); + assertEquals("60000", options.get(HTTPUtil.PARAM_CONNECT_TIMEOUT)); + } + + /* --------------------------------------------------------------------- applied to a real send */ + + @Test + public void thePartnershipSocketTimeoutIsWhatEndsAWaitForASlowPartner() throws Exception { + long elapsed = timeSendToSlowPartner("400"); + + assertTrue(elapsed < PARTNER_RESPONSE_DELAY_MILLIS - 500, + "a 400ms partnership socket timeout should end the send before the partner answers, took " + elapsed + "ms"); + } + + @Test + public void withoutAnOverrideTheSendWaitsForTheSlowPartner() throws Exception { + // The control for the test above: the module default comfortably outlasts the partner + long elapsed = timeSendToSlowPartner(null); + + assertTrue(elapsed >= PARTNER_RESPONSE_DELAY_MILLIS - 100, + "with the 60 second default the send should wait for the partner's answer, took " + elapsed + "ms"); + } + + /* ------------------------------------------------------------------------------------- fixtures */ + + private long timeSendToSlowPartner(String partnershipSocketTimeout) throws Exception { + Message msg = getSimpleTestMsg(); + addSendPayloadStuffToMsg("timeout-test.txt", msg); + msg.getPartnership().setAttribute(Partnership.PA_AS2_URL, slowPartnerUrl); + // What a directory poller sets up before handing a file to the sender + msg.setAttribute(FileAttribute.MA_PENDINGFILE, new File(configDir, "pending-" + System.nanoTime()).getAbsolutePath()); + if (partnershipSocketTimeout != null) { + msg.getPartnership().setAttribute(HttpSenderModule.PARAM_SOCKET_TIMEOUT, partnershipSocketTimeout); + } + int before = requestsReceived.get(); + AS2SenderModule sender = sender(new HashMap()); + + long started = System.nanoTime(); + try { + Map options = new HashMap(); + // No retries, so a failed send is abandoned rather than queued for the resender + options.put(ResenderModule.OPTION_RETRIES, "0"); + sender.handle(SenderModule.DO_SEND, msg, options); + } catch (Exception e) { + // A timed out or unusable response is handed to the resender or reported; only the time matters + } + long elapsed = (System.nanoTime() - started) / 1_000_000; + + assertEquals(before + 1, requestsReceived.get(), "the message should have reached the partner"); + return elapsed; + } + + private AS2SenderModule sender(Map moduleParams) { + AS2SenderModule sender = Mockito.spy(new AS2SenderModule()); + Mockito.doReturn(session).when(sender).getSession(); + sender.getParameters().putAll(moduleParams); + return sender; + } + + private Map params(String name, String value) { + Map params = new HashMap(); + params.put(name, value); + return params; + } +} diff --git a/changes.txt b/changes.txt index fdb885bf..44e314f2 100644 --- a/changes.txt +++ b/changes.txt @@ -1,5 +1,18 @@ **IMPORTANT NOTE**: Please review upgrade notes in the RELEASE-NOTES.md if you are upgrading +Version UNRELEASED +=========================== + +This is a minor enhancement and bugfix release. +1. Fix the AS2 sender's "readtimeout" having no effect, and allow the send timeouts to be set per partnership. When + "sockettimeout" replaced "readtimeout" as the setting for how long to wait for a partner's response, the shipped + config.xml and the HowTo kept setting "readtimeout", which was then read and never applied, so sends always timed out + after 60 seconds whatever it said. "readtimeout" is honoured again as the socket timeout when "sockettimeout" is not + set. A partnership can also set its own "connecttimeout" and "sockettimeout" attributes, in milliseconds, so a slow + partner can be given longer without raising the timeout for every partner. Note that a "readtimeout" set in + openas2.properties now takes effect: the sample file sets module.AS2SenderModule.readtimeout=3000000, 50 minutes, so + check the value if your properties file was copied from it. + Version 4.14.0 2026-10-13 =========================== diff --git a/docs/OpenAS2HowTo.odt b/docs/OpenAS2HowTo.odt index b8e93953..8bb59059 100644 Binary files a/docs/OpenAS2HowTo.odt and b/docs/OpenAS2HowTo.odt differ diff --git a/docs/OpenAS2HowTo.pdf b/docs/OpenAS2HowTo.pdf index fc2b5cbd..54705cb5 100644 Binary files a/docs/OpenAS2HowTo.pdf and b/docs/OpenAS2HowTo.pdf differ