Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
13 changes: 13 additions & 0 deletions RELEASE-NOTES.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
===========================

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -189,7 +189,7 @@ private void sendMessage(String url, Message msg, MimeBodyPart securedData) thro
logger.info("Connecting to: " + url + msg.getLogMsgID());
}

Map<String, Object> httpOptions = getHttpOptions();
Map<String, Object> 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
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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<String, Object> getHttpOptions() throws OpenAS2Exception {
Map<String, Object> options = new HashMap<String, Object>();
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) {
Expand All @@ -32,4 +41,39 @@ public Map<String, Object> 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<String, Object> getHttpOptions(Partnership partnership) throws OpenAS2Exception {
Map<String, Object> 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<String, Object> 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());
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -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<String, Object> httpOptions = getHttpOptions();
Map<String, Object> 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
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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;

Expand All @@ -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();
}

Expand Down
209 changes: 209 additions & 0 deletions Server/src/test/java/org/openas2/processor/sender/SendTimeoutTest.java
Original file line number Diff line number Diff line change
@@ -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.
* <p>
* 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<String, Object> options = sender(new HashMap<String, String>()).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<String, Object> options = sender(params(HttpSenderModule.PARAM_READ_TIMEOUT, "120000")).getHttpOptions();

assertEquals("120000", options.get(HTTPUtil.PARAM_SOCKET_TIMEOUT));
}

@Test
public void socketTimeoutWinsOverReadTimeoutWhenBothAreSet() throws Exception {
Map<String, String> 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<String, Object> 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<String, Object> 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<String, Object> 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<String, String>());

long started = System.nanoTime();
try {
Map<String, Object> options = new HashMap<String, Object>();
// 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<String, String> moduleParams) {
AS2SenderModule sender = Mockito.spy(new AS2SenderModule());
Mockito.doReturn(session).when(sender).getSession();
sender.getParameters().putAll(moduleParams);
return sender;
}

private Map<String, String> params(String name, String value) {
Map<String, String> params = new HashMap<String, String>();
params.put(name, value);
return params;
}
}
13 changes: 13 additions & 0 deletions changes.txt
Original file line number Diff line number Diff line change
@@ -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
===========================

Expand Down
Binary file modified docs/OpenAS2HowTo.odt
Binary file not shown.
Binary file modified docs/OpenAS2HowTo.pdf
Binary file not shown.
Loading